Compare commits

...

134 commits
v0.3.2 ... 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
booleanmaybe
404b4be3be theme-aware auto-generated caption colors 2026-04-14 00:08:24 -04:00
booleanmaybe
335743b874 fix go.mod 2026-04-13 15:48:54 -04:00
booleanmaybe
db900cfa5e per-theme navidown styles 2026-04-13 14:57:13 -04:00
booleanmaybe
8cbce760e3 fix go.mod 2026-04-13 12:41:44 -04:00
booleanmaybe
9e40a0f56b named color themes 2026-04-13 12:30:24 -04:00
booleanmaybe
9eee3ea019 theme-aware codebox styling 2026-04-13 10:38:33 -04:00
booleanmaybe
a5a7c2d124 add image requirements link 2026-04-13 09:18:58 -04:00
booleanmaybe
6f0ecf93b3 add color for task border to palette 2026-04-12 22:39:17 -04:00
booleanmaybe
ab06d1a08e cache effective theme to prevent OSC 11 hang after tview takes terminal 2026-04-12 21:32:07 -04:00
booleanmaybe
0fdeed41a5 fix go.mod 2026-04-12 21:16:34 -04:00
booleanmaybe
7656ae91ac light theme and termenv-based auto-detection 2026-04-12 21:02:04 -04:00
booleanmaybe
e93675c34f compact colors round 1 2026-04-11 23:42:32 -04:00
booleanmaybe
0be985a077 group colors by value 2026-04-10 16:07:34 -04:00
booleanmaybe
b5f2ad66fa add recipes 2026-04-10 00:32:32 -04:00
booleanmaybe
f610e74660 update README for ruki 2026-04-09 22:55:57 -04:00
boolean-maybe
0fb4baa899
Merge pull request #47 from boolean-maybe/feature/ruki-plugin
feature/ruki plugin
2026-04-09 17:17:20 -04:00
booleanmaybe
3239c0afad add coverage 2026-04-09 14:55:42 -04:00
booleanmaybe
b5e352533c remove plugin-level filter 2026-04-09 13:56:25 -04:00
booleanmaybe
b89e8c7d1e legacy workflow.yaml converter 2026-04-09 13:35:32 -04:00
booleanmaybe
d72283a153 update AI docs 2026-04-09 10:33:02 -04:00
booleanmaybe
69a0b01c0f collect colors 2026-04-09 09:21:48 -04:00
booleanmaybe
5eed6072cb update docs 2026-04-08 23:39:56 -04:00
booleanmaybe
00ba1ed8ec replace plugin expressions with ruki 2026-04-08 22:46:46 -04:00
booleanmaybe
65215da3b0 add common triggers 2026-04-08 20:33:35 -04:00
booleanmaybe
4875249855 show statusline on all warnings 2026-04-08 18:19:19 -04:00
booleanmaybe
7405ed03cc trigger message in task edit 2026-04-08 17:50:17 -04:00
booleanmaybe
aa37ef1722 upgrade CI go version to 1.25.x 2026-04-08 14:30:55 -04:00
booleanmaybe
7eb68505de fix github linter 2026-04-08 14:16:35 -04:00
booleanmaybe
5a7e30cb77 fix github linter 2026-04-08 14:09:01 -04:00
booleanmaybe
b8deb9e5e2 fix default workflow 2026-04-08 13:58:55 -04:00
booleanmaybe
b46649b714 make all fields camel case 2026-04-08 13:44:18 -04:00
booleanmaybe
7045d51003 highlight error in statusline 2026-04-08 12:43:20 -04:00
boolean-maybe
381f98c3af
Merge pull request #46 from boolean-maybe/feature/ruki
feature/ruki
2026-04-08 11:40:07 -04:00
booleanmaybe
682bcb8ace add coverage 2026-04-08 00:34:28 -04:00
booleanmaybe
182a0fe29b fix constructor 2026-04-07 23:59:45 -04:00
booleanmaybe
7957655487 semantic validator 2026-04-07 23:54:40 -04:00
booleanmaybe
7d48d538c3 time trigger executor 2026-04-07 23:54:40 -04:00
booleanmaybe
b5921c68b4 time triggers syntax 2026-04-07 23:54:40 -04:00
booleanmaybe
bd40184173 fix windows test 2026-04-07 23:54:37 -04:00
booleanmaybe
7aa7b4bc1e add coverage 2026-04-07 23:53:55 -04:00
booleanmaybe
09b63e9de8 add coverage 2026-04-07 23:53:52 -04:00
booleanmaybe
2d1a66d086 add coverage 2026-04-07 23:51:50 -04:00
booleanmaybe
a31fc25e4d add coverage 2026-04-07 23:51:50 -04:00
booleanmaybe
1cf10874a8 fix unit run 2026-04-07 23:51:50 -04:00
booleanmaybe
c466ca3059 execute triggers 2026-04-07 23:51:50 -04:00
booleanmaybe
116825334c replace contains with in 2026-04-07 23:51:50 -04:00
booleanmaybe
7ffd72f978 mutation gate 2026-04-07 23:51:19 -04:00
booleanmaybe
ca5a16e984 execute create and delete 2026-04-07 23:46:54 -04:00
booleanmaybe
d78b187ac0 improve coverage 2026-04-07 23:46:54 -04:00
booleanmaybe
f99a76fa56 execute update 2026-04-07 23:46:54 -04:00
booleanmaybe
bf2c2408ec improve coverage 2026-04-07 23:46:54 -04:00
booleanmaybe
fe47de1d68 fix dependencies 2026-04-07 23:46:54 -04:00
booleanmaybe
9266848570 ruki query runtime 2026-04-07 23:46:54 -04:00
booleanmaybe
1ee7c5a5fe select executor 2026-04-07 23:46:54 -04:00
booleanmaybe
308c1f8a5e select field list 2026-04-07 23:46:53 -04:00
booleanmaybe
2d9d81b741 refactor ruki keyword parsing 2026-04-07 23:46:53 -04:00
booleanmaybe
47c385513e order by support 2026-04-07 23:46:53 -04:00
booleanmaybe
2efaeb25f5 ruki documentation 2026-04-07 23:46:53 -04:00
booleanmaybe
1bf6bfa064 ruki parser 2026-04-07 23:46:53 -04:00
booleanmaybe
814494751d extract status and type registries into workflow package 2026-04-07 23:46:53 -04:00
booleanmaybe
5a8918b904 improve navigation in plugin 2026-04-07 23:10:12 -04:00
booleanmaybe
676a0ec97b update image 2026-04-07 10:40:08 -04:00
booleanmaybe
5781f37ffa remove updates 2026-04-06 23:22:01 -04:00
booleanmaybe
d9b5d88877 remove gif excess 2026-04-06 22:44:06 -04:00
booleanmaybe
81ecdabeae markdown viewer image 2026-04-06 20:58:03 -04:00
booleanmaybe
614f289fac convert demo to png 2026-04-05 19:07:35 -04:00
booleanmaybe
bd59ef7674 release update 2026-04-03 00:45:29 -04:00
booleanmaybe
c2a464b81f add test markdown file 2026-04-02 22:30:10 -04:00
booleanmaybe
c364bfba14 resolve remote images 2026-04-02 22:21:38 -04:00
booleanmaybe
edc347d258 markdown test file 2026-04-02 20:49:44 -04:00
booleanmaybe
6d65d07c10 refresh markdown viewer 2026-04-02 19:10:52 -04:00
booleanmaybe
16dd296b07 help command 2026-04-01 12:31:11 -04:00
booleanmaybe
c0621840e1 new.md precedence 2026-04-01 10:58:10 -04:00
booleanmaybe
1737e042a7 upgrade linter 2026-03-31 23:43:12 -04:00
booleanmaybe
e8fe742d01 add git demo 2026-03-31 23:38:00 -04:00
booleanmaybe
7cd8922f2f improve state and ER mermaid diagrams 2026-03-31 23:22:03 -04:00
booleanmaybe
b7982ac028 reverse points edit direction 2026-03-31 21:52:56 -04:00
booleanmaybe
91638fbcd2 chat with AI agent 2026-03-31 21:28:06 -04:00
booleanmaybe
39a2239125 fix large diagrams 2026-03-30 00:34:12 -04:00
booleanmaybe
ef4d8b63c4 improve mermaid and SVG rendering 2026-03-27 21:41:21 -04:00
booleanmaybe
0b95c80ac9 config precedence/merging rules 2026-03-26 13:15:02 -04:00
323 changed files with 60415 additions and 8555 deletions

31
.doc/doki/doc/ai.md Normal file
View file

@ -0,0 +1,31 @@
# AI collaboration
## AI skills
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
If installed you can:
- work with [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
- create, find, modify and delete tikis using AI
- create tikis/dokis directly from Markdown files
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`
- Keep a history of prompts/plans by saving prompts or plans with your repo
## Chat with AI
If you [configured](config.md) an AI agent these features will be enabled:
- open current tiki in an AI agent such as [Claude Code](https://code.claude.com) and have it read it. You can then chat or edit it without having to find it first
## Configuration
in your `config.yaml`:
```yaml
# AI agent integration
ai:
agent: claude # AI tool for chat: "claude", "gemini", "codex", "opencode"
# Enables AI collaboration features
# Omit or leave empty to disable
```

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

@ -17,23 +17,8 @@
Files stored here:
- `config.yaml` - User-global configuration
- `workflow.yaml` - Statuses and plugin/view definitions
- `new.md` - Custom task template
- Plugin definition files (e.g., custom plugin YAML files)
**User Cache** (temporary data):
- **Linux**: `~/.cache/tiki` (or `$XDG_CACHE_HOME/tiki`)
- **macOS**: `~/Library/Caches/tiki`
- **Windows**: `%LOCALAPPDATA%\tiki`
**Project-Local** (tasks and documentation, in git):
- `.doc/tiki/` - Task files (tikis)
- `.doc/doki/` - Documentation files (dokis)
- `.doc/tiki/config.yaml` - Project-specific config (overrides user config)
**Configuration Priority** (first found wins):
1. Project config directory (`.doc/tiki/config.yaml`) - highest priority
2. User config directory (`~/.config/tiki/config.yaml`)
3. Current directory (`./config.yaml` - development only)
**Environment Variables**:
- `XDG_CONFIG_HOME` - Override config directory location (all platforms)
@ -45,6 +30,53 @@ export XDG_CONFIG_HOME=~/my-config
tiki # Will use ~/my-config/tiki/ for configuration
```
## Precedence and merging
`tiki` looks for configuration in three locations, from least specific to most specific:
1. **User config directory** (platform-specific, see [above](#configuration-directories)) - your personal defaults
2. **Project config directory** (`.doc/`) - shared with the team via git
3. **Current working directory** (`./`) - local overrides, useful during development
The more specific location always wins. This means each project can have its own workflow, statuses, and views that differ from your personal defaults. A design-team project might use statuses like "Draft / Review / Approved" while an engineering project uses "Backlog / In Progress / Done" — each defined in their own `.doc/workflow.yaml`.
### config.yaml merging
All `config.yaml` files found are merged together. A project config only needs to specify the values it wants to change — everything else is inherited from the user config. Missing values fall back to built-in defaults.
Search order: user config dir (base) → `.doc/config.yaml` (project) → cwd (highest priority).
### new.md (task template)
`new.md` is searched in the same three locations but is **not merged** — the single highest-priority file found wins. If a project provides `.doc/new.md`, it completely replaces the user-level template. If no `new.md` is found anywhere, a built-in embedded template is used.
Search order: user config dir → `.doc/new.md` (project) → cwd. Last match wins.
### workflow.yaml merging
`workflow.yaml` is searched in all three locations. Files that exist are loaded and merged sequentially.
Search order: user config dir (base) → `.doc/workflow.yaml` (project) → cwd (highest priority).
**Statuses** — last file with a `statuses:` section wins (complete replacement). A project that defines its own statuses fully replaces the user-level defaults.
**Views (plugins)** — merged by name across files. The user config is the base; project and cwd files override individual fields:
- Non-empty fields in the override replace the base (description, key, view mode)
- Non-empty arrays in the override replace the entire base array (lanes, actions)
- 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:
plugins: []
```
### config.yaml
Example `config.yaml` with available settings:
@ -71,16 +103,21 @@ Backlog:
# Appearance settings
appearance:
theme: auto # Theme: "auto" (detect from terminal), "dark", "light"
theme: auto # Theme: "auto" (detect from terminal), "dark", "light",
# or a named theme: "dracula", "tokyo-night", "gruvbox-dark",
# "catppuccin-mocha", "solarized-dark", "nord", "monokai",
# "one-dark", "catppuccin-latte", "solarized-light",
# "gruvbox-light", "github-light"
gradientThreshold: 256 # Minimum terminal colors for gradient rendering
# Options: 16, 256, 16777216 (truecolor)
# Gradients disabled if terminal has fewer colors
# Default: 256 (works well on most terminals)
codeBlock:
theme: dracula # Chroma syntax theme for code blocks
# Examples: "dracula", "monokai", "catppuccin-macchiato"
background: "#282a36" # Code block background color (hex or ANSI e.g. "236")
border: "#6272a4" # Code block border color (hex or ANSI e.g. "244")
# AI agent integration
ai:
agent: claude # AI tool for chat: "claude", "gemini", "codex", "opencode"
# Enables AI collaboration features
# Omit or leave empty to disable
```
### workflow.yaml
@ -113,86 +150,109 @@ statuses:
done: true
views:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
foreground: "#87ceeb"
background: "#25496a"
key: "F1"
lanes:
- name: Ready
filter: status = 'ready' and type != 'epic'
action: status = 'ready'
- name: In Progress
filter: status = 'in_progress' and type != 'epic'
action: status = 'in_progress'
- name: Review
filter: status = 'review' and type != 'epic'
action: status = 'review'
- name: Done
filter: status = 'done' and type != 'epic'
action: status = 'done'
sort: Priority, CreatedAt
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
foreground: "#5fff87"
background: "#0b3d2e"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: status = 'backlog' and type != 'epic'
actions:
- key: "b"
label: "Add to board"
action: status = 'ready'
sort: Priority, ID
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
foreground: "#f4d6a6"
background: "#5a3d1b"
key: Ctrl-R
lanes:
- name: Recent
columns: 4
filter: NOW - UpdatedAt < 24hours
sort: UpdatedAt DESC
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
foreground: "#e2e8f0"
background: "#2a5f5a"
key: "F4"
lanes:
- name: Now
columns: 1
width: 25
filter: type = 'epic' AND status = 'ready'
action: status = 'ready'
- name: Next
columns: 1
width: 25
filter: type = 'epic' AND status = 'backlog' AND priority = 1
action: status = 'backlog', priority = 1
- name: Later
columns: 2
width: 50
filter: type = 'epic' AND status = 'backlog' AND priority > 1
action: status = 'backlog', priority = 2
sort: Priority, Points DESC
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
foreground: "#bcbcbc"
background: "#003399"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
foreground: "#ff9966"
background: "#2b3a42"
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,27 +47,54 @@ 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.
Place `new.md` in your user config directory to override the built-in defaults
Place `new.md` in your config directory to override the built-in defaults
The file uses YAML frontmatter for field defaults
### Built-in default
```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,38 +111,35 @@ how Backlog is defined:
```yaml
views:
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
foreground: "#5fff87"
background: "#0b3d2e"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: status = 'backlog' and type != 'epic'
actions:
- key: "b"
label: "Add to board"
action: status = 'ready'
sort: Priority, ID
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.
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
You define the name, description, caption colors, hotkey, tiki filter and sorting. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory
You define the name, description, hotkey, and `ruki` expressions for filtering and actions. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory
Likewise the documentation is just a plugin:
```yaml
views:
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
foreground: "#ff9966"
background: "#2b3a42"
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`
@ -116,31 +155,28 @@ definition that roughly mimics the board:
```yaml
name: Custom
foreground: "#5fff87"
background: "#005f00"
key: "F4"
sort: Priority, Title
lanes:
- name: Ready
columns: 1
width: 20
filter: status = 'ready'
action: status = 'ready'
filter: select where status = "ready" order by priority, title
action: update where id = id() set status="ready"
- name: In Progress
columns: 1
width: 30
filter: status = 'in_progress'
action: status = 'in_progress'
filter: select where status = "inProgress" order by priority, title
action: update where id = id() set status="inProgress"
- name: Review
columns: 1
width: 30
filter: status = 'review'
action: status = 'review'
filter: select where status = "review" order by priority, title
action: update where id = id() set status="review"
- name: Done
columns: 1
width: 20
filter: status = 'done'
action: status = 'done'
filter: select where status = "done" order by priority, title
action: update where id = id() set status="done"
```
### Lane width
@ -159,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,135 +225,153 @@ that apply to the currently selected tiki via a keyboard shortcut. These shortcu
actions:
- key: "b"
label: "Add to board"
action: status = 'ready'
action: update where id = id() set status="ready"
- key: "a"
label: "Assign to me"
action: assignee = CURRENT_USER
action: update where id = id() set assignee=user()
```
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - an action expression (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.
### Action expression
`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki.
The `action: status = 'backlog'` statement in a plugin is an action to be run when a tiki is moved into the lane. Here `=`
means `assign` so status is assigned `backlog` when the tiki is moved. Likewise you can manipulate tags using `+-` (add)
or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and `UI` tags to a tiki
### Input-backed actions
#### Supported Fields
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.
- `status` - set workflow status (must be a key defined in `workflow.yaml` statuses)
- `type` - set task type: `story`, `bug`, `spike`, `epic` (case-insensitive)
- `priority` - set numeric priority (1-5)
- `points` - set numeric points (0 or positive, up to max points)
- `assignee` - set assignee string
- `tags` - add/remove tags (list)
- `dependsOn` - add/remove dependency tiki IDs (list)
- `due` - set due date (YYYY-MM-DD format)
- `recurrence` - set recurrence pattern (cron format: `0 0 * * *`, `0 0 * * MON`, `0 0 1 * *`)
#### Operators
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`, `due`, `recurrence`
- `+=` adds tags or dependencies, `-=` removes them
- multiple operations are separated by commas: `status=done, tags+=[moved]`
#### Literals
- strings can be quoted (`'in_progress'`, `"alex"`) or bare (`done`, `alex`)
- use quotes when the value has spaces
- integers are used for `priority` and `points`
- tag lists use brackets: `tags += [ui, frontend]`
- `CURRENT_USER` assigns the current git user to `assignee`
- example: `assignee = CURRENT_USER`
### Filter expression
The `filter: status = 'backlog'` statement in a plugin is a filter expression that determines which tikis appear in the view.
#### Supported Fields
You can filter on these task fields:
- `id` - Task identifier (e.g., 'TIKI-m7n2xk')
- `title` - Task title text (case-insensitive)
- `type` - Task type: 'story', 'bug', 'spike', or 'epic' (case-insensitive)
- `status` - Workflow status (must match a key defined in `workflow.yaml` statuses)
- `assignee` - Assigned user (case-insensitive)
- `priority` - Numeric priority value
- `points` - Story points estimate
- `tags` (or `tag`) - List of tags (case-insensitive)
- `dependsOn` - List of dependency tiki IDs
- `due` - Due date (YYYY-MM-DD format, supports time arithmetic)
- `recurrence` - Recurrence pattern (cron string, compared as string)
- `createdAt` - Creation timestamp
- `updatedAt` - Last update timestamp
All string comparisons are case-insensitive.
#### Operators
- **Comparison**: `=` (or `==`), `!=`, `>`, `>=`, `<`, `<=`
- **Logical**: `AND`, `OR`, `NOT` (precedence: NOT > AND > OR)
- **Membership**: `IN`, `NOT IN` (check if value in list using `[val1, val2]`)
- **Grouping**: Use parentheses `()` to control evaluation order
#### Literals and Special Values
**Special expressions**:
- `CURRENT_USER` - Resolves to the current git user (works in comparisons and IN lists)
- `NOW` - Current timestamp
**Time expressions**:
- `NOW - UpdatedAt` - Time elapsed since update
- `NOW - CreatedAt` - Time since creation
- Duration units: `min`/`minutes`, `hour`/`hours`, `day`/`days`, `week`/`weeks`, `month`/`months`
- Examples: `2hours`, `14days`, `3weeks`, `60min`, `1month`
- Operators: `+` (add), `-` (subtract or compute duration)
**Special tag semantics**:
- `tags IN ['ui', 'frontend']` matches if ANY task tag matches ANY list value
- This allows intersection testing across tag arrays
#### Examples
```text
# Multiple statuses
status = 'ready' OR status = 'in_progress'
# With tags
tags IN ['frontend', 'urgent']
# High priority bugs
type = 'bug' AND priority = 0
# Features and ideas assigned to me
(type = 'feature' OR tags IN ['idea']) AND assignee = CURRENT_USER
# Unassigned large tasks
assignee = '' AND points >= 5
# Recently created tasks not in backlog
(NOW - CreatedAt < 2hours) AND status != 'backlog'
```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
```
### Sorting
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.
The `sort` field determines the order in which tikis appear in the view. You can sort by one or more fields, and control the direction (ascending or descending).
Supported `input:` types: `string`, `int`, `bool`, `date` (YYYY-MM-DD), `timestamp` (RFC3339 or YYYY-MM-DD), `duration` (e.g. `2day`, `1week`).
#### Sort Syntax
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
```text
sort: Field1, Field2 DESC, Field3
### 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. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
#### Filter (select)
The `filter` field uses a `ruki` `select` statement to determine which tikis appear in a lane. Sorting is part of the select — use `order by` to control display order.
```sql
-- basic filter with sort
select where status = "backlog" and type != "epic" order by priority, id
-- recent items, most recent first
select where now() - updatedAt < 24hour order by updatedAt desc
-- multiple conditions
select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
-- assigned to me
select where assignee = user() order by priority
```
#### Examples
#### Action (update)
```text
# Sort by creation time descending (recent first), then priority, then title
sort: CreatedAt DESC, Priority, Title
The `action` field uses a `ruki` `update` statement. In plugin context, `id()` refers to the currently selected tiki.
```sql
-- set status on move
update where id = id() set status="ready"
-- set multiple fields
update where id = id() set status="backlog" priority=2
-- assign to current user
update where id = id() set assignee=user()
```
#### Supported fields
- `id` - task identifier (e.g., "TIKI-M7N2XK")
- `title` - task title text
- `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)
- `points` - story points estimate
- `tags` - list of tags
- `dependsOn` - list of dependency tiki IDs
- `due` - due date (YYYY-MM-DD format)
- `recurrence` - recurrence pattern (cron format)
- `createdAt` - creation timestamp
- `updatedAt` - last update timestamp
#### Conditions
- **Comparison**: `=`, `!=`, `>`, `>=`, `<`, `<=`
- **Logical**: `and`, `or`, `not` (precedence: not > and > or)
- **Membership**: `"value" in field`, `status not in ["done", "cancelled"]`
- **Emptiness**: `assignee is empty`, `tags is not empty`
- **Quantifiers**: `dependsOn any status != "done"`, `dependsOn all status = "done"`
- **Grouping**: parentheses `()` to control evaluation order
#### Literals and built-ins
- Strings: double-quoted (`"ready"`, `"alex"`)
- Integers: `1`, `5`
- Dates: `2026-03-25`
- Durations: `2hour`, `14day`, `3week`, `1month`
- Lists: `["bug", "frontend"]`
- `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).

View file

@ -0,0 +1,322 @@
# Customization Examples
- [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)
- [My tasks](#my-tasks--user-scoped-plugin)
- [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 — global plugin action
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
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.
```yaml
actions:
- key: "t"
label: "Tag my_project"
action: update where id = id() set tags=tags + ["my_project"]
```
## Custom status + reject action
Define a custom "rejected" status, then add a plugin action on the Backlog view to reject tasks.
```yaml
statuses:
- key: rejected
label: Rejected
emoji: "🚫"
done: true
```
```yaml
- name: Backlog
key: "F3"
lanes:
- name: Backlog
filter: select where status = "backlog" order by priority
actions:
- key: "r"
label: "Reject"
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.
```yaml
- name: All
key: "F5"
lanes:
- name: All
columns: 4
filter: select order by updatedAt desc
```
## Quick assign — lane-based assignment
Three lanes split tasks by assignee. Moving a task into Alice's or Bob's lane auto-assigns it.
```yaml
- name: Team
key: "F6"
lanes:
- name: Unassigned
filter: select where assignee is empty order by priority
- name: Alice
filter: select where assignee = "alice" order by priority
action: update where id = id() set assignee="alice"
- name: Bob
filter: select where assignee = "bob" order by priority
action: update where id = id() set assignee="bob"
```
## Stale task detection — time trigger + plugin
A daily trigger tags in-progress tasks that haven't been updated in a week. A dedicated plugin shows all flagged tasks.
```yaml
triggers:
- description: flag stale in-progress tasks
ruki: >
every 1day
update where status = "inProgress" and now() - updatedAt > 7day
and "attention" not in tags
set tags=tags + ["attention"]
```
```yaml
- name: Attention
key: "F7"
lanes:
- name: Needs Attention
columns: 4
filter: select where "attention" in tags order by updatedAt
```
## My tasks — user-scoped plugin
Shows only tasks assigned to the current git user.
```yaml
- name: My Tasks
key: "F8"
lanes:
- name: My Tasks
columns: 4
filter: select where assignee = user() order by priority
```
## Recent ideas — good or trash?
Two-lane plugin to review recent ideas and trash the ones you don't need. Moving to Trash swaps the "idea" tag for "trash".
```yaml
- name: Recent Ideas
description: "Review recent"
key: "F9"
lanes:
- name: Recent Ideas
columns: 3
filter: select where "idea" in tags and now() - createdAt < 7day order by createdAt desc
- name: Trash
columns: 1
filter: select where "trash" in tags order by updatedAt desc
action: update where id = id() set tags=tags - ["idea"] + ["trash"]
```
## Auto-delete stale tasks — time trigger
Deletes backlog tasks that were created over 3 months ago and haven't been updated in 2 months.
```yaml
triggers:
- description: auto-delete stale backlog tasks
ruki: >
every 1day
delete where status = "backlog"
and now() - createdAt > 3month
and now() - updatedAt > 2month
```
## Priority triage — five-lane plugin
One lane per priority level. Moving a task between lanes reassigns its priority.
```yaml
- name: Priorities
key: "F10"
lanes:
- name: Critical
filter: select where priority = 1 order by updatedAt desc
action: update where id = id() set priority=1
- name: High
filter: select where priority = 2 order by updatedAt desc
action: update where id = id() set priority=2
- name: Medium
filter: select where priority = 3 order by updatedAt desc
action: update where id = id() set priority=3
- name: Low
filter: select where priority = 4 order by updatedAt desc
action: update where id = id() set priority=4
- name: Minimal
filter: select where priority = 5 order by updatedAt desc
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.
```yaml
- name: By Topic
key: "F11"
lanes:
- name: Frontend
columns: 2
filter: select where "frontend" in tags order by priority
- name: Backend
columns: 2
filter: select where "backend" in tags order by priority
```

View file

@ -0,0 +1,298 @@
# Trigger Ideas
Candidate triggers for the default workflow. Each represents a common workflow pattern.
- [WIP limit per assignee](#wip-limit-per-assignee)
- [Require description for high-priority tasks](#require-description-for-high-priority-tasks)
- [Auto-create next occurrence for recurring tasks](#auto-create-next-occurrence-for-recurring-tasks)
- [Return stale in-progress tasks to backlog](#return-stale-in-progress-tasks-to-backlog)
- [Unblock tasks when dependencies complete](#unblock-tasks-when-dependencies-complete)
- [Prevent re-opening completed tasks directly](#prevent-re-opening-completed-tasks-directly)
- [Auto-assign creator on start](#auto-assign-creator-on-start)
- [Escalate overdue tasks](#escalate-overdue-tasks)
- [Require points estimate before review](#require-points-estimate-before-review)
- [Auto-tag bugs as urgent when high priority](#auto-tag-bugs-as-urgent-when-high-priority)
- [Prevent epics from being assigned](#prevent-epics-from-being-assigned)
- [Auto-remove urgent tag when priority drops](#auto-remove-urgent-tag-when-priority-drops)
- [Notify on critical task creation via webhook](#notify-on-critical-task-creation-via-webhook)
- [Spike time-box](#spike-time-box)
- [Require task breakdown for large estimates](#require-task-breakdown-for-large-estimates)
- [Propagate priority from epic to children](#propagate-priority-from-epic-to-children)
- [Auto-set due date for in-progress tasks](#auto-set-due-date-for-in-progress-tasks)
- [Require a title on creation](#require-a-title-on-creation)
- [Prevent creating tasks as done](#prevent-creating-tasks-as-done)
- [Epics require a description at creation](#epics-require-a-description-at-creation)
- [Prevent deleting epics with active children](#prevent-deleting-epics-with-active-children)
- [Block deletion of high-priority tasks](#block-deletion-of-high-priority-tasks)
## WIP limit per assignee
Prevent overloading a single person with too many active tasks.
```yaml
- description: limit work in progress to 3 tasks per assignee
ruki: >
before update
where new.status = "inProgress"
and new.assignee is not empty
and count(select where assignee = new.assignee and status = "inProgress" and id != new.id) >= 3
deny "WIP limit reached: assignee already has 3 tasks in progress"
```
## Require description for high-priority tasks
Force authors to provide context before something becomes urgent.
```yaml
- description: high priority tasks must have a description
ruki: >
before update
where new.priority <= 2 and new.description is empty
deny "high priority tasks require a description"
```
## Auto-create next occurrence for recurring tasks
Recurring tasks automatically regenerate when completed.
```yaml
- 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"
```
## Return stale in-progress tasks to backlog
Tasks untouched for 7 days are likely blocked or abandoned.
```yaml
- description: return stale in-progress tasks to backlog after 7 days
ruki: >
every 1day
update where status = "inProgress" and now() - updatedAt > 7day
set status="backlog"
```
## Unblock tasks when dependencies complete
Automatically promote tasks to ready when all their dependencies are done.
```yaml
- description: unblock ready tasks when all dependencies complete
ruki: >
after update
where new.status = "done" and old.status != "done"
update where old.id in dependsOn and dependsOn all status = "done"
set status="ready"
```
## Prevent re-opening completed tasks directly
Enforce a deliberate re-triage step through ready status.
```yaml
- description: prevent moving done tasks back to in-progress
ruki: >
before update
where old.status = "done" and new.status = "inProgress"
deny "cannot reopen completed tasks directly — move to ready first"
```
## Auto-assign creator on start
If you pull something into in-progress without assigning it, you implicitly own it.
```yaml
- description: auto-assign tasks to creator when moved to in-progress
ruki: >
after update
where new.status = "inProgress" and new.assignee is empty
update where id = new.id set assignee=user()
```
Note: this is the permissive alternative to the "require assignee before starting" trigger
that ships in the default workflow. Use one or the other, not both.
## Escalate overdue tasks
Overdue items that aren't already P1 get automatically escalated.
```yaml
- description: escalate overdue tasks by raising priority
ruki: >
every 1day
update where due < now() and status not in ["done"] and priority > 1
set priority=1
```
## Require points estimate before review
Ensures the team reflects on effort before review catches unestimated work.
```yaml
- description: tasks must be estimated before entering review
ruki: >
before update
where new.status = "review" and new.points = 0
deny "estimate points before moving to review"
```
## Auto-tag bugs as urgent when high priority
High-priority bugs should be visually flagged without relying on humans to remember.
```yaml
- description: auto-tag high priority bugs as urgent
ruki: >
after update
where new.type = "bug" and new.priority <= 2 and "urgent" not in new.tags
update where id = new.id set tags=tags + ["urgent"]
```
## Prevent epics from being assigned
Epics are containers, not actionable units — assign individual tasks instead.
```yaml
- description: epics track work groups and should not be assigned
ruki: >
before update
where new.type = "epic" and new.assignee is not empty
deny "epics represent work groups — assign individual tasks instead"
```
## Auto-remove urgent tag when priority drops
Paired with "auto-tag bugs as urgent" to keep tags in sync with priority.
```yaml
- description: remove urgent tag when priority is lowered
ruki: >
after update
where old.priority <= 2 and new.priority > 2 and "urgent" in new.tags
update where id = new.id set tags=tags - ["urgent"]
```
## Notify on critical task creation via webhook
P1s deserve immediate attention — shell integration bridges tiki to external alerting.
```yaml
- description: fire webhook when a P1 task is created
ruki: >
after create
where new.priority = 1
run("curl -s -X POST https://hooks.example.com/tiki -d 'P1 created: " + new.id + " - " + new.title + "'")
```
## Spike time-box
Spikes are investigation tasks — they should be time-boxed by definition.
```yaml
- description: time-box spikes to 3 days
ruki: >
every 1day
update where type = "spike" and status = "inProgress" and now() - updatedAt > 3day
set status="done"
```
## Require task breakdown for large estimates
Large estimates are a smell — nudge toward decomposition for better flow.
```yaml
- description: large tasks should be broken into smaller pieces
ruki: >
before update
where new.points >= 8 and new.type != "epic"
deny "tasks with 8+ points should be broken down — use an epic instead"
```
## Propagate priority from epic to children
When an epic gets escalated, its children should follow.
```yaml
- description: raise child task priority when epic priority increases
ruki: >
after update
where new.type = "epic" and new.priority < old.priority
update where new.id in dependsOn and priority > new.priority
set priority=new.priority
```
## Auto-set due date for in-progress tasks
Tasks without deadlines drift forever — a default creates gentle pressure.
```yaml
- description: default 7-day due date when work starts
ruki: >
after update
where new.status = "inProgress" and new.due is empty
update where id = new.id set due=now() + 7day
```
## Require a title on creation
Every task needs at least a title — catch empty tasks at the gate.
```yaml
- description: tasks must have a title
ruki: >
before create
where new.title is empty
deny "tasks must have a title"
```
## Prevent creating tasks as done
New tasks should enter the workflow, not skip it entirely.
```yaml
- description: new tasks cannot start as done
ruki: >
before create
where new.status = "done"
deny "cannot create a task that is already done"
```
## Epics require a description at creation
Epics define scope — a description ensures the goal is documented upfront.
```yaml
- description: epics require a description at creation
ruki: >
before create
where new.type = "epic" and new.description is empty
deny "epics must have a description explaining the goal"
```
## Prevent deleting epics with active children
Deleting an epic should not orphan its child tasks.
```yaml
- description: cannot delete epics that still have linked tasks
ruki: >
before delete
where old.type = "epic" and count(select where old.id in dependsOn and status != "done") > 0
deny "cannot delete epic with active child tasks"
```
## Block deletion of high-priority tasks
P1 tasks require explicit demotion before they can be removed.
```yaml
- description: P1 tasks require explicit demotion before deletion
ruki: >
before delete
where old.priority = 1
deny "cannot delete a P1 task — lower priority first"
```

View file

@ -1,10 +1,17 @@
# 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)
- [tiki format](tiki-format.md)
- [Quick capture](quick-capture.md)
- [AI skills](skills.md)
- [AI collaboration](ai.md)
- [Recipes](ideas/plugins.md)
- [Triggers](ideas/triggers.md)

View file

@ -1,6 +1,7 @@
# Markdown viewer
![Markdown viewer demo](markdown-viewer.gif)
see [requirements](image-requirements.md) for supported terminals, SVG and diagrams support
## Open Markdown
`tiki` can be used as a navigable Markdown viewer. A Markdown file can be opened via:
@ -12,7 +13,7 @@ tiki my-file.md
- HTTP link
```
tiki https://raw.githubusercontent.com/mxstbr/markdown-test-file/refs/heads/master/TEST.md
tiki https://github.com/boolean-maybe/tiki/blob/main/testdata/go-concurrency.md
```
- From STDIN
@ -64,4 +65,4 @@ Horizontal Navigation
## Edit and save
Press `e` to edit the raw Markdown source file in editor
Press `e` to edit the raw Markdown source file in editor

View file

@ -9,14 +9,20 @@ All vim-like pager commands are supported in addition to:
## File and issue management
`cd` into your **git** repo and run `tiki init` to initialize.
`cd` into your **git** repo and run `tiki init` to initialize
Or you can also check out a demo project and play around:
```
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
You will be prompted to install skills for
- [Claude Code](https://code.claude.com)
- [Gemini CLI](https://github.com/google-gemini/gemini-cli)
- [Codex](https://openai.com/codex)
- [Opencode](https://opencode.ai)
@ -50,14 +56,14 @@ 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
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
If installed you can:
- work with [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
- work with [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
- create, find, modify and delete tikis using AI
- create tikis/dokis directly from Markdown files
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`

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

@ -0,0 +1,288 @@
# Examples
## Table of contents
- [Overview](#overview)
- [Simple statements](#simple-statements)
- [Conditions and lists](#conditions-and-lists)
- [Functions and dates](#functions-and-dates)
- [Before triggers](#before-triggers)
- [After triggers](#after-triggers)
- [Invalid examples](#invalid-examples)
## Overview
The examples show common patterns, useful combinations, and a few edge cases.
## Simple statements
Select all tikis:
```sql
select
```
Select with a basic filter:
```sql
select where status = "done" and priority <= 2
```
Select specific fields:
```sql
select title, status
select id, title where status = "done"
select * where priority <= 2
select title, status where "bug" in tags order by priority
```
Select with ordering:
```sql
select order by priority
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
create title="Fix login" priority=2 status="ready" tags=["bug"]
```
Update matching tikis:
```sql
update where status = "ready" and "sprint-3" in tags set status="cancelled"
```
Delete matching tikis:
```sql
delete where status = "cancelled" and "old" in tags
```
## Conditions and lists
Check whether a tag is present:
```sql
select where "bug" in tags
```
Check whether one tiki depends on another:
```sql
select where id in dependsOn
```
Check whether status is one of several values:
```sql
select where status in ["done", "cancelled"]
```
Check whether dependencies match a condition:
```sql
select where dependsOn any status != "done"
select where dependsOn all status = "done"
```
Boolean grouping:
```sql
select where not (status = "done" or priority = 1)
```
## Functions and dates
Count matching tikis:
```sql
select where count(select where status = "done") >= 1
```
Current user:
```sql
select where assignee = user()
```
Compare timestamps:
```sql
select where updatedAt < now()
select where updatedAt - createdAt > 1day
```
Calculate a date from recurrence:
```sql
create title="x" due=next_date(recurrence)
```
Add to or remove from a list:
```sql
create title="x" tags=tags + ["needs-triage"]
create title="x" dependsOn=dependsOn - ["TIKI-ABC123"]
```
Use `call(...)` in a value:
```sql
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:
```sql
before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete tiki with open dependencies"
```
Require a description for high-priority work:
```sql
before update where new.priority <= 2 and new.description is empty deny "high priority tikis need a description"
```
Limit how many in-progress tikis someone can have:
```sql
before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit reached for this assignee"
```
## After triggers
Auto-assign urgent new work:
```sql
after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"
```
Create the next recurring tiki:
```sql
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="ready"
```
Clear recurrence on the completed source tiki:
```sql
after update where new.status = "done" and old.recurrence is not empty update where id = old.id set recurrence=empty
```
Clean up reverse dependencies on delete:
```sql
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
```
Run a command after an update:
```sql
after update where new.status = "in progress" and "claude" in new.tags run("claude -p 'implement tiki " + old.id + "'")
```
## Time triggers
Move stale in-progress tasks back to backlog:
```sql
every 1hour update where status = "in_progress" and updatedAt < now() - 7day set status="backlog"
```
Delete expired done tasks:
```sql
every 1day delete where status = "done" and updatedAt < now() - 30day
```
Create a weekly review task:
```sql
every 1week create title="weekly review" status="ready" priority=3
```
## Invalid examples
Unknown field:
```sql
select where foo = "bar"
```
Wrong field value type:
```sql
create title="x" priority="high"
```
Using the wrong operator with status:
```sql
select where status < "done"
```
Using `any` on the wrong kind of field:
```sql
select where tags any status = "done"
```
Using `old.` where it is not allowed:
```sql
select where old.status = "done"
```
A list with mixed value types:
```sql
select where status in ["done", 1]
```
Trigger is missing the right action:
```sql
after update where new.status = "done" deny "no"
```
Non-string `run(...)` command:
```sql
after update run(1 + 2)
```
Ordering by a non-orderable field:
```sql
select order by tags
select order by dependsOn
```
Order by inside a subquery:
```sql
select where count(select where status = "done" order by priority) >= 1
```

View file

@ -0,0 +1,202 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 580">
<!--
Grid system:
- Column width: 84px, row height: 28px
- Row header: 90px wide
- Header bar: 26px tall
- All fills use opacity for dark/light background compatibility
- Valid cells: green pill with result type
- Invalid cells: dim dash
-->
<defs>
<style>
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
.op-title { font: 700 14px -apple-system, 'Segoe UI', Roboto, sans-serif; }
.hdr { font: 600 11px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #E2E8F0; }
.rhdr { font: 600 11px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
.cell-ok { font: 600 10px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #34D399; }
.cell-na { font: 400 11px -apple-system, sans-serif; fill: #475569; }
.footnote { font: italic 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #64748B; }
.legend-text { font: 400 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #94A3B8; }
</style>
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
</filter>
</defs>
<text class="title" x="390" y="24" text-anchor="middle">Binary operator type resolution</text>
<!-- ==================== + OPERATOR ==================== -->
<g transform="translate(20, 44)">
<!-- Operator label -->
<rect x="0" y="0" width="26" height="26" rx="7" fill="#818CF8" fill-opacity="0.25" stroke="#818CF8" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<text x="13" y="17" text-anchor="middle" font-size="16" font-weight="700" fill="#A5B4FC">+</text>
<text class="op-title" x="36" y="17" fill="#A5B4FC">addition / concatenation</text>
<!-- Column header bar -->
<rect x="90" y="34" width="646" height="26" rx="6" fill="#334155" fill-opacity="0.6"/>
<text class="hdr" x="178" y="51" text-anchor="middle">string</text>
<text class="hdr" x="264" y="51" text-anchor="middle">int</text>
<text class="hdr" x="350" y="51" text-anchor="middle">date</text>
<text class="hdr" x="436" y="51" text-anchor="middle">tstamp</text>
<text class="hdr" x="522" y="51" text-anchor="middle">duration</text>
<text class="hdr" x="608" y="51" text-anchor="middle">list&lt;str&gt;</text>
<text class="hdr" x="694" y="51" text-anchor="middle">list&lt;ref&gt;</text>
<!-- Row 1: string -->
<text class="rhdr" x="50" y="79" text-anchor="middle">string</text>
<rect x="140" y="66" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="178" y="81" text-anchor="middle">string</text>
<text class="cell-na" x="264" y="81" text-anchor="middle"></text>
<text class="cell-na" x="350" y="81" text-anchor="middle"></text>
<text class="cell-na" x="436" y="81" text-anchor="middle"></text>
<text class="cell-na" x="522" y="81" text-anchor="middle"></text>
<text class="cell-na" x="608" y="81" text-anchor="middle"></text>
<text class="cell-na" x="694" y="81" text-anchor="middle"></text>
<!-- Row 2: int (alternating row background) -->
<rect x="90" y="92" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
<text class="rhdr" x="50" y="107" text-anchor="middle">int</text>
<text class="cell-na" x="178" y="107" text-anchor="middle"></text>
<rect x="226" y="94" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="264" y="109" text-anchor="middle">int</text>
<text class="cell-na" x="350" y="107" text-anchor="middle"></text>
<text class="cell-na" x="436" y="107" text-anchor="middle"></text>
<text class="cell-na" x="522" y="107" text-anchor="middle"></text>
<text class="cell-na" x="608" y="107" text-anchor="middle"></text>
<text class="cell-na" x="694" y="107" text-anchor="middle"></text>
<!-- Row 3: date -->
<text class="rhdr" x="50" y="135" text-anchor="middle">date</text>
<text class="cell-na" x="178" y="135" text-anchor="middle"></text>
<text class="cell-na" x="264" y="135" text-anchor="middle"></text>
<text class="cell-na" x="350" y="135" text-anchor="middle"></text>
<text class="cell-na" x="436" y="135" text-anchor="middle"></text>
<rect x="484" y="122" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="522" y="137" text-anchor="middle">date</text>
<text class="cell-na" x="608" y="135" text-anchor="middle"></text>
<text class="cell-na" x="694" y="135" text-anchor="middle"></text>
<!-- Row 4: timestamp (alternating row background) -->
<rect x="90" y="148" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
<text class="rhdr" x="50" y="163" text-anchor="middle">tstamp</text>
<text class="cell-na" x="178" y="163" text-anchor="middle"></text>
<text class="cell-na" x="264" y="163" text-anchor="middle"></text>
<text class="cell-na" x="350" y="163" text-anchor="middle"></text>
<text class="cell-na" x="436" y="163" text-anchor="middle"></text>
<rect x="484" y="150" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="522" y="165" text-anchor="middle">tstamp</text>
<text class="cell-na" x="608" y="163" text-anchor="middle"></text>
<text class="cell-na" x="694" y="163" text-anchor="middle"></text>
<!-- Row 5: list<string> -->
<text class="rhdr" x="50" y="191" text-anchor="middle">list&lt;str&gt;</text>
<rect x="140" y="178" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="178" y="193" text-anchor="middle">list&lt;str&gt;</text>
<text class="cell-na" x="264" y="191" text-anchor="middle"></text>
<text class="cell-na" x="350" y="191" text-anchor="middle"></text>
<text class="cell-na" x="436" y="191" text-anchor="middle"></text>
<text class="cell-na" x="522" y="191" text-anchor="middle"></text>
<rect x="570" y="178" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="608" y="193" text-anchor="middle">list&lt;str&gt;</text>
<text class="cell-na" x="694" y="191" text-anchor="middle"></text>
<!-- Row 6: list<ref> (alternating row background) -->
<rect x="90" y="204" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
<text class="rhdr" x="50" y="219" text-anchor="middle">list&lt;ref&gt;</text>
<text class="cell-na" x="178" y="219" text-anchor="middle"></text>
<text class="cell-na" x="264" y="219" text-anchor="middle"></text>
<text class="cell-na" x="350" y="219" text-anchor="middle"></text>
<text class="cell-na" x="436" y="219" text-anchor="middle"></text>
<text class="cell-na" x="522" y="219" text-anchor="middle"></text>
<text class="cell-na" x="608" y="219" text-anchor="middle"></text>
<rect x="656" y="206" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="694" y="221" text-anchor="middle">list&lt;ref&gt;</text>
</g>
<!-- ==================== - OPERATOR ==================== -->
<g transform="translate(20, 300)">
<!-- Operator label -->
<rect x="0" y="0" width="26" height="26" rx="7" fill="#F87171" fill-opacity="0.25" stroke="#F87171" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<text x="13" y="17" text-anchor="middle" font-size="16" font-weight="700" fill="#F87171"></text>
<text class="op-title" x="36" y="17" fill="#F87171">subtraction / removal</text>
<!-- Column header bar -->
<rect x="90" y="34" width="646" height="26" rx="6" fill="#334155" fill-opacity="0.6"/>
<text class="hdr" x="178" y="51" text-anchor="middle">string</text>
<text class="hdr" x="264" y="51" text-anchor="middle">int</text>
<text class="hdr" x="350" y="51" text-anchor="middle">date</text>
<text class="hdr" x="436" y="51" text-anchor="middle">tstamp</text>
<text class="hdr" x="522" y="51" text-anchor="middle">duration</text>
<text class="hdr" x="608" y="51" text-anchor="middle">list&lt;str&gt;</text>
<text class="hdr" x="694" y="51" text-anchor="middle">list&lt;ref&gt;</text>
<!-- Row 1: int -->
<text class="rhdr" x="50" y="79" text-anchor="middle">int</text>
<text class="cell-na" x="178" y="79" text-anchor="middle"></text>
<rect x="226" y="66" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="264" y="81" text-anchor="middle">int</text>
<text class="cell-na" x="350" y="79" text-anchor="middle"></text>
<text class="cell-na" x="436" y="79" text-anchor="middle"></text>
<text class="cell-na" x="522" y="79" text-anchor="middle"></text>
<text class="cell-na" x="608" y="79" text-anchor="middle"></text>
<text class="cell-na" x="694" y="79" text-anchor="middle"></text>
<!-- Row 2: date (alternating row background) -->
<rect x="90" y="92" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
<text class="rhdr" x="50" y="107" text-anchor="middle">date</text>
<text class="cell-na" x="178" y="107" text-anchor="middle"></text>
<text class="cell-na" x="264" y="107" text-anchor="middle"></text>
<rect x="312" y="94" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="350" y="109" text-anchor="middle">duration</text>
<text class="cell-na" x="436" y="107" text-anchor="middle"></text>
<rect x="484" y="94" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="522" y="109" text-anchor="middle">date</text>
<text class="cell-na" x="608" y="107" text-anchor="middle"></text>
<text class="cell-na" x="694" y="107" text-anchor="middle"></text>
<!-- Row 3: timestamp -->
<text class="rhdr" x="50" y="135" text-anchor="middle">tstamp</text>
<text class="cell-na" x="178" y="135" text-anchor="middle"></text>
<text class="cell-na" x="264" y="135" text-anchor="middle"></text>
<text class="cell-na" x="350" y="135" text-anchor="middle"></text>
<rect x="398" y="122" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="436" y="137" text-anchor="middle">duration</text>
<rect x="484" y="122" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="522" y="137" text-anchor="middle">tstamp</text>
<text class="cell-na" x="608" y="135" text-anchor="middle"></text>
<text class="cell-na" x="694" y="135" text-anchor="middle"></text>
<!-- Row 4: list<string> (alternating row background) -->
<rect x="90" y="148" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
<text class="rhdr" x="50" y="163" text-anchor="middle">list&lt;str&gt;</text>
<rect x="140" y="150" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="178" y="165" text-anchor="middle">list&lt;str&gt;</text>
<text class="cell-na" x="264" y="163" text-anchor="middle"></text>
<text class="cell-na" x="350" y="163" text-anchor="middle"></text>
<text class="cell-na" x="436" y="163" text-anchor="middle"></text>
<text class="cell-na" x="522" y="163" text-anchor="middle"></text>
<rect x="570" y="150" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="608" y="165" text-anchor="middle">list&lt;str&gt;</text>
<text class="cell-na" x="694" y="163" text-anchor="middle"></text>
<!-- Row 5: list<ref> -->
<text class="rhdr" x="50" y="191" text-anchor="middle">list&lt;ref&gt;</text>
<text class="cell-na" x="178" y="191" text-anchor="middle"></text>
<text class="cell-na" x="264" y="191" text-anchor="middle"></text>
<text class="cell-na" x="350" y="191" text-anchor="middle"></text>
<text class="cell-na" x="436" y="191" text-anchor="middle"></text>
<text class="cell-na" x="522" y="191" text-anchor="middle"></text>
<text class="cell-na" x="608" y="191" text-anchor="middle"></text>
<rect x="656" y="178" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="cell-ok" x="694" y="193" text-anchor="middle">list&lt;ref&gt;</text>
</g>
<!-- Legend + footnotes -->
<rect x="270" y="520" width="14" height="14" rx="7" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3"/>
<text class="legend-text" x="290" y="531">valid (shows result type)</text>
<text class="legend-text" x="460" y="531">— invalid</text>
<text class="footnote" x="390" y="554" text-anchor="middle">string includes string-like types: string, status, type, id, ref</text>
<text class="footnote" x="390" y="570" text-anchor="middle">list&lt;ref&gt; + also accepts bare id/ref values on the right side</text>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,339 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 888">
<!--
Condition grammar railroad diagram — transparent/dark-bg compatible
Tracks: condition, orCond, andCond, notCond, primaryCond, exprCond
-->
<defs>
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
</marker>
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
</filter>
<style>
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
</style>
</defs>
<!-- title -->
<text class="title" x="450" y="22" text-anchor="middle">Condition grammar</text>
<!-- all track content shifted down 8px for title clearance -->
<g transform="translate(0, 8)">
<!-- ==================== condition ==================== -->
<g transform="translate(0, 20)">
<g>
<rect x="2" y="22" width="92" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="36" text-anchor="start">condition</text>
</g>
<circle cx="130" cy="32" r="4" fill="#94A3B8"/>
<circle cx="130" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="134" y1="32" x2="160" y2="32"/>
<g class="nonterminal" transform="translate(160, 16)">
<rect width="82" height="32" rx="4"/>
<text x="41" y="16" text-anchor="middle" dominant-baseline="central">orCond</text>
</g>
<line class="track" x1="242" y1="32" x2="280" y2="32"/>
<circle cx="280" cy="32" r="4" fill="#94A3B8"/>
<circle cx="280" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
<!-- separator -->
<line x1="10" y1="80" x2="890" y2="80" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== orCond ==================== -->
<!-- andCond with loopback via "or" below -->
<g transform="translate(0, 100)">
<g>
<rect x="2" y="22" width="66" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="36" text-anchor="start">orCond</text>
</g>
<circle cx="130" cy="32" r="4" fill="#94A3B8"/>
<circle cx="130" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="134" y1="32" x2="170" y2="32"/>
<g class="nonterminal" transform="translate(170, 16)">
<rect width="96" height="32" rx="4"/>
<text x="48" y="16" text-anchor="middle" dominant-baseline="central">andCond</text>
</g>
<line class="track" x1="266" y1="32" x2="320" y2="32"/>
<!-- loopback below with "or" keyword -->
<path class="track" d="M 310,32 C 310,62 300,76 280,76 L 240,76"/>
<g class="terminal" transform="translate(200, 60)">
<rect width="40" height="32" rx="16"/>
<text x="20" y="16" text-anchor="middle" dominant-baseline="central">or</text>
</g>
<path class="track" d="M 200,76 C 180,76 170,62 170,42"/>
<path d="M 165,46 L 170,36 L 175,46 z" fill="#94A3B8"/>
<circle cx="320" cy="32" r="4" fill="#94A3B8"/>
<circle cx="320" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
<!-- separator -->
<line x1="10" y1="195" x2="890" y2="195" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== andCond ==================== -->
<!-- notCond with loopback via "and" below -->
<g transform="translate(0, 210)">
<g>
<rect x="2" y="22" width="75" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="36" text-anchor="start">andCond</text>
</g>
<circle cx="130" cy="32" r="4" fill="#94A3B8"/>
<circle cx="130" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="134" y1="32" x2="170" y2="32"/>
<g class="nonterminal" transform="translate(170, 16)">
<rect width="96" height="32" rx="4"/>
<text x="48" y="16" text-anchor="middle" dominant-baseline="central">notCond</text>
</g>
<line class="track" x1="266" y1="32" x2="320" y2="32"/>
<!-- loopback below with "and" keyword -->
<path class="track" d="M 310,32 C 310,62 300,76 280,76 L 240,76"/>
<g class="terminal" transform="translate(195, 60)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">and</text>
</g>
<path class="track" d="M 195,76 C 175,76 170,62 170,42"/>
<path d="M 165,46 L 170,36 L 175,46 z" fill="#94A3B8"/>
<circle cx="320" cy="32" r="4" fill="#94A3B8"/>
<circle cx="320" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
<!-- separator -->
<line x1="10" y1="305" x2="890" y2="305" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== notCond ==================== -->
<!-- Two alternatives: "not" notCond (recursive) | primaryCond -->
<g transform="translate(0, 320)">
<g>
<rect x="2" y="40" width="75" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="54" text-anchor="start">notCond</text>
</g>
<circle cx="130" cy="50" r="4" fill="#94A3B8"/>
<circle cx="130" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="134" y1="50" x2="170" y2="50"/>
<!-- upper: not + notCond -->
<path class="track" d="M 170,50 C 170,20 180,10 200,10"/>
<g class="terminal" transform="translate(200, -6)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">not</text>
</g>
<line class="track" x1="250" y1="10" x2="274" y2="10"/>
<g class="nonterminal" transform="translate(274, -6)">
<rect width="96" height="32" rx="4"/>
<text x="48" y="16" text-anchor="middle" dominant-baseline="central">notCond</text>
</g>
<path class="track" d="M 370,10 C 390,10 400,20 400,50"/>
<!-- lower: primaryCond (straight through) -->
<line class="track" x1="170" y1="50" x2="200" y2="50"/>
<g class="nonterminal" transform="translate(200, 34)">
<rect width="130" height="32" rx="4"/>
<text x="65" y="16" text-anchor="middle" dominant-baseline="central">primaryCond</text>
</g>
<line class="track" x1="330" y1="50" x2="400" y2="50"/>
<line class="track" x1="400" y1="50" x2="440" y2="50"/>
<circle cx="440" cy="50" r="4" fill="#94A3B8"/>
<circle cx="440" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
<!-- separator -->
<line x1="10" y1="420" x2="890" y2="420" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== primaryCond ==================== -->
<!-- Two alternatives: ( condition ) | exprCond -->
<g transform="translate(0, 440)">
<g>
<rect x="2" y="40" width="108" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="54" text-anchor="start">primaryCond</text>
</g>
<circle cx="140" cy="50" r="4" fill="#94A3B8"/>
<circle cx="140" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="144" y1="50" x2="180" y2="50"/>
<!-- upper: ( condition ) -->
<path class="track" d="M 180,50 C 180,20 190,10 210,10"/>
<g class="terminal" transform="translate(210, -6)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
</g>
<line class="track" x1="240" y1="10" x2="258" y2="10"/>
<g class="nonterminal" transform="translate(258, -6)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
</g>
<line class="track" x1="358" y1="10" x2="376" y2="10"/>
<g class="terminal" transform="translate(376, -6)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
</g>
<path class="track" d="M 406,10 C 426,10 436,20 436,50"/>
<!-- lower: exprCond (straight through) -->
<line class="track" x1="180" y1="50" x2="210" y2="50"/>
<g class="nonterminal" transform="translate(210, 34)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">exprCond</text>
</g>
<line class="track" x1="310" y1="50" x2="436" y2="50"/>
<line class="track" x1="436" y1="50" x2="476" y2="50"/>
<circle cx="476" cy="50" r="4" fill="#94A3B8"/>
<circle cx="476" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
<!-- separator -->
<line x1="10" y1="537" x2="890" y2="537" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== exprCond ==================== -->
<!-- expr followed by optional tail: 7 alternatives or bypass -->
<g transform="translate(0, 555)">
<g>
<rect x="2" y="40" width="83" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="54" text-anchor="start">exprCond</text>
</g>
<circle cx="130" cy="50" r="4" fill="#94A3B8"/>
<circle cx="130" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="134" y1="50" x2="160" y2="50"/>
<g class="nonterminal" transform="translate(160, 34)">
<rect width="60" height="32" rx="4"/>
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
</g>
<line class="track" x1="220" y1="50" x2="260" y2="50"/>
<!-- branch: bypass (no tail) above, or one of 7 tails -->
<!-- bypass: straight across above -->
<path class="track" d="M 250,50 C 250,15 260,5 280,5 L 730,5 C 750,5 760,15 760,50"/>
<!-- compareTail: compareOp + expr -->
<path class="track" d="M 260,50 C 260,68 270,78 290,78"/>
<g class="nonterminal" transform="translate(290, 62)">
<rect width="110" height="32" rx="4"/>
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">compareOp</text>
</g>
<line class="track" x1="400" y1="78" x2="420" y2="78"/>
<g class="nonterminal" transform="translate(420, 62)">
<rect width="60" height="32" rx="4"/>
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
</g>
<path class="track" d="M 480,78 C 740,78 740,60 760,50"/>
<!-- isEmptyTail: is empty -->
<path class="track" d="M 260,50 C 260,108 270,118 290,118"/>
<g class="terminal" transform="translate(290, 102)">
<rect width="36" height="32" rx="16"/>
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">is</text>
</g>
<line class="track" x1="326" y1="118" x2="346" y2="118"/>
<g class="terminal" transform="translate(346, 102)">
<rect width="70" height="32" rx="16"/>
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">empty</text>
</g>
<path class="track" d="M 416,118 C 740,118 740,60 760,50"/>
<!-- isNotEmptyTail: is not empty -->
<path class="track" d="M 260,50 C 260,148 270,158 290,158"/>
<g class="terminal" transform="translate(290, 142)">
<rect width="36" height="32" rx="16"/>
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">is</text>
</g>
<line class="track" x1="326" y1="158" x2="346" y2="158"/>
<g class="terminal" transform="translate(346, 142)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">not</text>
</g>
<line class="track" x1="396" y1="158" x2="416" y2="158"/>
<g class="terminal" transform="translate(416, 142)">
<rect width="70" height="32" rx="16"/>
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">empty</text>
</g>
<path class="track" d="M 486,158 C 740,158 740,60 760,50"/>
<!-- inTail: in expr -->
<path class="track" d="M 260,50 C 260,188 270,198 290,198"/>
<g class="terminal" transform="translate(290, 182)">
<rect width="36" height="32" rx="16"/>
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">in</text>
</g>
<line class="track" x1="326" y1="198" x2="346" y2="198"/>
<g class="nonterminal" transform="translate(346, 182)">
<rect width="60" height="32" rx="4"/>
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
</g>
<path class="track" d="M 406,198 C 740,198 740,60 760,50"/>
<!-- notInTail: not in expr -->
<path class="track" d="M 260,50 C 260,228 270,238 290,238"/>
<g class="terminal" transform="translate(290, 222)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">not</text>
</g>
<line class="track" x1="340" y1="238" x2="360" y2="238"/>
<g class="terminal" transform="translate(360, 222)">
<rect width="36" height="32" rx="16"/>
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">in</text>
</g>
<line class="track" x1="396" y1="238" x2="416" y2="238"/>
<g class="nonterminal" transform="translate(416, 222)">
<rect width="60" height="32" rx="4"/>
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
</g>
<path class="track" d="M 476,238 C 740,238 740,60 760,50"/>
<!-- anyTail: any primaryCond -->
<path class="track" d="M 260,50 C 260,268 270,278 290,278"/>
<g class="terminal" transform="translate(290, 262)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">any</text>
</g>
<line class="track" x1="340" y1="278" x2="360" y2="278"/>
<g class="nonterminal" transform="translate(360, 262)">
<rect width="130" height="32" rx="4"/>
<text x="65" y="16" text-anchor="middle" dominant-baseline="central">primaryCond</text>
</g>
<path class="track" d="M 490,278 C 740,278 740,60 760,50"/>
<!-- allTail: all primaryCond -->
<path class="track" d="M 260,50 C 260,308 270,318 290,318"/>
<g class="terminal" transform="translate(290, 302)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">all</text>
</g>
<line class="track" x1="340" y1="318" x2="360" y2="318"/>
<g class="nonterminal" transform="translate(360, 302)">
<rect width="130" height="32" rx="4"/>
<text x="65" y="16" text-anchor="middle" dominant-baseline="central">primaryCond</text>
</g>
<path class="track" d="M 490,318 C 740,318 740,60 760,50"/>
<!-- exit -->
<line class="track" x1="760" y1="50" x2="800" y2="50"/>
<circle cx="800" cy="50" r="4" fill="#94A3B8"/>
<circle cx="800" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,303 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 712">
<!--
Expression grammar railroad diagram — transparent/dark-bg compatible
Tracks: expr, unaryExpr, funcCall, qualifiedRef
-->
<defs>
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
</marker>
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
</filter>
<style>
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
.lit-terminal rect { fill: #6366F1; fill-opacity: 0.15; stroke: #6366F1; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
.lit-terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; font-weight: 500; }
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
.track-dot { fill: #94A3B8; }
.label-pill { fill: #94A3B8; fill-opacity: 0.08; stroke: #94A3B8; stroke-width: 0.5; stroke-opacity: 0.2; }
</style>
</defs>
<!-- Title -->
<text class="title" x="450" y="22" text-anchor="middle">Expression grammar</text>
<!-- All content shifted down 8px for title -->
<g transform="translate(0, 8)">
<!-- ==================== expr ==================== -->
<!-- unaryExpr with loopback via +/- below -->
<g transform="translate(0, 20)">
<!-- label background pill -->
<rect class="label-pill" x="2" y="20" width="50" height="24" rx="6"/>
<text class="label" x="10" y="36" text-anchor="start">expr</text>
<!-- entry dot with outer ring -->
<circle fill="#94A3B8" cx="100" cy="32" r="4"/>
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="100" cy="32" r="7"/>
<line class="track" x1="104" y1="32" x2="140" y2="32"/>
<g class="nonterminal" transform="translate(140, 16)">
<rect width="110" height="32" rx="4"/>
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">unaryExpr</text>
</g>
<line class="track" x1="250" y1="32" x2="310" y2="32"/>
<!-- loopback below with +/- alternatives -->
<path class="track" d="M 300,32 C 300,58 292,72 276,72"/>
<!-- two alternatives for the operator: + and - -->
<!-- + path (upper of the two) -->
<g class="terminal" transform="translate(220, 56)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">+</text>
</g>
<path class="track" d="M 276,72 L 250,72"/>
<path class="track" d="M 220,72 C 200,72 190,62 180,52"/>
<!-- - path (lower) -->
<path class="track" d="M 276,72 C 276,98 268,108 250,108"/>
<g class="terminal" transform="translate(220, 92)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">-</text>
</g>
<path class="track" d="M 220,108 C 200,108 186,90 180,52"/>
<!-- arrow pointing up at rejoin -->
<path class="track" d="M 180,52 C 175,42 170,38 140,42"/>
<path d="M 135,46 L 140,36 L 145,46 z" fill="#94A3B8"/>
<!-- exit dot with outer ring -->
<circle fill="#94A3B8" cx="310" cy="32" r="4"/>
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="310" cy="32" r="7"/>
</g>
<!-- separator line between expr and unaryExpr -->
<line x1="10" y1="155" x2="890" y2="155" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== unaryExpr ==================== -->
<!-- 11-way alternative fan. Use literal type boxes for value types -->
<g transform="translate(0, 160)">
<!-- label background pill -->
<rect class="label-pill" x="2" y="84" width="98" height="24" rx="6"/>
<text class="label" x="10" y="100" text-anchor="start">unaryExpr</text>
<!-- entry dot with outer ring -->
<circle fill="#94A3B8" cx="120" cy="96" r="4"/>
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="120" cy="96" r="7"/>
<line class="track" x1="124" y1="96" x2="160" y2="96"/>
<!-- 1. funcCall (y=-30) -->
<path class="track" d="M 160,96 C 160,0 170,-30 190,-30"/>
<g class="nonterminal" transform="translate(190, -46)">
<rect width="92" height="32" rx="4"/>
<text x="46" y="16" text-anchor="middle" dominant-baseline="central">funcCall</text>
</g>
<path class="track" d="M 282,-30 C 380,-30 390,0 390,96"/>
<!-- 2. subQuery (y=2) -->
<path class="track" d="M 160,96 C 160,30 170,2 190,2"/>
<g class="nonterminal" transform="translate(190, -14)">
<rect width="92" height="32" rx="4"/>
<text x="46" y="16" text-anchor="middle" dominant-baseline="central">subQuery</text>
</g>
<path class="track" d="M 282,2 C 370,2 380,30 390,96"/>
<!-- 3. qualifiedRef (y=34) -->
<path class="track" d="M 160,96 C 160,54 170,34 190,34"/>
<g class="nonterminal" transform="translate(190, 18)">
<rect width="120" height="32" rx="4"/>
<text x="60" y="16" text-anchor="middle" dominant-baseline="central">qualifiedRef</text>
</g>
<path class="track" d="M 310,34 C 370,34 380,54 390,96"/>
<!-- 4. listLiteral (y=66) -->
<path class="track" d="M 160,96 C 160,76 170,66 190,66"/>
<g class="nonterminal" transform="translate(190, 50)">
<rect width="110" height="32" rx="4"/>
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">listLiteral</text>
</g>
<path class="track" d="M 300,66 C 370,66 380,76 390,96"/>
<!-- 5. string (y=96 — straight through) -->
<line class="track" x1="160" y1="96" x2="190" y2="96"/>
<g class="lit-terminal" transform="translate(190, 80)">
<rect width="78" height="32" rx="16"/>
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">string</text>
</g>
<line class="track" x1="268" y1="96" x2="390" y2="96"/>
<!-- 6. date (y=128) -->
<path class="track" d="M 160,96 C 160,116 170,128 190,128"/>
<g class="lit-terminal" transform="translate(190, 112)">
<rect width="64" height="32" rx="16"/>
<text x="32" y="16" text-anchor="middle" dominant-baseline="central">date</text>
</g>
<path class="track" d="M 254,128 C 370,128 380,116 390,96"/>
<!-- 7. duration (y=160) -->
<path class="track" d="M 160,96 C 160,140 170,160 190,160"/>
<g class="lit-terminal" transform="translate(190, 144)">
<rect width="86" height="32" rx="16"/>
<text x="43" y="16" text-anchor="middle" dominant-baseline="central">duration</text>
</g>
<path class="track" d="M 276,160 C 370,160 380,140 390,96"/>
<!-- 8. int (y=192) -->
<path class="track" d="M 160,96 C 160,166 170,192 190,192"/>
<g class="lit-terminal" transform="translate(190, 176)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">int</text>
</g>
<path class="track" d="M 240,192 C 370,192 380,166 390,96"/>
<!-- 9. empty (y=224) -->
<path class="track" d="M 160,96 C 160,196 170,224 190,224"/>
<g class="lit-terminal" transform="translate(190, 208)">
<rect width="70" height="32" rx="16"/>
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">empty</text>
</g>
<path class="track" d="M 260,224 C 370,224 380,196 390,96"/>
<!-- 10. fieldRef (y=256) -->
<path class="track" d="M 160,96 C 160,224 170,256 190,256"/>
<g class="nonterminal" transform="translate(190, 240)">
<rect width="88" height="32" rx="4"/>
<text x="44" y="16" text-anchor="middle" dominant-baseline="central">fieldRef</text>
</g>
<path class="track" d="M 278,256 C 370,256 380,224 390,96"/>
<!-- 11. ( expr ) (y=288) -->
<path class="track" d="M 160,96 C 160,256 170,288 190,288"/>
<g class="terminal" transform="translate(190, 272)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
</g>
<line class="track" x1="220" y1="288" x2="236" y2="288"/>
<g class="nonterminal" transform="translate(236, 272)">
<rect width="60" height="32" rx="4"/>
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
</g>
<line class="track" x1="296" y1="288" x2="312" y2="288"/>
<g class="terminal" transform="translate(312, 272)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
</g>
<path class="track" d="M 342,288 C 370,288 380,256 390,96"/>
<!-- exit -->
<line class="track" x1="390" y1="96" x2="430" y2="96"/>
<!-- exit dot with outer ring -->
<circle fill="#94A3B8" cx="430" cy="96" r="4"/>
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="430" cy="96" r="7"/>
</g>
<!-- separator line between unaryExpr and funcCall -->
<line x1="10" y1="505" x2="890" y2="505" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== funcCall ==================== -->
<g transform="translate(0, 510)">
<!-- label background pill -->
<rect class="label-pill" x="2" y="20" width="82" height="24" rx="6"/>
<text class="label" x="10" y="36" text-anchor="start">funcCall</text>
<!-- entry dot with outer ring -->
<circle fill="#94A3B8" cx="120" cy="32" r="4"/>
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="120" cy="32" r="7"/>
<line class="track" x1="124" y1="32" x2="150" y2="32"/>
<g class="nonterminal" transform="translate(150, 16)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
</g>
<line class="track" x1="250" y1="32" x2="270" y2="32"/>
<g class="terminal" transform="translate(270, 16)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
</g>
<line class="track" x1="300" y1="32" x2="330" y2="32"/>
<!-- optional args: bypass above, expr with comma loopback below -->
<!-- main path: expr -->
<g class="nonterminal" transform="translate(330, 16)">
<rect width="60" height="32" rx="4"/>
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
</g>
<line class="track" x1="390" y1="32" x2="440" y2="32"/>
<!-- loopback below with comma -->
<path class="track" d="M 430,32 C 430,62 420,72 400,72"/>
<g class="terminal" transform="translate(355, 56)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">,</text>
</g>
<path class="track" d="M 355,72 C 340,72 330,62 330,42"/>
<path d="M 325,46 L 330,36 L 335,46 z" fill="#94A3B8"/>
<!-- bypass above: no args -->
<path class="track" d="M 320,32 C 320,5 330,-5 350,-5 L 410,-5 C 430,-5 440,5 440,32"/>
<g class="terminal" transform="translate(440, 16)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
</g>
<line class="track" x1="470" y1="32" x2="510" y2="32"/>
<!-- exit dot with outer ring -->
<circle fill="#94A3B8" cx="510" cy="32" r="4"/>
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="510" cy="32" r="7"/>
</g>
<!-- separator line between funcCall and qualifiedRef -->
<line x1="10" y1="605" x2="890" y2="605" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== qualifiedRef ==================== -->
<g transform="translate(0, 610)">
<!-- label background pill -->
<rect class="label-pill" x="2" y="20" width="118" height="24" rx="6"/>
<text class="label" x="10" y="36" text-anchor="start">qualifiedRef</text>
<!-- entry dot with outer ring -->
<circle fill="#94A3B8" cx="140" cy="32" r="4"/>
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="140" cy="32" r="7"/>
<line class="track" x1="144" y1="32" x2="180" y2="32"/>
<!-- alternative: old | new -->
<path class="track" d="M 180,32 C 180,10 190,0 210,0"/>
<g class="terminal" transform="translate(210, -16)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">old</text>
</g>
<path class="track" d="M 260,0 C 280,0 290,10 290,32"/>
<path class="track" d="M 180,32 C 180,54 190,64 210,64"/>
<g class="terminal" transform="translate(210, 48)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">new</text>
</g>
<path class="track" d="M 260,64 C 280,64 290,54 290,32"/>
<line class="track" x1="290" y1="32" x2="310" y2="32"/>
<g class="terminal" transform="translate(310, 16)">
<rect width="24" height="32" rx="16"/>
<text x="12" y="16" text-anchor="middle" dominant-baseline="central">.</text>
</g>
<line class="track" x1="334" y1="32" x2="354" y2="32"/>
<g class="nonterminal" transform="translate(354, 16)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
</g>
<line class="track" x1="454" y1="32" x2="490" y2="32"/>
<!-- exit dot with outer ring -->
<circle fill="#94A3B8" cx="490" cy="32" r="4"/>
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="490" cy="32" r="7"/>
</g>
</g><!-- end content wrapper -->
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,95 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 350">
<!--
Grid system:
- Row height: 40px, gap: 6px
- Column widths: context=200, old=100, new=100
- Start x=40, y=60
- Indicator pills: 60x28, rx=14
- All fills use opacity for transparency on any background
-->
<defs>
<style>
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
.col-hdr { font: 700 15px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; }
.row-label { font: 500 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #CBD5E1; }
.note-text { font: 500 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FBBF24; }
.legend-text { font: 400 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #94A3B8; }
.indicator { font: 700 14px -apple-system, sans-serif; }
</style>
<!-- Checkmark icon -->
<symbol id="check" viewBox="0 0 16 16">
<path d="M3 8.5 L6.5 12 L13 4" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</symbol>
<!-- X icon -->
<symbol id="cross" viewBox="0 0 16 16">
<path d="M4 4 L12 12 M12 4 L4 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none"/>
</symbol>
<!-- Drop shadow filter -->
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Title -->
<text class="title" x="250" y="30" text-anchor="middle">Qualifier scope by context</text>
<!-- Subtle top border -->
<line x1="40" y1="38" x2="470" y2="38" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- Column header backgrounds -->
<rect x="270" y="42" width="80" height="28" rx="6" fill="#818CF8" fill-opacity="0.08"/>
<rect x="380" y="42" width="80" height="28" rx="6" fill="#34D399" fill-opacity="0.08"/>
<!-- Column headers -->
<text class="col-hdr" x="310" y="62" text-anchor="middle" fill="#818CF8">old.</text>
<text class="col-hdr" x="420" y="62" text-anchor="middle" fill="#34D399">new.</text>
<!-- Separator line -->
<line x1="40" y1="74" x2="470" y2="74" stroke="#475569" stroke-width="1" stroke-opacity="0.5"/>
<!-- Row 1: standalone statement -->
<text class="row-label" x="50" y="102">standalone statement</text>
<rect x="280" y="86" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<use href="#cross" x="296" y="92" width="16" height="16" color="#F87171"/>
<rect x="390" y="86" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<use href="#cross" x="406" y="92" width="16" height="16" color="#F87171"/>
<!-- Row 2: create trigger (alternate row highlight) -->
<rect x="40" y="120" width="430" height="40" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
<text class="row-label" x="50" y="148">create trigger</text>
<rect x="280" y="132" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<use href="#cross" x="296" y="138" width="16" height="16" color="#F87171"/>
<rect x="390" y="132" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<use href="#check" x="406" y="138" width="16" height="16" color="#34D399"/>
<!-- Row 3: update trigger -->
<text class="row-label" x="50" y="194">update trigger</text>
<rect x="280" y="178" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<use href="#check" x="296" y="184" width="16" height="16" color="#34D399"/>
<rect x="390" y="178" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<use href="#check" x="406" y="184" width="16" height="16" color="#34D399"/>
<!-- Row 4: delete trigger (alternate row highlight) -->
<rect x="40" y="212" width="430" height="40" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
<text class="row-label" x="50" y="240">delete trigger</text>
<rect x="280" y="224" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<use href="#check" x="296" y="230" width="16" height="16" color="#34D399"/>
<rect x="390" y="224" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
<use href="#cross" x="406" y="230" width="16" height="16" color="#F87171"/>
<!-- Separator -->
<line x1="40" y1="264" x2="470" y2="264" stroke="#475569" stroke-width="1" stroke-opacity="0.3"/>
<!-- Note about quantifier body -->
<rect x="40" y="274" width="430" height="30" rx="8" fill="#FBBF24" fill-opacity="0.1" stroke="#FBBF24" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
<text class="note-text" x="255" y="293" text-anchor="middle">quantifier body (any / all): both old. and new. are disabled</text>
<!-- Legend -->
<rect x="100" y="316" width="16" height="16" rx="8" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4"/>
<use href="#check" x="102" y="318" width="12" height="12" color="#34D399"/>
<text class="legend-text" x="124" y="328">allowed</text>
<rect x="230" y="316" width="16" height="16" rx="8" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4"/>
<use href="#cross" x="232" y="318" width="12" height="12" color="#F87171"/>
<text class="legend-text" x="254" y="328">not allowed</text>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -0,0 +1,286 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1020 560">
<!--
Grid system for Ruki railroad diagrams (transparent/dark-bg compatible)
========================================
Terminal (keyword) box: height=32, rx=16 (fully rounded ends)
Non-terminal box: height=32, rx=4 (subtle rounding)
Track vertical spacing: ~130px between track baselines
Label column: x=10, track starts at x=140
Line/dot color: #94A3B8
Terminal: fill=#3B82F6 fill-opacity=0.15, stroke=#3B82F6 stroke-opacity=0.5
Non-terminal: fill=#F59E0B fill-opacity=0.15, stroke=#F59E0B stroke-opacity=0.4
Loopback: below main line (standard railroad repetition)
Bypass: above main line for optional elements
Visual polish:
- Drop shadow filter on all boxes (subtle, dark-bg friendly)
- Label background pills with rounded rect behind each rule name
- Entry dots with outer ring; exit dots with double circle
- Separator lines between tracks
- Title text at top in accent color
- viewBox height +20 to accommodate title; content shifted down 8px
-->
<defs>
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
</filter>
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
</marker>
<style>
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.4; filter: url(#shadow); }
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
.track-dot { fill: #94A3B8; }
.separator { stroke: #475569; stroke-width: 0.5; stroke-opacity: 0.3; }
</style>
</defs>
<!-- diagram title -->
<text class="title" x="410" y="22" text-anchor="middle">Statement grammar</text>
<!-- content shifted down 8px to accommodate title -->
<g transform="translate(0, 8)">
<!-- ==================== selectStmt ==================== -->
<!-- Main line at y=50, field-list bypass above at y=10, loopback below at y=90 -->
<g transform="translate(0, 30)">
<!-- label background pill -->
<g class="label-group">
<rect x="5" y="38" width="120" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="65" y="54" text-anchor="middle">selectStmt</text>
</g>
<!-- entry dot with ring -->
<circle class="track-dot" cx="140" cy="50" r="4"/>
<circle cx="140" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="144" y1="50" x2="170" y2="50"/>
<!-- select keyword -->
<g class="terminal" transform="translate(170, 34)">
<rect width="72" height="32" rx="16"/>
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">select</text>
</g>
<line class="track" x1="242" y1="50" x2="260" y2="50"/>
<!-- === optional field list group (260460) === -->
<!-- main path: * terminal -->
<line class="track" x1="260" y1="50" x2="290" y2="50"/>
<g class="terminal" transform="translate(290, 34)">
<rect width="32" height="32" rx="16"/>
<text x="16" y="16" text-anchor="middle" dominant-baseline="central">*</text>
</g>
<line class="track" x1="322" y1="50" x2="460" y2="50"/>
<!-- alternate path below: identifier { , identifier } at y=90 -->
<path class="track" d="M 270,50 C 270,70 280,90 300,90"/>
<g class="nonterminal" transform="translate(300, 74)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
</g>
<line class="track" x1="400" y1="90" x2="440" y2="90"/>
<path class="track" d="M 440,90 C 450,90 460,80 460,50"/>
<!-- loopback below identifier: , identifier at y=130 -->
<path class="track" d="M 430,90 C 430,110 420,130 400,130"/>
<g class="terminal" transform="translate(356, 114)">
<rect width="32" height="32" rx="16"/>
<text x="16" y="16" text-anchor="middle" dominant-baseline="central">,</text>
</g>
<line class="track" x1="356" y1="130" x2="340" y2="130"/>
<g class="nonterminal" transform="translate(230, 114)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
</g>
<path class="track" d="M 230,130 C 220,130 210,110 210,100"/>
<!-- arrowhead pointing up at the rejoin into the identifier path -->
<path d="M 205,100 L 210,90 L 215,100 z" fill="#94A3B8"/>
<!-- bypass path above: skip field list entirely at y=10 -->
<path class="track" d="M 260,50 C 260,30 270,10 290,10 L 430,10 C 450,10 460,30 460,50"/>
<!-- === end field list group === -->
<line class="track" x1="460" y1="50" x2="490" y2="50"/>
<!-- === optional where + condition group (490680) === -->
<g class="terminal" transform="translate(490, 34)">
<rect width="70" height="32" rx="16"/>
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
</g>
<line class="track" x1="560" y1="50" x2="580" y2="50"/>
<g class="nonterminal" transform="translate(580, 34)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
</g>
<line class="track" x1="680" y1="50" x2="710" y2="50"/>
<!-- bypass above: skip where+condition -->
<path class="track" d="M 480,50 C 480,20 490,10 510,10 L 680,10 C 700,10 710,20 710,50"/>
<!-- === optional orderBy group (710830) === -->
<line class="track" x1="710" y1="50" x2="740" y2="50"/>
<g class="nonterminal" transform="translate(740, 34)">
<rect width="88" height="32" rx="4"/>
<text x="44" y="16" text-anchor="middle" dominant-baseline="central">orderBy</text>
</g>
<line class="track" x1="828" y1="50" x2="860" y2="50"/>
<!-- bypass above: skip orderBy -->
<path class="track" d="M 730,50 C 730,20 740,10 760,10 L 830,10 C 850,10 860,20 860,50"/>
<!-- exit dot with double circle -->
<circle class="track-dot" cx="860" cy="50" r="4"/>
<circle cx="860" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
<!-- separator between selectStmt and createStmt -->
<line class="separator" x1="10" y1="140" x2="1010" y2="140"/>
<!-- ==================== createStmt ==================== -->
<!-- Main line at y=32, loopback below at y=72 -->
<g transform="translate(0, 150)">
<!-- label background pill -->
<g class="label-group">
<rect x="5" y="18" width="122" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="66" y="34" text-anchor="middle">createStmt</text>
</g>
<!-- entry dot with ring -->
<circle class="track-dot" cx="140" cy="32" r="4"/>
<circle cx="140" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="144" y1="32" x2="170" y2="32"/>
<!-- create keyword -->
<g class="terminal" transform="translate(170, 16)">
<rect width="74" height="32" rx="16"/>
<text x="37" y="16" text-anchor="middle" dominant-baseline="central">create</text>
</g>
<line class="track" x1="244" y1="32" x2="280" y2="32"/>
<!-- assignment non-terminal -->
<g class="nonterminal" transform="translate(280, 16)">
<rect width="110" height="32" rx="4"/>
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">assignment</text>
</g>
<line class="track" x1="390" y1="32" x2="440" y2="32"/>
<!-- loopback below: from after assignment back to before assignment -->
<path class="track" d="M 430,32 C 430,62 420,76 400,76 L 300,76 C 280,76 270,62 270,42"/>
<!-- arrowhead pointing up at the rejoin -->
<path d="M 265,46 L 270,36 L 275,46 z" fill="#94A3B8"/>
<!-- exit dot with double circle -->
<circle class="track-dot" cx="440" cy="32" r="4"/>
<circle cx="440" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
<!-- separator between createStmt and updateStmt -->
<line class="separator" x1="10" y1="270" x2="1010" y2="270"/>
<!-- ==================== updateStmt ==================== -->
<!-- Main line at y=32, loopback below at y=76 -->
<g transform="translate(0, 280)">
<!-- label background pill -->
<g class="label-group">
<rect x="5" y="18" width="124" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="67" y="34" text-anchor="middle">updateStmt</text>
</g>
<!-- entry dot with ring -->
<circle class="track-dot" cx="140" cy="32" r="4"/>
<circle cx="140" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="144" y1="32" x2="170" y2="32"/>
<!-- update keyword -->
<g class="terminal" transform="translate(170, 16)">
<rect width="78" height="32" rx="16"/>
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">update</text>
</g>
<line class="track" x1="248" y1="32" x2="268" y2="32"/>
<!-- where keyword -->
<g class="terminal" transform="translate(268, 16)">
<rect width="70" height="32" rx="16"/>
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
</g>
<line class="track" x1="338" y1="32" x2="358" y2="32"/>
<!-- condition -->
<g class="nonterminal" transform="translate(358, 16)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
</g>
<line class="track" x1="458" y1="32" x2="478" y2="32"/>
<!-- set keyword -->
<g class="terminal" transform="translate(478, 16)">
<rect width="50" height="32" rx="16"/>
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">set</text>
</g>
<line class="track" x1="528" y1="32" x2="548" y2="32"/>
<!-- assignment non-terminal -->
<g class="nonterminal" transform="translate(548, 16)">
<rect width="110" height="32" rx="4"/>
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">assignment</text>
</g>
<line class="track" x1="658" y1="32" x2="710" y2="32"/>
<!-- loopback below -->
<path class="track" d="M 700,32 C 700,62 690,76 670,76 L 568,76 C 548,76 538,62 538,42"/>
<path d="M 533,46 L 538,36 L 543,46 z" fill="#94A3B8"/>
<!-- exit dot with double circle -->
<circle class="track-dot" cx="710" cy="32" r="4"/>
<circle cx="710" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
<!-- separator between updateStmt and deleteStmt -->
<line class="separator" x1="10" y1="400" x2="1010" y2="400"/>
<!-- ==================== deleteStmt ==================== -->
<g transform="translate(0, 420)">
<!-- label background pill -->
<g class="label-group">
<rect x="5" y="18" width="120" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="65" y="34" text-anchor="middle">deleteStmt</text>
</g>
<!-- entry dot with ring -->
<circle class="track-dot" cx="140" cy="32" r="4"/>
<circle cx="140" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<line class="track" x1="144" y1="32" x2="170" y2="32"/>
<!-- delete keyword -->
<g class="terminal" transform="translate(170, 16)">
<rect width="72" height="32" rx="16"/>
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">delete</text>
</g>
<line class="track" x1="242" y1="32" x2="270" y2="32"/>
<!-- where keyword -->
<g class="terminal" transform="translate(270, 16)">
<rect width="70" height="32" rx="16"/>
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
</g>
<line class="track" x1="340" y1="32" x2="370" y2="32"/>
<!-- condition -->
<g class="nonterminal" transform="translate(370, 16)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
</g>
<line class="track" x1="470" y1="32" x2="510" y2="32"/>
<!-- exit dot with double circle -->
<circle class="track-dot" cx="510" cy="32" r="4"/>
<circle cx="510" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,307 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 708">
<!--
Trigger railroad diagram — transparent/dark-bg compatible
6 tracks: trigger, timing, event, action, deny, runAction
-->
<defs>
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
</marker>
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
</filter>
<style>
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
</style>
</defs>
<!-- title -->
<text class="title" x="450" y="22" text-anchor="middle">Trigger grammar</text>
<!-- all tracks shifted down 8px to accommodate title -->
<g transform="translate(0, 8)">
<!-- ==================== trigger ==================== -->
<!-- Main line y=50, bypass above at y=10 for optional where -->
<g transform="translate(0, 30)">
<g>
<rect x="2" y="40" width="71" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="54" text-anchor="start">trigger</text>
</g>
<!-- entry dot with outer ring -->
<circle cx="120" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<circle cx="120" cy="50" r="4" fill="#94A3B8"/>
<line class="track" x1="124" y1="50" x2="150" y2="50"/>
<!-- timing non-terminal -->
<g class="nonterminal" transform="translate(150, 34)">
<rect width="80" height="32" rx="4"/>
<text x="40" y="16" text-anchor="middle" dominant-baseline="central">timing</text>
</g>
<line class="track" x1="230" y1="50" x2="256" y2="50"/>
<!-- event non-terminal -->
<g class="nonterminal" transform="translate(256, 34)">
<rect width="72" height="32" rx="4"/>
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">event</text>
</g>
<line class="track" x1="328" y1="50" x2="358" y2="50"/>
<!-- optional where + condition: main path -->
<g class="terminal" transform="translate(358, 34)">
<rect width="70" height="32" rx="16"/>
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
</g>
<line class="track" x1="428" y1="50" x2="454" y2="50"/>
<g class="nonterminal" transform="translate(454, 34)">
<rect width="100" height="32" rx="4"/>
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
</g>
<line class="track" x1="554" y1="50" x2="600" y2="50"/>
<!-- bypass above for optional where+condition -->
<path class="track" d="M 348,50 C 348,20 358,10 378,10 L 570,10 C 590,10 600,20 600,50"/>
<!-- alternative: action or deny -->
<!-- upper path: action -->
<path class="track" d="M 600,50 C 600,20 610,10 630,10"/>
<g class="nonterminal" transform="translate(630, -6)">
<rect width="80" height="32" rx="4"/>
<text x="40" y="16" text-anchor="middle" dominant-baseline="central">action</text>
</g>
<path class="track" d="M 710,10 C 730,10 740,20 740,50"/>
<!-- lower path: deny -->
<path class="track" d="M 600,50 C 600,80 610,90 630,90"/>
<g class="nonterminal" transform="translate(630, 74)">
<rect width="80" height="32" rx="4"/>
<text x="40" y="16" text-anchor="middle" dominant-baseline="central">deny</text>
</g>
<path class="track" d="M 710,90 C 730,90 740,80 740,50"/>
<line class="track" x1="740" y1="50" x2="780" y2="50"/>
<!-- exit dot with thicker outer ring -->
<circle cx="780" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
<circle cx="780" cy="50" r="4" fill="#94A3B8"/>
</g>
<!-- separator between trigger and timing -->
<line x1="10" y1="160" x2="890" y2="160" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== timing ==================== -->
<g transform="translate(0, 170)">
<g>
<rect x="2" y="22" width="64" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="36" text-anchor="start">timing</text>
</g>
<!-- entry dot with outer ring -->
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
<line class="track" x1="124" y1="32" x2="160" y2="32"/>
<!-- upper: before -->
<path class="track" d="M 160,32 C 160,10 170,0 190,0"/>
<g class="terminal" transform="translate(190, -16)">
<rect width="78" height="32" rx="16"/>
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">before</text>
</g>
<path class="track" d="M 268,0 C 288,0 298,10 298,32"/>
<!-- lower: after -->
<path class="track" d="M 160,32 C 160,54 170,64 190,64"/>
<g class="terminal" transform="translate(190, 48)">
<rect width="78" height="32" rx="16"/>
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">after</text>
</g>
<path class="track" d="M 268,64 C 288,64 298,54 298,32"/>
<line class="track" x1="298" y1="32" x2="340" y2="32"/>
<!-- exit dot with thicker outer ring -->
<circle cx="340" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
<circle cx="340" cy="32" r="4" fill="#94A3B8"/>
</g>
<!-- separator between timing and event -->
<line x1="10" y1="278" x2="890" y2="278" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== event ==================== -->
<g transform="translate(0, 290)">
<g>
<rect x="2" y="22" width="56" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="36" text-anchor="start">event</text>
</g>
<!-- entry dot with outer ring -->
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
<line class="track" x1="124" y1="32" x2="160" y2="32"/>
<!-- top: create -->
<path class="track" d="M 160,32 C 160,2 170,-8 190,-8"/>
<g class="terminal" transform="translate(190, -24)">
<rect width="78" height="32" rx="16"/>
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">create</text>
</g>
<path class="track" d="M 268,-8 C 288,-8 298,2 298,32"/>
<!-- middle: update -->
<g class="terminal" transform="translate(190, 16)">
<rect width="78" height="32" rx="16"/>
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">update</text>
</g>
<!-- straight through for middle option -->
<line class="track" x1="160" y1="32" x2="190" y2="32"/>
<line class="track" x1="268" y1="32" x2="298" y2="32"/>
<!-- bottom: delete -->
<path class="track" d="M 160,32 C 160,62 170,72 190,72"/>
<g class="terminal" transform="translate(190, 56)">
<rect width="78" height="32" rx="16"/>
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">delete</text>
</g>
<path class="track" d="M 268,72 C 288,72 298,62 298,32"/>
<line class="track" x1="298" y1="32" x2="340" y2="32"/>
<!-- exit dot with thicker outer ring -->
<circle cx="340" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
<circle cx="340" cy="32" r="4" fill="#94A3B8"/>
</g>
<!-- separator between event and action -->
<line x1="10" y1="407" x2="890" y2="407" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== action ==================== -->
<!-- Center at y=55 to give room for 4 alternatives above and below -->
<g transform="translate(0, 420)">
<g>
<rect x="2" y="45" width="64" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="59" text-anchor="start">action</text>
</g>
<!-- entry dot with outer ring -->
<circle cx="120" cy="55" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<circle cx="120" cy="55" r="4" fill="#94A3B8"/>
<line class="track" x1="124" y1="55" x2="160" y2="55"/>
<!-- top: runAction -->
<path class="track" d="M 160,55 C 160,10 170,-5 190,-5"/>
<g class="nonterminal" transform="translate(190, -21)">
<rect width="104" height="32" rx="4"/>
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">runAction</text>
</g>
<path class="track" d="M 294,-5 C 314,-5 324,10 324,55"/>
<!-- upper-middle: createStmt -->
<path class="track" d="M 160,55 C 160,35 170,25 190,25"/>
<g class="nonterminal" transform="translate(190, 9)">
<rect width="104" height="32" rx="4"/>
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">createStmt</text>
</g>
<path class="track" d="M 294,25 C 314,25 324,35 324,55"/>
<!-- lower-middle: updateStmt (straight through) -->
<line class="track" x1="160" y1="55" x2="190" y2="55"/>
<g class="nonterminal" transform="translate(190, 39)">
<rect width="104" height="32" rx="4"/>
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">updateStmt</text>
</g>
<line class="track" x1="294" y1="55" x2="324" y2="55"/>
<!-- bottom: deleteStmt -->
<path class="track" d="M 160,55 C 160,75 170,85 190,85"/>
<g class="nonterminal" transform="translate(190, 69)">
<rect width="104" height="32" rx="4"/>
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">deleteStmt</text>
</g>
<path class="track" d="M 294,85 C 314,85 324,75 324,55"/>
<line class="track" x1="324" y1="55" x2="370" y2="55"/>
<!-- exit dot with thicker outer ring -->
<circle cx="370" cy="55" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
<circle cx="370" cy="55" r="4" fill="#94A3B8"/>
</g>
<!-- separator between action and deny -->
<line x1="10" y1="548" x2="890" y2="548" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== deny ==================== -->
<g transform="translate(0, 560)">
<g>
<rect x="2" y="22" width="52" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="36" text-anchor="start">deny</text>
</g>
<!-- entry dot with outer ring -->
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
<line class="track" x1="124" y1="32" x2="150" y2="32"/>
<g class="terminal" transform="translate(150, 16)">
<rect width="62" height="32" rx="16"/>
<text x="31" y="16" text-anchor="middle" dominant-baseline="central">deny</text>
</g>
<line class="track" x1="212" y1="32" x2="240" y2="32"/>
<g class="nonterminal" transform="translate(240, 16)">
<rect width="72" height="32" rx="4"/>
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">string</text>
</g>
<line class="track" x1="312" y1="32" x2="350" y2="32"/>
<!-- exit dot with thicker outer ring -->
<circle cx="350" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
<circle cx="350" cy="32" r="4" fill="#94A3B8"/>
</g>
<!-- separator between deny and runAction -->
<line x1="10" y1="620" x2="890" y2="620" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- ==================== runAction ==================== -->
<g transform="translate(0, 630)">
<g>
<rect x="2" y="22" width="90" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
<text class="label" x="10" y="36" text-anchor="start">runAction</text>
</g>
<!-- entry dot with outer ring -->
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
<line class="track" x1="124" y1="32" x2="150" y2="32"/>
<g class="terminal" transform="translate(150, 16)">
<rect width="52" height="32" rx="16"/>
<text x="26" y="16" text-anchor="middle" dominant-baseline="central">run</text>
</g>
<line class="track" x1="202" y1="32" x2="222" y2="32"/>
<g class="terminal" transform="translate(222, 16)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
</g>
<line class="track" x1="252" y1="32" x2="272" y2="32"/>
<g class="nonterminal" transform="translate(272, 16)">
<rect width="60" height="32" rx="4"/>
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
</g>
<line class="track" x1="332" y1="32" x2="352" y2="32"/>
<g class="terminal" transform="translate(352, 16)">
<rect width="30" height="32" rx="16"/>
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
</g>
<line class="track" x1="382" y1="32" x2="420" y2="32"/>
<!-- exit dot with thicker outer ring -->
<circle cx="420" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
<circle cx="420" cy="32" r="4" fill="#94A3B8"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 310">
<defs>
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
</filter>
<filter id="success-glow" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<marker id="flow-arrow" viewBox="0 0 12 8" refX="12" refY="4" markerWidth="12" markerHeight="8" orient="auto">
<path d="M 0 0 L 12 4 L 0 8 z" fill="#94A3B8"/>
</marker>
<marker id="err-arrow" viewBox="0 0 12 8" refX="12" refY="4" markerWidth="12" markerHeight="8" orient="auto">
<path d="M 0 0 L 12 4 L 0 8 z" fill="#F87171"/>
</marker>
<style>
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
.stage-text { font: 700 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; }
.data-text { font: 600 12px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #6EE7B7; }
.err-title { font: 700 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCA5A5; }
.err-detail { font: 400 10px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FDA4AF; }
.flow-line { stroke: #94A3B8; stroke-width: 1.5; fill: none; marker-end: url(#flow-arrow); }
.err-line { stroke: #F87171; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; marker-end: url(#err-arrow); }
.err-line-solid { stroke: #F87171; stroke-width: 1.5; fill: none; marker-end: url(#err-arrow); }
.stage-label { font: 500 10px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #64748B; }
.legend-text { font: 400 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #94A3B8; }
</style>
</defs>
<text class="title" x="410" y="24" text-anchor="middle">Validation pipeline</text>
<!-- ===== Main pipeline (y=70) ===== -->
<!-- Input text -->
<rect x="20" y="50" width="90" height="40" rx="10" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
<text class="data-text" x="65" y="74" text-anchor="middle">Input text</text>
<line class="flow-line" x1="110" y1="70" x2="148" y2="70"/>
<!-- Lexer / Parser -->
<rect x="148" y="46" width="134" height="48" rx="10" fill="#3B82F6" fill-opacity="0.12" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
<text class="stage-text" x="215" y="74" text-anchor="middle">Lexer / Parser</text>
<line class="flow-line" x1="282" y1="70" x2="318" y2="70"/>
<!-- AST -->
<rect x="318" y="52" width="60" height="36" rx="10" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
<text class="data-text" x="348" y="74" text-anchor="middle">AST</text>
<line class="flow-line" x1="378" y1="70" x2="414" y2="70"/>
<!-- Semantic Validator -->
<rect x="414" y="46" width="176" height="48" rx="10" fill="#3B82F6" fill-opacity="0.12" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
<text class="stage-text" x="502" y="74" text-anchor="middle">Semantic Validator</text>
<line class="flow-line" x1="590" y1="70" x2="626" y2="70"/>
<!-- Valid AST -->
<rect x="626" y="50" width="100" height="40" rx="10" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.4" filter="url(#success-glow)"/>
<text class="data-text" x="676" y="74" text-anchor="middle">Valid AST</text>
<!-- ===== Parse errors (branch down from Lexer/Parser) ===== -->
<line class="err-line" x1="215" y1="94" x2="215" y2="140"/>
<!-- stage 1 label with pill background -->
<rect x="191" y="122" width="50" height="16" rx="4" fill="#64748B" fill-opacity="0.15"/>
<text class="stage-label" x="215" y="134" text-anchor="middle">stage 1</text>
<rect x="130" y="144" width="170" height="52" rx="8" fill="#EF4444" fill-opacity="0.1" stroke="#EF4444" stroke-width="1.5" stroke-opacity="0.35" filter="url(#shadow)"/>
<text class="err-title" x="215" y="164" text-anchor="middle">Parse errors</text>
<text class="err-detail" x="215" y="180" text-anchor="middle">unknown keyword, missing clause, bad syntax</text>
<!-- ===== Semantic errors (branch down from Semantic Validator) ===== -->
<line class="err-line" x1="502" y1="94" x2="502" y2="140"/>
<!-- stage 2 label with pill background -->
<rect x="478" y="122" width="50" height="16" rx="4" fill="#64748B" fill-opacity="0.15"/>
<text class="stage-label" x="502" y="134" text-anchor="middle">stage 2</text>
<!-- Semantic error umbrella -->
<rect x="350" y="144" width="304" height="36" rx="8" fill="#EF4444" fill-opacity="0.1" stroke="#EF4444" stroke-width="1.5" stroke-opacity="0.35" filter="url(#shadow)"/>
<text class="err-title" x="502" y="166" text-anchor="middle">Semantic validation errors</text>
<!-- Sub-category connectors -->
<line class="err-line-solid" x1="400" y1="180" x2="400" y2="200"/>
<line class="err-line-solid" x1="502" y1="180" x2="502" y2="200"/>
<line class="err-line-solid" x1="604" y1="180" x2="604" y2="200"/>
<!-- Sub-category boxes -->
<rect x="345" y="200" width="110" height="52" rx="8" fill="#EF4444" fill-opacity="0.08" stroke="#EF4444" stroke-width="1" stroke-opacity="0.25" filter="url(#shadow)"/>
<text class="err-title" x="400" y="219" text-anchor="middle">Structural</text>
<text class="err-detail" x="400" y="237" text-anchor="middle">dup fields, trigger rules</text>
<rect x="467" y="200" width="110" height="52" rx="8" fill="#EF4444" fill-opacity="0.08" stroke="#EF4444" stroke-width="1" stroke-opacity="0.25" filter="url(#shadow)"/>
<text class="err-title" x="522" y="219" text-anchor="middle">Field / Qualifier</text>
<text class="err-detail" x="522" y="237" text-anchor="middle">unknown, old./new. scope</text>
<rect x="589" y="200" width="110" height="52" rx="8" fill="#EF4444" fill-opacity="0.08" stroke="#EF4444" stroke-width="1" stroke-opacity="0.25" filter="url(#shadow)"/>
<text class="err-title" x="644" y="219" text-anchor="middle">Type / Operator</text>
<text class="err-detail" x="644" y="237" text-anchor="middle">mismatch, bad usage</text>
<!-- ===== Legend ===== -->
<rect x="200" y="275" width="12" height="12" rx="3" fill="#3B82F6" fill-opacity="0.12" stroke="#3B82F6" stroke-width="1" stroke-opacity="0.4"/>
<text class="legend-text" x="218" y="285">processing stage</text>
<rect x="350" y="275" width="12" height="12" rx="3" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1" stroke-opacity="0.4"/>
<text class="legend-text" x="368" y="285">data</text>
<rect x="430" y="275" width="12" height="12" rx="3" fill="#EF4444" fill-opacity="0.1" stroke="#EF4444" stroke-width="1" stroke-opacity="0.35"/>
<text class="legend-text" x="448" y="285">error</text>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

View file

@ -0,0 +1,33 @@
# ruki Documentation
## Table of contents
- [ruki](#ruki)
- [Quick start](#quick-start)
- [More details](#more-details)
## ruki
This section documents the `ruki` language. `ruki` is a small language for finding, creating, updating, and deleting tikis, with SQL-like statements and trigger rules.
## Quick start
New users: start with [Quick Start](quick-start.md) and [Examples](examples.md).
## Recipes
ready-to-use examples for common workflow patterns
- [Plugins](../ideas/plugins.md)
- [Triggers](../ideas/triggers.md)
## More details
- [Syntax](syntax.md): lexical structure and grammar-oriented reference.
- [Semantics](semantics.md): statement behavior, trigger structure, qualifier scope, and evaluation model.
- [Triggers](triggers.md): configuration, runtime execution model, cascade behavior, and operational 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

@ -0,0 +1,290 @@
# Operators And Built-ins
## Table of contents
- [Overview](#overview)
- [Operator precedence and associativity](#operator-precedence-and-associativity)
- [Comparison operators](#comparison-operators)
- [Membership](#membership)
- [Any And All](#any-and-all)
- [Binary expression operators](#binary-expression-operators)
- [Built-in functions](#built-in-functions)
- [Shell-related forms](#shell-related-forms)
## Overview
This page describes the operators and built-in functions available in `ruki`.
## Operator precedence and associativity
Condition precedence:
| Level | Operators or forms | Associativity |
|---|---|---|
| 1 | condition in parentheses, comparison, `is empty`, `in`, `not in`, `any`, `all` | n/a |
| 2 | `not` | right-associative by grammar recursion |
| 3 | `and` | left-associative |
| 4 | `or` | left-associative |
Expression precedence:
| Level | Operators | Associativity |
|---|---|---|
| 1 | `+`, `-` | left-associative |
Examples:
```sql
select where not status = "done"
select where priority = 1 or priority = 2 and status = "done"
create title="x" due=2026-03-25 + 2day - 1day
```
## Comparison operators
Supported operators:
- `=`
- `!=`
- `<`
- `>`
- `<=`
- `>=`
Supported by type:
| Type | `=` / `!=` | Ordering operators |
|---|---|---|
| `string` | yes | no |
| `status` | yes | no |
| `type` | yes | no |
| `id` | yes | no |
| `ref` | yes | no |
| `int` | yes | yes |
| `date` | yes | yes |
| `timestamp` | yes | yes |
| `duration` | yes | yes |
| `bool` | yes | no |
| `recurrence` | yes | no |
| list types | yes | no |
Examples:
```sql
select where priority <= 2
select where due < 2026-03-25
select where updatedAt - createdAt > 7day
select where title != "hello"
```
Invalid examples:
```sql
select where status < "done"
select where title < "hello"
```
## Membership
Membership and substring:
- `<expr> in <collection>`
- `<expr> not in <collection>`
The `in` operator has two modes depending on the right-hand side:
**List membership** — when the right side is a list:
- checks whether the value appears in the list
- membership uses stricter compatibility than general comparison typing
- `id` and `ref` are treated as compatible for membership
**Substring check** — when the right side is a `string` field:
- checks whether the left side is a substring of the right side
- both sides must be `string` type (not `status`, `type`, `id`, or `ref`)
Examples:
```sql
select where "bug" in tags
select where id in dependsOn
select where status in ["done", "cancelled"]
select where status not in ["done"]
select where "bug" in title
select where "bug" in title and "fix" in title
select where "ali" in assignee
select where "bug" not in title
```
Invalid examples:
```sql
select where "done" in status
select where "x" in id
```
## Any And All
`any` means at least one related tiki matches the condition.
`all` means every related tiki matches the condition.
Use them after a field that contains related tikis, such as `dependsOn`:
- `<expr> any <condition>`
- `<expr> all <condition>`
Rules:
- the left side must be a field that contains related tikis, such as `dependsOn`
- after `any` or `all`, write a condition about those related tikis
- do not use `old.` or `new.` inside that condition
Examples:
```sql
select where dependsOn any status != "done"
select where dependsOn all status = "done"
```
These mean:
- `dependsOn any status != "done"`: at least one dependency is not done
- `dependsOn all status = "done"`: every dependency is done
Invalid examples:
```sql
select where tags any status = "done"
before update where dependsOn any old.status = "done" deny "blocked"
```
## Binary expression operators
`+`
- string-like plus string-like yields `string`
- `int + int` yields `int`
- `list<string> + string` and `list<string> + list<string>` yield `list<string>`
- `list<ref> + id-or-ref-compatible value` and `list<ref> + list<ref>` yield `list<ref>`
- `date + duration` yields `date`
- `timestamp + duration` yields `timestamp`
`-`
- `int - int` yields `int`
- `list<string> - string` and `list<string> - list<string>` yield `list<string>`
- `list<ref> - id-or-ref-compatible value` and `list<ref> - list<ref>` yield `list<ref>`
- `date - duration` yields `date`
- `date - date` yields `duration`
- `timestamp - duration` yields `timestamp`
- `timestamp - timestamp` yields `duration`
Examples:
```sql
create title="hello" + " world"
create title="x" tags=tags + ["new"]
create title="x" dependsOn=dependsOn - ["TIKI-ABC123"]
create title="x" due=2026-03-25 + 2day
select where updatedAt - createdAt > 1day
```
Invalid examples:
```sql
create title="x" priority=1 + "a"
select where due = 2026-03-25 + 2026-03-20
create title="x" dependsOn=dependsOn + tags
```
## Built-in functions
`ruki` has these built-ins:
| Name | Result type | Arguments | Notes |
|---|---|---|---|
| `count(...)` | `int` | exactly 1 | argument must be a `select` subquery |
| `now()` | `timestamp` | 0 | no additional validation |
| `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 |
Examples:
```sql
select where count(select where status = "done") >= 1
select where updatedAt < now()
create title="x" due=next_date(recurrence)
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:
- `id()` is semantically valid only in plugin runtime.
- 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(...)`
- not a normal expression built-in
- only valid as the top-level action of an `after` trigger
- its command expression must validate as `string`
Example:
```sql
after update where new.status = "in progress" run("echo hello")
```
## Shell-related forms
`ruki` includes four shell-related forms:
**`call(...)`** — a string-returning expression
- 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

@ -0,0 +1,147 @@
# Quick Start
## Table of contents
- [Overview](#overview)
- [Mental model](#mental-model)
- [CRUD statements](#crud-statements)
- [Conditions and expressions](#conditions-and-expressions)
- [Triggers](#triggers)
- [Where to go next](#where-to-go-next)
## Overview
This page is a practical introduction to the `ruki` language. It covers the main statement forms, the conditions they use, and the trigger rules that let you block or react to changes.
## Mental model
`ruki` has two top-level forms:
- Statements: `select`, `create`, `update`, and `delete`
- Triggers: `before` or `after` rules attached to `create`, `update`, or `delete`
Statements read and change tiki fields such as `status`, `type`, `tags`, `dependsOn`, `priority`, and `due`. Triggers use the same fields and conditions, but add `before` or `after` timing around `create`, `update`, or `delete`.
The simplest way to read `ruki` is:
- `select` filters tikis
- `create` assigns fields for a new tiki
- `update` finds tikis with `where`, then applies `set`
- `delete` finds tikis with `where`, then removes them
- `before ... deny "message"` blocks an operation when its guard matches
- `after ... <action>` reacts to an operation by creating, updating, deleting, or `run(...)`
## CRUD statements
`select` reads:
```sql
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:
```sql
create title="Fix login"
create title="Fix login" priority=2 status="ready" tags=["bug"]
```
`update` always has a `where` clause and a `set` clause:
```sql
update where id = "TIKI-ABC123" set status="done"
update where status = "ready" and "sprint-3" in tags set status="cancelled"
```
`delete` always has a `where` clause:
```sql
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:
- comparisons such as `status = "done"` or `priority <= 2`
- emptiness checks such as `assignee is empty`
- membership checks such as `"bug" in tags`
- quantifiers over `list<ref>` values such as `dependsOn any status != "done"`
- boolean composition with `not`, `and`, and `or`
Examples:
```sql
select where status = "done" and priority <= 2
select where assignee is empty
select where status not in ["done", "cancelled"]
select where dependsOn all status = "done"
select where not (status = "done" or priority = 1)
```
Expressions include literals, field references, built-in calls, list literals, subqueries for `count(...)`, and `+` or `-` binary expressions:
```sql
create title="Fix login"
create title="x" due=2026-03-25 + 2day
create title="x" tags=tags + ["needs-triage"]
create title="x" due=next_date(recurrence)
select where count(select where status = "done") >= 1
```
## Triggers
Triggers add timing and event context around the same condition language.
`before` triggers can only `deny`:
```sql
before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete tiki with open dependencies"
before delete where old.priority <= 2 deny "cannot delete high priority tikis"
```
`after` triggers can perform an action:
```sql
after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"
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="ready"
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
```
`after` triggers may also use `run(...)` as a top-level action:
```sql
after update where new.status = "in progress" and "claude" in new.tags run("claude -p 'implement tiki " + old.id + "'")
```
Triggers are configured in `workflow.yaml` under the `triggers:` key. See [Triggers](triggers.md) for configuration details and runtime behavior.
## Where to go next
### Recipes
ready-to-use examples for common workflow patterns
- [Plugins](../ideas/plugins.md)
- [Triggers](../ideas/triggers.md)
### More info
- Use [Triggers](triggers.md) for configuration, execution model, and runtime behavior.
- Use [Syntax](syntax.md) for the grammar-level reference.
- Use [Types And Values](types-and-values.md) for the type system and literal rules.
- Use [Operators And Built-ins](operators-and-builtins.md) for precedence and function signatures.
- Use [Examples](examples.md) for more complete programs and invalid cases.

View file

@ -0,0 +1,241 @@
# Semantics
## Table of contents
- [Overview](#overview)
- [Statement semantics](#statement-semantics)
- [Trigger semantics](#trigger-semantics)
- [Qualifier scope](#qualifier-scope)
- [Time trigger semantics](#time-trigger-semantics)
- [Condition and expression semantics](#condition-and-expression-semantics)
## Overview
This page explains how `ruki` statements, triggers, conditions, and expressions behave.
## Statement semantics
`select`
- `select` without `where` means a statement with no condition node.
- `select where ...` validates the condition and its contained expressions.
- `select ... order by <field> [asc|desc], ...` specifies result ordering.
- A subquery form `select` or `select where ...` can appear only inside `count(...)`. Subqueries do not support `order by`.
`order by`
- Each field must exist in the schema and be an orderable type.
- Orderable types: `int`, `date`, `timestamp`, `duration`, `string`, `status`, `type`, `id`, `ref`.
- Non-orderable types: `list<string>`, `list<ref>`, `recurrence`, `bool`.
- Default direction is ascending. Use `desc` for descending.
- 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.
- At least one assignment is required.
- The resulting task must have a non-empty `title`. This can come from an explicit `title=...` assignment or from the task template.
- Duplicate assignments to the same field are rejected.
- Every assigned field must exist in the injected schema.
- `id`, `createdBy`, `createdAt`, and `updatedAt` are immutable and cannot be assigned.
`update`
- `update` has two parts: a `where` condition and a `set` assignment list.
- At least one assignment in `set` is required.
- The `where` clause and every right-hand side expression are validated.
- Duplicate assignments inside `set` are rejected.
- `id`, `createdBy`, `createdAt`, and `updatedAt` are immutable and cannot be assigned.
`delete`
- `delete` always requires a `where` condition.
- The `where` condition is validated exactly like `select where ...`.
## Trigger semantics
Triggers have the shape:
```text
<timing> <event> [where <condition>] <deny-or-action>
```
Rules:
- `before` triggers must have `deny`.
- `before` triggers must not have an action or `run(...)`.
- `after` triggers must not have `deny`.
- `after` triggers must have either a CRUD action or `run(...)`.
- trigger CRUD actions may be `create`, `update`, or `delete`, but not `select`
Examples:
```sql
before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete tiki with open dependencies"
after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
```
At runtime, triggers execute in a pipeline: before-triggers run as validators before persistence, the mutation is persisted, then after-triggers run as hooks. For the full execution model, cascade behavior, configuration, and runtime details, see [Triggers](triggers.md).
## Qualifier scope
Qualifier rules depend on the event:
- `create` triggers: `new.` is allowed, `old.` is not
- `delete` triggers: `old.` is allowed, `new.` is not
- `update` triggers: both `old.` and `new.` are allowed
- standalone statements: neither `old.` nor `new.` is allowed
Examples:
```sql
before create where new.type = "story" and new.description is empty deny "stories must have a description"
before delete where old.priority <= 2 deny "cannot delete high priority tikis"
before update where old.status = "in progress" and new.status = "done" deny "tikis must go through review before completion"
```
![Qualifier scope by context](images/qualifier-scope.svg)
Important special case:
- inside a quantifier body such as `dependsOn any ...`, qualifiers are disabled again
- use bare fields inside the quantifier body, not `old.` or `new.`
Example:
```sql
before update where dependsOn any status = "done" deny "blocked"
```
## Time trigger semantics
Time triggers have the shape:
```text
every <duration> <statement>
```
Rules:
- the interval must be a positive duration (e.g. `1hour`, `2day`, `1week`)
- the inner statement must be `create`, `update`, or `delete` — not `select`
- `run()` is not allowed inside a time trigger
- `old.` and `new.` qualifiers are not allowed — there is no mutation context for a periodic operation
- bare field references in the inner statement resolve against the tasks being matched, exactly as in standalone statements
Examples:
```sql
every 1hour update where status = "in_progress" and updatedAt < now() - 7day set status="backlog"
every 1day delete where status = "done" and updatedAt < now() - 30day
every 2week create title="sprint review" status="ready" priority=3
```
## Condition and expression semantics
Conditions:
- comparisons validate both operand types before checking operator legality
- `is empty` and `is not empty` are allowed on every supported type
- `in` and `not in` require a collection on the right side
- `any` and `all` require `list<ref>` on the left side
Expressions:
- field references resolve through the injected schema
- qualified references use the same field catalog, then apply qualifier-policy checks
- list literals must be homogeneous
- `empty` is a context-sensitive zero value, resolved by surrounding type checks
- subqueries are only legal as the argument to `count(...)`
Binary `+` and `-` are semantic rather than purely numeric:
- string-like `+` yields `string`
- `int + int` and `int - int` yield `int`
- `list<string> +/- string-or-list<string>` yields `list<string>`
- `list<ref> +/- id-ref-compatible values` yields `list<ref>`
- `date + duration` yields `date`
- `date - duration` yields `date`
- `date - date` yields `duration`
- `timestamp + duration` yields `timestamp`
- `timestamp - duration` yields `timestamp`
- `timestamp - timestamp` yields `duration`
![Binary operator type resolution](images/binary-op-types.svg)
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

@ -0,0 +1,229 @@
# Syntax
## Table of contents
- [Overview](#overview)
- [Lexical structure](#lexical-structure)
- [Top-level grammar](#top-level-grammar)
- [Condition grammar](#condition-grammar)
- [Expression grammar](#expression-grammar)
- [Operator binding summary](#operator-binding-summary)
- [Syntax notes](#syntax-notes)
## Overview
This page describes `ruki` syntax. It starts with tokens and then shows the grammar for statements, triggers, conditions, and expressions.
## Lexical structure
`ruki` uses these token classes:
- comments: `--` to end of line
- whitespace: ignored between tokens
- durations: `\d+(sec|min|hour|day|week|month|year)s?`
- dates: `YYYY-MM-DD`
- integers: decimal digits only
- strings: double-quoted strings with backslash escapes
- comparison operators: `=`, `!=`, `<`, `>`, `<=`, `>=`
- binary operators: `+`, `-`
- star: `*`
- punctuation: `.`, `(`, `)`, `[`, `]`, `,`
- identifiers: `[a-zA-Z_][a-zA-Z0-9_]*`
Examples:
```sql
-- line comment
2026-03-25
2day
"hello"
dependsOn
new.status
```
## Top-level grammar
The following EBNF-style summary shows the grammar:
```text
statement = selectStmt | createStmt | updateStmt | deleteStmt ;
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 } ;
sortField = identifier [ "asc" | "desc" ] ;
updateStmt = "update" "where" condition "set" assignment { assignment } ;
deleteStmt = "delete" "where" condition ;
assignment = identifier "=" expr ;
trigger = timing event [ "where" condition ] ( action | deny ) ;
timing = "before" | "after" ;
event = "create" | "update" | "delete" ;
action = runAction | createStmt | updateStmt | deleteStmt ;
runAction = "run" "(" expr ")" ;
deny = "deny" string ;
timeTrigger = "every" duration ( createStmt | updateStmt | deleteStmt ) ;
```
![Statement grammar railroad diagram](images/stmt-railroad.svg)
![Trigger grammar railroad diagram](images/trigger-railroad.svg)
Notes:
- `select` is a valid top-level statement, but it is not valid as a trigger action.
- `create` requires at least one assignment.
- `update` requires both `where` and `set`.
- `delete` requires `where`.
- `order by` is only valid on `select`, not on subqueries inside `count(...)`.
- `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
## Condition grammar
Condition precedence follows this order:
```text
condition = orCond ;
orCond = andCond { "or" andCond } ;
andCond = notCond { "and" notCond } ;
notCond = "not" notCond | primaryCond ;
primaryCond = "(" condition ")" | exprCond ;
exprCond = expr
[ compareTail
| isEmptyTail
| isNotEmptyTail
| notInTail
| inTail
| anyTail
| allTail ] ;
compareTail = compareOp expr ;
isEmptyTail = "is" "empty" ;
isNotEmptyTail = "is" "not" "empty" ;
inTail = "in" expr ;
notInTail = "not" "in" expr ;
anyTail = "any" primaryCond ;
allTail = "all" primaryCond ;
```
![Condition grammar railroad diagram](images/cond-railroad.svg)
Examples:
```sql
select where status = "done"
select where assignee is empty
select where status not in ["done", "cancelled"]
select where dependsOn any status != "done"
select where not (status = "done" or priority = 1)
```
Field list:
```sql
select title, status
select id, title where status = "done"
select * where priority <= 2
select title, status where "bug" in tags order by priority
```
Order by:
```sql
select order by priority
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:
```text
expr = unaryExpr { ("+" | "-") unaryExpr } ;
unaryExpr = funcCall
| subQuery
| qualifiedRef
| listLiteral
| string
| date
| duration
| int
| emptyLiteral
| fieldRef
| "(" expr ")" ;
funcCall = identifier "(" [ expr { "," expr } ] ")" ;
subQuery = "select" [ "where" condition ] ;
qualifiedRef = ( "old" | "new" ) "." identifier ;
listLiteral = "[" [ expr { "," expr } ] "]" ;
emptyLiteral = "empty" ;
fieldRef = identifier ;
```
![Expression grammar railroad diagram](images/expr-railroad.svg)
Examples:
```sql
title
old.status
["bug", "frontend"]
next_date(recurrence)
count(select where status = "done")
2026-03-25 + 2day
tags + ["needs-triage"]
```
## Operator binding summary
Condition operators:
- highest: a condition in parentheses, or a condition built from a single expression
- then: `not`
- then: `and`
- lowest: `or`
Expression operators:
- only one binary precedence level exists for expressions
- `+` and `-` associate left to right
That means:
```sql
select where priority = 1 or priority = 2 and status = "done"
```
parses as:
```text
priority = 1 or (priority = 2 and status = "done")
```
## Syntax notes
- `any` and `all` apply to the condition that comes right after them. If you want to combine that condition with `and` or `or`, use parentheses.
- `select` used inside expressions is only valid as a `count(...)` argument. Bare subqueries are rejected during validation.
- The grammar accepts `run(<expr>)`, but only as the top-level action of an `after` trigger.
- `old.` and `new.` are only allowed in some trigger conditions. See [Semantics](semantics.md) and [Validation And Errors](validation-and-errors.md).

View file

@ -0,0 +1,320 @@
# Triggers
## Table of contents
- [Overview](#overview)
- [What triggers look like](#what-triggers-look-like)
- [Configuration](#configuration)
- [Patterns](#patterns)
- [Tips and gotchas](#tips-and-gotchas)
- [Execution pipeline](#execution-pipeline)
- [Before-trigger behavior](#before-trigger-behavior)
- [After-trigger behavior](#after-trigger-behavior)
- [Cascade depth](#cascade-depth)
- [The run() action](#the-run-action)
- [Configuration discovery details](#configuration-discovery-details)
- [Time triggers](#time-triggers)
- [Startup and error handling](#startup-and-error-handling)
## Overview
Triggers are reactive rules that fire when tikis are created, updated, or deleted. A before-trigger can block a mutation with a denial message. An after-trigger can react to a mutation by creating, updating, deleting tikis, or running a shell command.
This page covers how triggers are configured, how they execute at runtime, and common patterns. For the grammar, see [Syntax](syntax.md). For structural rules and qualifier scoping, see [Semantics](semantics.md). For parse and validation errors, see [Validation And Errors](validation-and-errors.md).
## What triggers look like
A before-trigger guards against unwanted changes:
```sql
-- block completing a tiki that has unfinished dependencies
before update
where new.status = "done" and dependsOn any status != "done"
deny "cannot complete tiki with open dependencies"
```
- `before update` — fires before an update is persisted
- `where ...` — the guard condition; the trigger only fires when this matches
- `deny "..."` — the rejection message returned to the caller
An after-trigger automates a reaction:
```sql
-- when a recurring tiki is completed, create the next occurrence
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="ready"
```
- `after update` — fires after an update is persisted
- `where ...` — the guard; the action only runs when this matches
- `create ...` — the action to perform (can also be `update`, `delete`, or `run(...)`)
Triggers without a `where` clause fire on every matching event:
```sql
-- clean up reverse dependencies whenever a tiki is deleted
after delete
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
```
## Configuration
Triggers are defined in `workflow.yaml` under the `triggers:` key. Each entry has two fields:
- `ruki` — the trigger rule in `ruki` syntax (required)
- `description` — an optional label
```yaml
triggers:
- description: "block done with open deps"
ruki: >-
before update
where new.status = "done" and dependsOn any status != "done"
deny "resolve dependencies first"
- description: "auto-assign urgent"
ruki: >-
after create
where new.priority <= 2 and new.assignee is empty
update where id = new.id set assignee="booleanmaybe"
- description: "cleanup deps on delete"
ruki: >-
after delete
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
```
`workflow.yaml` is searched in the standard configuration locations described in [Configuration](../config.md#configuration-precedence). If multiple files define a `triggers:` section, the last one wins — cwd overrides project, which overrides user. A file without a `triggers:` key does not override anything. An explicit empty list (`triggers: []`) overrides inherited triggers to zero.
## Patterns
### Limit work in progress
Prevent anyone from having too many in-progress tikis at once:
```sql
before update
where new.status = "in progress"
and count(select where assignee = new.assignee and status = "in progress") >= 3
deny "WIP limit reached for this assignee"
```
The `count(select ...)` evaluates against the candidate state — the proposed update is already reflected in the count, so the limit fires before persistence.
### Auto-assign urgent work
Automatically assign high-priority tikis that arrive without an owner:
```sql
after create
where new.priority <= 2 and new.assignee is empty
update where id = new.id set assignee="booleanmaybe"
```
The after-trigger fires after the tiki is persisted, then updates it with the assignee. This cascades through the mutation gate, so any update validators (like WIP limits) still apply to the auto-assignment.
### Recurring task creation
When a recurring tiki is completed, create the next occurrence:
```sql
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="ready"
```
The new tiki inherits the original's title, priority, tags, and recurrence pattern. Its due date is set to the next occurrence using `next_date()`.
### Dependency cleanup on delete
When a tiki is deleted, remove it from every other tiki's `dependsOn` list:
```sql
after delete
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
```
This fires after every delete, with no guard condition. The `old.id in dependsOn` condition finds tikis that depend on the deleted one, and the set clause removes the reference.
### Cascade completion
Auto-complete an epic when all its dependencies are done:
```sql
after update
where new.status = "done"
update where id in blocks(old.id) and type = "epic"
and dependsOn all status = "done"
set status="done"
```
When any tiki is marked done, this finds epics that block on it. If all of the epic's other dependencies are also done, the epic is completed automatically. This itself fires further after-update triggers, so cascade chains work naturally (up to the depth limit).
### Propagate cancellation
When a tiki is cancelled, cancel downstream tikis that haven't started:
```sql
after update
where new.status = "cancelled"
update where id in blocks(old.id) and status in ["backlog", "ready"]
set status="cancelled"
```
Only tikis in `backlog` or `ready` are affected — in-progress work is not cancelled automatically.
### Run an external command
Trigger a script when a tiki enters a specific state:
```sql
after update
where new.status = "in progress" and "claude" in new.tags
run("claude -p 'implement tiki " + old.id + "'")
```
The `run()` action evaluates the expression to a command string, then executes it via `sh -c` with a 30-second timeout. Command failures are logged but do not block the mutation chain.
## Tips and gotchas
- Test your guard condition as a `select where ...` statement first. If the select returns unexpected results, the trigger will fire unexpectedly too.
- Before-triggers are fail-closed. If the guard expression itself has a runtime error, the mutation is rejected. Keep guard logic straightforward.
- Triggers that modify the same fields they guard on can cascade. For example, an after-update trigger that changes `status` will fire other after-update triggers. Design triggers to converge — avoid chains that cycle indefinitely. The cascade depth limit (8) prevents runaway loops, but silent termination is rarely what you want.
- `run()` commands execute with the permissions of the tiki process. Treat the `ruki` field in `workflow.yaml` the same as any other executable configuration.
- A parse error in any trigger definition prevents the app from starting. Validate your `workflow.yaml` before deploying.
---
## Execution pipeline
When a tiki is created, updated, or deleted, the mutation goes through this pipeline:
1. **Depth check** — reject if the trigger cascade depth exceeds the limit
2. **Before-validators** — run all registered before-triggers for this event; collect rejections
3. **Persist** — write the change to the store
4. **After-hooks** — run all registered after-triggers for this event
Before-triggers are registered as mutation validators. They run before persistence and can block the mutation. After-triggers are registered as hooks. They run after persistence and cannot undo it.
All validators for a given event run — rejections are accumulated, not short-circuited. If any validator rejects, the mutation is blocked and none of the rejection messages are lost.
After-hooks run in definition order. Each hook's errors are logged but do not propagate — the original mutation is unaffected.
## Before-trigger behavior
Before-triggers use **fail-closed** semantics:
- If the guard condition matches, the mutation is rejected with the `deny` message.
- If the guard condition evaluation itself errors (e.g. a runtime type error), the mutation is also rejected. This prevents bad triggers from silently allowing mutations they were meant to block.
The context provided to before-triggers depends on the event:
| Event | `old` | `new` | `allTasks` |
|---|---|---|---|
| create | nil | proposed task | stored tasks + proposed |
| update | persisted (cloned) | proposed version | stored tasks with proposed applied |
| delete | task being deleted | nil | current stored tasks |
For before-update triggers, `allTasks` reflects the **candidate state** — the proposed update is already applied in the task list. This matters for aggregate predicates like WIP limits using `count(select ...)`, which need to see the world as it would look after the update.
## After-trigger behavior
After-triggers use **fail-open** semantics:
- If the guard condition matches, the action executes.
- If the guard condition evaluation itself errors, the trigger is skipped and the error is logged. The mutation chain continues.
- If the action fails, the error is logged. The original mutation is not rolled back.
After-hooks read a fresh task list from the store each time they fire. This means cascaded triggers see the current state of the world, including changes made by earlier triggers in the chain.
After-triggers support two action forms:
- A CRUD action (`create`, `update`, or `delete`) — executed through the mutation gate, which fires its own triggers
- A `run()` command — executed as a shell command (see [The run() action](#the-run-action))
## Cascade depth
After-triggers can cause further mutations, which fire their own triggers, and so on. To prevent infinite loops, cascade depth is tracked:
- The root mutation (user-initiated) runs at depth 0.
- Each triggered mutation increments the depth by 1.
- At depth >= 8, after-hooks are skipped with a warning log.
- At depth > 8, the mutation gate rejects the mutation entirely.
The maximum cascade depth is **8**. Termination is graceful — a warning is logged, not a panic. Within a cascade, each after-hook reads the latest store state, so it sees changes from earlier triggers.
## 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>`.
- A **30-second timeout** is enforced. If the command exceeds it, the child process is killed.
- Command failure (non-zero exit) is **logged** but does not block the mutation chain.
- Command success is also logged.
The command string is dynamically evaluated from the trigger's expression, which may reference `old.id`, `new.status`, or other fields via string concatenation.
## Configuration discovery details
Trigger definitions are loaded from `workflow.yaml` using the standard [configuration precedence](../config.md#configuration-precedence). The **last file with a `triggers:` key wins**:
| File | `triggers:` key | Effect |
|---|---|---|
| user config | yes, 2 triggers | base: 2 triggers |
| project config | absent | no override, user triggers survive |
| cwd config | `triggers: []` | override: 0 triggers |
A file that exists but has no `triggers:` key expresses no opinion and does not override. An explicit empty list (`triggers: []`) is an active override that disables inherited triggers.
If two candidate paths resolve to the same absolute path (e.g. when the project root is the current directory), the file is read once.
## Time triggers
Time triggers use the `every` keyword to define a periodic CRUD operation:
```
every <duration> <statement>
```
Where `<statement>` is `create`, `update`, or `delete` (not `select` or `run()`). The interval must be a positive duration.
```yaml
triggers:
- description: stale tasks go back to backlog
ruki: >
every 1hour
update where status = "in_progress" and updatedAt < now() - 7day set status="backlog"
- description: delete expired tasks
ruki: >
every 1day
delete where status = "done" and updatedAt < now() - 30day
```
Time triggers differ from event triggers in several ways:
- No timing/event pair (`before`/`after` + `create`/`update`/`delete`) — just `every` + duration
- No `where` guard at the trigger level — filtering belongs inside the CRUD statement
- No `old.`/`new.` qualifiers — there is no "old" or "new" task context for a periodic operation
- No `deny` or `run()` — only mutating CRUD statements
Time triggers are parsed and validated at startup alongside event triggers. A parse error in any trigger definition prevents the app from starting.
**Note:** the time trigger scheduler (executor) is not yet implemented. Time trigger definitions are parsed and stored, but they do not run periodically at this time.
## Startup and error handling
Triggers are loaded during application startup, after the store is initialized but before controllers are created.
- Each trigger definition is parsed with the `ruki` parser. A parse error in any trigger is **fail-fast**: the application will not start, and the error message identifies the failing trigger by its `description` (or by index if no description is set).
- If no `triggers:` section is found in any workflow file, zero triggers are loaded and the app starts normally.
- Successfully loaded triggers are logged with a count at startup.
## Recipes
For a catalog of ready-to-use trigger examples — WIP limits, recurring tasks, auto-escalation, and more — see [Trigger Ideas](../ideas/triggers.md).

View file

@ -0,0 +1,165 @@
# Types And Values
## Table of contents
- [Overview](#overview)
- [Value types](#value-types)
- [Field catalog](#field-catalog)
- [Literals](#literals)
- [The empty literal](#the-empty-literal)
- [Enum normalization](#enum-normalization)
- [List constraints](#list-constraints)
- [Type notes](#type-notes)
## Overview
This page explains the value types used in `ruki`. You do not write types explicitly. `ruki` works them out from the values, expressions, built-in functions, and tiki fields you use.
## Value types
`ruki` uses these value types:
| Type | Meaning |
|---|---|
| `string` | plain string values |
| `int` | integer values such as `priority` and `points` |
| `date` | day-level date values |
| `timestamp` | timestamp values such as `createdAt` and `updatedAt` |
| `duration` | duration literals and date or timestamp differences |
| `bool` | boolean result type (reserved, not currently produced by any expression) |
| `id` | tiki identifier |
| `ref` | tiki reference |
| `recurrence` | recurrence value |
| `list<string>` | list of strings |
| `list<ref>` | list of references |
| `status` | workflow status enum |
| `type` | workflow tiki-type enum |
## Field catalog
The workflow field catalog exposes these fields to `ruki`:
| Field | Type |
|---|---|
| `id` | `id` |
| `title` | `string` |
| `description` | `string` |
| `status` | `status` |
| `type` | `type` |
| `tags` | `list<string>` |
| `dependsOn` | `list<ref>` |
| `due` | `date` |
| `recurrence` | `recurrence` |
| `assignee` | `string` |
| `priority` | `int` |
| `points` | `int` |
| `createdBy` | `string` |
| `createdAt` | `timestamp` |
| `updatedAt` | `timestamp` |
## Literals
Implemented literal forms:
| Form | Example | Inferred type |
|---|---|---|
| string literal | `"Fix login"` | `string` |
| int literal | `2` | `int` |
| date literal | `2026-03-25` | `date` |
| duration literal | `2day` | `duration` |
| list literal | `["bug", "frontend"]` | usually `list<string>` |
| empty literal | `empty` | context-sensitive |
Qualified and unqualified references are not literals, but they participate in type inference:
- `status` resolves through the schema
- `old.status` or `new.status` resolve through the same schema, then pass qualifier checks
## The empty literal
`empty` is a special context-sensitive literal. Its type depends on where you use it.
Implemented behavior:
- `empty` can be assigned to most field types
- `title`, `status`, `type`, and `priority` reject `empty` assignment — these fields are required
- `empty` can be compared against any typed expression
- `is empty` and `is not empty` are allowed for any expression type
Examples:
```sql
create title="x" assignee=empty
create title="x" priority=empty
select where assignee = empty
select where due is empty
```
## Enum normalization
`status` and `type` are special:
- they have dedicated semantic types
- they accept validated string literals
- comparisons against enum fields are stricter than generic string comparisons
`status`
- normalized through the injected schema
- production normalization lowercases, trims, and converts `-` and space to `_`
- recognized values depend on the workflow status registry
`type`
- 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`
- type keys must be canonical (matching normalized form); aliases are not supported
- unknown type values are rejected — no silent fallback
Examples:
```sql
select where status = "done"
select where type = "bug"
create title="x" status="done"
create title="x" type="feature"
```
## List constraints
List rules are intentionally strict:
- list literals must be homogeneous
- non-empty lists infer their type from the first element
- empty list literals default to `list<string>` until context narrows them
- `id` and `ref` elements cause a list literal to be treated as `list<ref>`
Important edge cases:
- `dependsOn=["TIKI-ABC123"]` is valid because a string-literal list can be assigned to `list<ref>`
- `dependsOn=["TIKI-ABC123", title]` is invalid because `list<ref>` assignment only permits literal string elements in that special case
- `tags=[1, 2]` is invalid because `tags` is `list<string>`
Examples:
```sql
create title="x" tags=["bug", "frontend"]
create title="x" dependsOn=["TIKI-ABC123", "TIKI-DEF456"]
create title="x" dependsOn=[]
```
Invalid examples:
```sql
create title="x" tags=[1, 2]
create title="x" dependsOn=["TIKI-ABC123", title]
select where status in ["done", 1]
```
## Type notes
- `string`, `status`, `type`, `id`, and `ref` are treated as string-like in some comparison and concatenation paths, but they are not interchangeable everywhere.
- Membership checks are stricter than general comparison compatibility. For `in` and `not in` with list collections, only exact type matches count, except that `id` and `ref` are treated as compatible with each other. When the right side is a `string` field, `in` performs a substring check — both sides must be `string` type (not `status`, `type`, `id`, or `ref`).
- Enum fields reject non-literal field references in assignments such as `status=title` or `type=title`.
- The exact accepted status values depend on runtime workflow configuration, while the accepted type values depend on the type registry supplied to the parser.

View file

@ -0,0 +1,367 @@
# Validation And Errors
## Table of contents
- [Overview](#overview)
- [Validation layers](#validation-layers)
- [Structural statement and trigger errors](#structural-statement-and-trigger-errors)
- [Field and qualifier errors](#field-and-qualifier-errors)
- [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
This page explains the errors you can get in `ruki`. It covers syntax errors, unknown fields, type mismatches, invalid enum values, unsupported operators, and invalid trigger structure.
## Validation layers
![Validation pipeline](images/validation-pipeline.svg)
`ruki` has two distinct failure stages:
1. Parse-time failures
2. Validation-time failures
Parse-time failures happen when the input does not fit the grammar at all.
Examples:
```sql
drop where id = 1
update set status="done"
delete id = "x"
after update select
```
Validation-time failures happen after parsing, once the AST is checked against schema and semantic rules.
Examples:
```sql
select where foo = "bar"
create title="x" priority="high"
select where status < "done"
before update where new.status = "done"
```
## Structural statement and trigger errors
Statements:
- `create` must have at least one assignment
- `update` must have at least one assignment in `set`
- duplicate assignments to the same field are rejected
Triggers:
- `before` triggers must have `deny`
- `before` triggers must not have action or `run(...)`
- `after` triggers must have an action or `run(...)`
- `after` triggers must not have `deny`
- trigger actions must not be `select`
Examples:
```sql
before update where new.status = "done" update where id = old.id set status="done"
after update where new.status = "done" deny "no"
before update where new.status = "done"
after update where new.status = "done"
```
## Field and qualifier errors
Unknown field errors:
```sql
select where foo = "bar"
create title="x" foo="bar"
```
Immutable field errors:
- `id`, `createdBy`, `createdAt`, and `updatedAt` cannot be assigned in `create` or `update`
```sql
create title="x" id="TIKI-ABC123"
update where status = "done" set createdBy="someone"
```
Qualifier misuse:
- `old.` and `new.` are invalid in standalone statements
- `old.` is invalid in create-trigger contexts
- `new.` is invalid in delete-trigger contexts
- both are invalid inside quantifier bodies
Examples:
```sql
select where old.status = "done"
create title=old.title
after create where old.status = "done" update where id = new.id set status="done"
before delete where new.status = "done" deny "x"
before update where dependsOn any old.status = "done" deny "blocked"
```
## Required field errors
The resulting task from `create` must have a non-empty `title`. If the template does not provide one, a `title=...` assignment is required.
`title`, `status`, `type`, and `priority` reject `empty` assignment:
```sql
create title="" priority=2
create title="x" status=empty
update where id = "TIKI-ABC123" set priority=empty
```
## Type and operator errors
Comparison mismatches:
```sql
select where priority = "high"
select where status = title
select where type = assignee
```
Unsupported operators:
```sql
select where title < "hello"
select where status < "done"
select where recurrence < recurrence
```
Invalid assignment types:
```sql
create title="x" priority="high"
create title="x" assignee=42
create title="x" status=title
update where id="x" set title=status
```
Invalid binary expressions:
```sql
create title="x" priority=1 + "a"
select where due = 2026-03-25 + 2026-03-20
create title="x" dependsOn=dependsOn + status
create title="x" dependsOn=dependsOn + tags
```
## Enum and list errors
Unknown enum values:
```sql
select where status = "nonexistent"
select where type = "nonexistent"
create title="x" status="nonexistent"
create title="x" type="nonexistent"
```
Invalid enum list membership:
```sql
select where status in ["done", "bogus"]
select where type in ["bug", "bogus"]
```
List strictness:
- list literals must be homogeneous
- `list<string>` fields reject non-string elements
- the special `list<ref>` assignment path accepts string-literal lists, but not arbitrary string fields or mixed element expressions
Invalid examples:
```sql
select where status in ["done", 1]
create title="x" tags=[1, 2]
create title="x" dependsOn=["TIKI-ABC123", title]
select where status in dependsOn
select where tags any status = "done"
```
## Order by errors
Unknown field:
```sql
select order by nonexistent
```
Non-orderable types:
```sql
select order by tags
select order by dependsOn
select order by recurrence
```
Duplicate field:
```sql
select order by priority, priority desc
```
Order by inside a subquery:
```sql
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:
```sql
select where foo(1) = 1
```
Argument count errors:
```sql
select where now(1) = now()
select where count() >= 1
select where user(1) = "bob"
```
Argument type errors:
```sql
select where blocks(priority) is empty
create title=call(42)
create title="x" due=next_date(42)
```
Subquery restrictions:
- only `count(...)` accepts a subquery
- bare subqueries elsewhere are rejected
- `count(...)` validates the subquery body recursively
Examples:
```sql
select where count(select where status = "done") >= 1
select where count(select where nosuchfield = "x") >= 1
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

@ -1,10 +0,0 @@
# AI skills
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
If installed you can:
- work with [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
- create, find, modify and delete tikis using AI
- create tikis/dokis directly from Markdown files
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`
- Keep a history of prompts/plans by saving prompts or plans with your repo

79
.doc/doki/doc/themes.md Normal file
View file

@ -0,0 +1,79 @@
# Themes
## Setting a theme
Set the theme in your `config.yaml` under the `appearance` section (see [Configuration](config.md) for file locations and merging rules):
```yaml
appearance:
theme: dracula
```
Available values:
- `auto` — detects your terminal background and picks `dark` or `light` automatically (default)
- `dark`, `light` — built-in base themes
- Any named theme listed below
## Dark themes
### dark
The built-in dark base theme. Used by `auto` when a dark terminal background is detected.
![dark](themes/dark.png)
### dracula
![dracula](themes/dracula.png)
### tokyo-night
![tokyo-night](themes/tokyo-night.png)
### gruvbox-dark
![gruvbox-dark](themes/gruvbox-dark.png)
### catppuccin-mocha
![catppuccin-mocha](themes/catppuccin-mocha.png)
### solarized-dark
![solarized-dark](themes/solarized-dark.png)
### nord
![nord](themes/nord.png)
### monokai
![monokai](themes/monokai.png)
### one-dark
![one-dark](themes/one-dark.png)
## Light themes
### light
The built-in light base theme. Used by `auto` when a light terminal background is detected.
![light](themes/light.png)
### catppuccin-latte
![catppuccin-latte](themes/catppuccin-latte.png)
### solarized-light
![solarized-light](themes/solarized-light.png)
### gruvbox-light
![gruvbox-light](themes/gruvbox-light.png)
### github-light
![github-light](themes/github-light.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

BIN
.doc/doki/doc/themes/dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

BIN
.doc/doki/doc/themes/dracula.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
.doc/doki/doc/themes/github-light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

BIN
.doc/doki/doc/themes/gruvbox-dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

BIN
.doc/doki/doc/themes/gruvbox-light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

BIN
.doc/doki/doc/themes/light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

BIN
.doc/doki/doc/themes/monokai.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

BIN
.doc/doki/doc/themes/nord.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

BIN
.doc/doki/doc/themes/one-dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

BIN
.doc/doki/doc/themes/solarized-dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

BIN
.doc/doki/doc/themes/solarized-light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
.doc/doki/doc/themes/tokyo-night.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

View file

@ -43,7 +43,7 @@ The `.doc/` directory contains two main subdirectories:
Tiki files are saved in `.doc/tiki` directory and can be managed via:
- `tiki` cli
- AI tools such as `claude`, `codex` or `opencode`
- AI tools such as `claude`, `gemini`, `codex` or `opencode`
- manually
A tiki is made of its frontmatter that includes all fields related to a tiki status and types and its description
@ -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

@ -46,8 +46,6 @@ Just configuring multiple plugins. Create a file like `brainstorm.yaml`:
```text
name: Brainstorm
type: doki
foreground: "##ffff99"
background: "#996600"
key: "F6"
url: new-doc-root.md
```

View file

@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go-version: ['1.24.x']
go-version: ['1.25.x']
steps:
- name: Checkout code
@ -43,7 +43,7 @@ jobs:
go test -coverprofile=coverage.out -covermode=atomic $pkgs
- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.24.x'
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.25.x'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -62,13 +62,13 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24.x'
go-version: '1.25.x'
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.8.0
version: v2.11.3
args: --timeout=5m
build:
@ -82,7 +82,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24.x'
go-version: '1.25.x'
cache: true
- name: Build

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,38 +2,24 @@ Follow me on X: [![X Badge](https://img.shields.io/badge/-%23000000.svg?style=fl
# tiki
UPDATE:
**Update:** [v0.5.0 and custom fields](https://github.com/boolean-maybe/tiki/releases/tag/v0.5.0)
Now support images and Mermaid diagrams in Kitty-compatible terminals (iTerm2, Kitty, WezTerm, Ghostty)
![Intro](assets/images.gif)
supported:
- PNG, JPEG, GIF, BMP, WebP, TIFF
- SVG
- Mermaid diagram blocks
see [requirements](.doc/doki/doc/image-requirements.md) for more details
`tiki` is a simple and lightweight way to keep your tasks, prompts, documents, ideas, scratchpads in your project **git** repo
`tiki` is a terminal-first Markdown workspace for tasks, docs, prompts, and notes stored in your **git** repo
![Intro](assets/intro.png)
[Documentation](.doc/doki/doc/index.md)
Markdown is the new go-to format for everything, it's simple, efficient, human and AI native - project management,
documentation, brainstorming ideas, incomplete implementations, AI prompts and plans and what not are saved as Markdown files.
Stick them in your repo. Keep around for as long as you need. Find them back in **git** history. Make issues out of them
and take them through an agile lifecycle. `tiki` helps you save and organize these files:
What `tiki` does:
- Standalone **Markdown viewer** - view and edit Markdown or image files, navigate to local/external/GitHub/GitLab links, image display
- Standalone **Markdown viewer** with images, Mermaid diagrams, and link/TOC navigation
- Keep, search, view and version Markdown files in the **git repo**
- **Wiki-style** documentation with multiple entry points
- Keep a **to-do list** with priorities, status, assignee and size
- Issue management with **Kanban/Scrum** style board and burndown chart
- **Plugin-first** architecture - user-defined plugins with filters and actions like Backlog, Recent, Roadmap
- AI **skills** to enable [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) work with natural language commands like
- SQL-like command language [ruki](.doc/doki/doc/ruki/index.md) to query and update tasks and define custom workflows
- **Plugin-first** architecture - user-defined plugins based on [ruki](.doc/doki/doc/ruki/index.md) and custom views
- AI **skills** to enable [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) work with natural language commands like
"_create a tiki from @my-file.md_"
"_mark tiki ABC123 as complete_"
@ -72,15 +58,22 @@ make build install
### Verify installation
```bash
tiki --version
tiki --help
```
## Quick start
### Markdown viewer
<img src=".doc/doki/doc/markdown-viewer.gif" alt="Markdown viewer demo" width="800">
![Markdown viewer](assets/markdown-viewer.gif)
`tiki my-markdownfile` to view, edit and navigate markdown files in terminal.
if you have no Markdown file to try - use this:
```
tiki https://github.com/boolean-maybe/tiki/blob/main/testdata/go-concurrency.md
```
see [requirements](.doc/doki/doc/image-requirements.md) for supported terminals, SVG and diagrams support
All vim-like pager commands are supported in addition to:
- `Tab/Enter` to select and load a link in the document
- `e` to edit it in your favorite editor
@ -89,14 +82,23 @@ All vim-like pager commands are supported in addition to:
<img src=".doc/doki/doc/kanban.gif" alt="Kanban demo" width="800">
to try with a demo project just run:
```
cd /tmp && tiki demo
```
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
You will be prompted to install skills for
You will be prompted to install skills for
- [Claude Code](https://code.claude.com)
- [Gemini CLI](https://github.com/google-gemini/gemini-cli)
- [Codex](https://openai.com/codex)
- [Opencode](https://opencode.ai)
@ -115,8 +117,6 @@ grep ERROR server.log | sort -u | while read -r line; do echo "$line" | tiki; do
Read more [quick capture docs](.doc/doki/doc/quick-capture.md).
Happy tikking!
## tiki
Keep your tickets in your pockets!
@ -142,14 +142,14 @@ 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
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
If installed you can:
- work with [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
- work with [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
- create, find, modify and delete tikis using AI
- create tikis/dokis directly from Markdown files
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`
@ -170,4 +170,4 @@ to contribute:
![Build Status](https://github.com/boolean-maybe/tiki/actions/workflows/go.yml/badge.svg)
[![Go Report Card](https://goreportcard.com/badge/github.com/boolean-maybe/tiki)](https://goreportcard.com/report/github.com/boolean-maybe/tiki)
[![Go Reference](https://pkg.go.dev/badge/github.com/boolean-maybe/tiki.svg)](https://pkg.go.dev/github.com/boolean-maybe/tiki)
[![Go Reference](https://pkg.go.dev/badge/github.com/boolean-maybe/tiki.svg)](https://pkg.go.dev/github.com/boolean-maybe/tiki)

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 filter expressions:
- `due = '2026-04-01'` - exact match
- `due < '2026-04-01'` - before date
- `due < NOW` - overdue tasks
- `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

BIN
assets/light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

BIN
assets/markdown-viewer.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

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

@ -64,11 +64,11 @@ type BarChart struct {
func DefaultTheme() Theme {
colors := config.GetColors()
return Theme{
AxisColor: colors.BurndownChartAxisColor,
LabelColor: colors.BurndownChartLabelColor,
ValueColor: colors.BurndownChartValueColor,
BarColor: colors.BurndownChartBarColor,
BackgroundColor: config.GetContentBackgroundColor(),
AxisColor: colors.BurndownChartAxisColor.TCell(),
LabelColor: colors.BurndownChartLabelColor.TCell(),
ValueColor: colors.BurndownChartValueColor.TCell(),
BarColor: colors.BurndownChartBarColor.TCell(),
BackgroundColor: config.GetColors().ContentBackgroundColor.TCell(),
BarGradientFrom: colors.BurndownChartGradientFrom.Start,
BarGradientTo: colors.BurndownChartGradientTo.Start,
DotChar: '⣿', // braille full cell for dense dot matrix

View file

@ -44,7 +44,7 @@ func barFillColor(bar Bar, row, total int, theme Theme) tcell.Color {
// Use adaptive gradient: solid color when gradients disabled
if !config.UseGradients {
return config.FallbackBurndownColor
return config.GetColors().FallbackBurndownColor.TCell()
}
t := float64(row) / float64(total-1)

View file

@ -25,14 +25,13 @@ func NewCompletionPrompt(words []string) *CompletionPrompt {
inputField := tview.NewInputField()
// Configure the input field
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
cp := &CompletionPrompt{
InputField: inputField,
words: words,
hintColor: colors.CompletionHintColor,
hintColor: colors.CompletionHintColor.TCell(),
}
return cp

View file

@ -27,8 +27,9 @@ type DateEdit struct {
// NewDateEdit creates a new date input field.
func NewDateEdit() *DateEdit {
inputField := tview.NewInputField()
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
de := &DateEdit{
InputField: inputField,

View file

@ -29,8 +29,9 @@ func NewEditSelectList(values []string, allowTyping bool) *EditSelectList {
inputField := tview.NewInputField()
// Configure the input field
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
esl := &EditSelectList{
InputField: inputField,

View file

@ -8,7 +8,7 @@ import (
)
func TestEditSelectList_ArrowNavigation(t *testing.T) {
values := []string{"ready", "in_progress", "review", "done"}
values := []string{"ready", "inProgress", "review", "done"}
esl := NewEditSelectList(values, true)
tests := []struct {
@ -39,7 +39,7 @@ func TestEditSelectList_ArrowNavigation(t *testing.T) {
name: "down from first goes to second",
initialIndex: 0,
key: tcell.KeyDown,
expectedText: "in_progress",
expectedText: "inProgress",
expectedIndex: 1,
description: "Down arrow should move to next value",
},
@ -92,12 +92,12 @@ func TestEditSelectList_ArrowNavigation(t *testing.T) {
}
func TestEditSelectList_FreeFormTyping(t *testing.T) {
values := []string{"ready", "in_progress", "review", "done"}
values := []string{"ready", "inProgress", "review", "done"}
esl := NewEditSelectList(values, true)
// Start at a specific index
esl.currentIndex = 1
esl.InputField.SetText(values[1]) // "in_progress"
esl.InputField.SetText(values[1]) // "inProgress"
// Simulate typing (any character key resets index to -1)
event := tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone)
@ -110,7 +110,7 @@ func TestEditSelectList_FreeFormTyping(t *testing.T) {
}
func TestEditSelectList_SubmitHandler(t *testing.T) {
values := []string{"ready", "in_progress", "review", "done"}
values := []string{"ready", "inProgress", "review", "done"}
esl := NewEditSelectList(values, true)
var submittedText string
@ -132,7 +132,7 @@ func TestEditSelectList_SubmitHandler(t *testing.T) {
}
func TestEditSelectList_SubmitFromList(t *testing.T) {
values := []string{"ready", "in_progress", "review", "done"}
values := []string{"ready", "inProgress", "review", "done"}
esl := NewEditSelectList(values, true)
var submittedText string
@ -155,7 +155,7 @@ func TestEditSelectList_SubmitFromList(t *testing.T) {
}
func TestEditSelectList_SetInitialValue(t *testing.T) {
values := []string{"ready", "in_progress", "review", "done"}
values := []string{"ready", "inProgress", "review", "done"}
esl := NewEditSelectList(values, true)
// Set to a value that exists in the list
@ -182,7 +182,7 @@ func TestEditSelectList_SetInitialValue(t *testing.T) {
}
func TestEditSelectList_Clear(t *testing.T) {
values := []string{"ready", "in_progress", "review", "done"}
values := []string{"ready", "inProgress", "review", "done"}
esl := NewEditSelectList(values, true)
esl.SetInitialValue("review")
@ -198,7 +198,7 @@ func TestEditSelectList_Clear(t *testing.T) {
}
func TestEditSelectList_SetText(t *testing.T) {
values := []string{"ready", "in_progress", "review", "done"}
values := []string{"ready", "inProgress", "review", "done"}
esl := NewEditSelectList(values, true)
// Start with a list selection
@ -235,7 +235,7 @@ func TestEditSelectList_EmptyValues(t *testing.T) {
}
func TestEditSelectList_NavigationAfterTyping(t *testing.T) {
values := []string{"ready", "in_progress", "review", "done"}
values := []string{"ready", "inProgress", "review", "done"}
esl := NewEditSelectList(values, true)
// Type some text (simulated by SetText which sets index to -1)
@ -267,7 +267,7 @@ func TestEditSelectList_SetLabel(t *testing.T) {
}
func TestEditSelectList_TypingDisabled_IgnoresNonArrowKeys(t *testing.T) {
values := []string{"ready", "in_progress", "done"}
values := []string{"ready", "inProgress", "done"}
esl := NewEditSelectList(values, false) // typing disabled
esl.SetInitialValue("ready")
@ -288,7 +288,7 @@ func TestEditSelectList_TypingDisabled_IgnoresNonArrowKeys(t *testing.T) {
}
func TestEditSelectList_TypingDisabled_ArrowKeysStillWork(t *testing.T) {
values := []string{"ready", "in_progress", "done"}
values := []string{"ready", "inProgress", "done"}
esl := NewEditSelectList(values, false)
handler := esl.InputHandler()
@ -307,7 +307,7 @@ func TestEditSelectList_TypingDisabled_ArrowKeysStillWork(t *testing.T) {
}
func TestEditSelectList_TypingEnabled_AllowsFreeForm(t *testing.T) {
values := []string{"ready", "in_progress", "done"}
values := []string{"ready", "inProgress", "done"}
esl := NewEditSelectList(values, true) // typing enabled
esl.SetInitialValue("ready")
@ -324,7 +324,7 @@ func TestEditSelectList_TypingEnabled_AllowsFreeForm(t *testing.T) {
}
func TestEditSelectList_SubmitCallbackNotFiredWhenTypingDisabled(t *testing.T) {
values := []string{"ready", "in_progress", "done"}
values := []string{"ready", "inProgress", "done"}
esl := NewEditSelectList(values, false)
callCount := 0

View file

@ -37,8 +37,9 @@ func NewIntEditSelect(min, max int, allowTyping bool) *IntEditSelect {
}
inputField := tview.NewInputField()
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
ies := &IntEditSelect{
InputField: inputField,
@ -178,17 +179,17 @@ func (ies *IntEditSelect) InputHandler() func(event *tcell.EventKey, setFocus fu
switch key {
case tcell.KeyUp:
// Decrement value (wraps at min to max)
ies.clearOnType = false // user is navigating, not typing fresh
ies.decrement()
return
case tcell.KeyDown:
// Increment value (wraps at max to min)
ies.clearOnType = false // user is navigating, not typing fresh
ies.increment()
return
case tcell.KeyDown:
// Decrement value (wraps at min to max)
ies.clearOnType = false // user is navigating, not typing fresh
ies.decrement()
return
case tcell.KeyRune:
// If typing is disabled, silently ignore
if !ies.allowTyping {

View file

@ -156,21 +156,21 @@ func TestArrowKeyNavigation(t *testing.T) {
handler := ies.InputHandler()
// Test down arrow (increment)
downEvent := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
handler(downEvent, nil)
if ies.GetValue() != 6 {
t.Errorf("After KeyDown, expected value=6, got %d", ies.GetValue())
}
// Test up arrow (decrement)
// Test up arrow (increment)
upEvent := tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)
handler(upEvent, nil)
handler(upEvent, nil)
if ies.GetValue() != 6 {
t.Errorf("After KeyUp, expected value=6, got %d", ies.GetValue())
}
// Test down arrow (decrement)
downEvent := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
handler(downEvent, nil)
handler(downEvent, nil)
if ies.GetValue() != 4 {
t.Errorf("After 2x KeyUp, expected value=4, got %d", ies.GetValue())
t.Errorf("After 2x KeyDown, expected value=4, got %d", ies.GetValue())
}
}
@ -180,20 +180,20 @@ func TestArrowKeyWrapAround(t *testing.T) {
handler := ies.InputHandler()
// Down at max wraps to min
downEvent := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
handler(downEvent, nil)
if ies.GetValue() != 0 {
t.Errorf("After KeyDown at max, expected value=0, got %d", ies.GetValue())
}
// Up at min wraps to max
// Up at max wraps to min
upEvent := tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)
handler(upEvent, nil)
if ies.GetValue() != 0 {
t.Errorf("After KeyUp at max, expected value=0, got %d", ies.GetValue())
}
// Down at min wraps to max
downEvent := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
handler(downEvent, nil)
if ies.GetValue() != 9 {
t.Errorf("After KeyUp at min, expected value=9, got %d", ies.GetValue())
t.Errorf("After KeyDown at min, expected value=9, got %d", ies.GetValue())
}
}
@ -449,17 +449,17 @@ func TestIntEditSelect_TypingDisabled_ArrowKeysWork(t *testing.T) {
handler := ies.InputHandler()
// Up arrow (decrement)
// Up arrow (increment)
handler(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), nil)
if ies.GetValue() != 4 {
t.Errorf("Expected value 4 after up arrow, got %d", ies.GetValue())
if ies.GetValue() != 6 {
t.Errorf("Expected value 6 after up arrow, got %d", ies.GetValue())
}
// Down arrow (increment)
// Down arrow (decrement)
handler(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), nil)
handler(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), nil)
if ies.GetValue() != 6 {
t.Errorf("Expected value 6 after down arrows, got %d", ies.GetValue())
if ies.GetValue() != 4 {
t.Errorf("Expected value 4 after down arrows, got %d", ies.GetValue())
}
}

View file

@ -31,8 +31,9 @@ type RecurrenceEdit struct {
// NewRecurrenceEdit creates a new recurrence editor.
func NewRecurrenceEdit() *RecurrenceEdit {
inputField := tview.NewInputField()
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
re := &RecurrenceEdit{
InputField: inputField,

View file

@ -23,11 +23,12 @@ type TaskList struct {
selectionIndex int
idColumnWidth int // computed from widest ID
idGradient config.Gradient // gradient for ID text
idFallback tcell.Color // fallback solid color for ID
titleColor string // tview color tag for title, e.g. "[#b8b8b8]"
selectionColor string // tview color tag for selected row highlight
statusDoneColor string // tview color tag for done status indicator
statusPendingColor string // tview color tag for pending status indicator
idFallback config.Color // fallback solid color for ID
titleColor config.Color // color for title text
selectionColor config.Color // foreground color for selected row highlight
selectionBgColor config.Color // background color for selected row highlight
statusDoneColor config.Color // color for done status indicator
statusPendingColor config.Color // color for pending status indicator
}
// NewTaskList creates a new TaskList with the given maximum visible row count.
@ -37,9 +38,10 @@ func NewTaskList(maxVisibleRows int) *TaskList {
Box: tview.NewBox(),
maxVisibleRows: maxVisibleRows,
idGradient: colors.TaskBoxIDColor,
idFallback: config.FallbackTaskIDColor,
idFallback: colors.FallbackTaskIDColor,
titleColor: colors.TaskBoxTitleColor,
selectionColor: colors.TaskListSelectionColor,
selectionColor: colors.TaskListSelectionFg,
selectionBgColor: colors.TaskListSelectionBg,
statusDoneColor: colors.TaskListStatusDoneColor,
statusPendingColor: colors.TaskListStatusPendingColor,
}
@ -92,14 +94,14 @@ func (tl *TaskList) ScrollDown() {
}
// SetIDColors overrides the gradient and fallback color for the ID column.
func (tl *TaskList) SetIDColors(g config.Gradient, fallback tcell.Color) *TaskList {
func (tl *TaskList) SetIDColors(g config.Gradient, fallback config.Color) *TaskList {
tl.idGradient = g
tl.idFallback = fallback
return tl
}
// SetTitleColor overrides the tview color tag for the title column.
func (tl *TaskList) SetTitleColor(color string) *TaskList {
// SetTitleColor overrides the color for the title column.
func (tl *TaskList) SetTitleColor(color config.Color) *TaskList {
tl.titleColor = color
return tl
}
@ -134,9 +136,9 @@ func (tl *TaskList) buildRow(t *task.Task, selected bool, width int) string {
// Status indicator: done = checkmark, else circle
var statusIndicator string
if config.GetStatusRegistry().IsDone(string(t.Status)) {
statusIndicator = tl.statusDoneColor + "\u2713[-]"
statusIndicator = tl.statusDoneColor.Tag().String() + "\u2713[-]"
} else {
statusIndicator = tl.statusPendingColor + "\u25CB[-]"
statusIndicator = tl.statusPendingColor.Tag().String() + "\u25CB[-]"
}
// Gradient-rendered ID, padded to idColumnWidth
@ -151,10 +153,10 @@ func (tl *TaskList) buildRow(t *task.Task, selected bool, width int) string {
titleAvailable := max(width-1-1-tl.idColumnWidth-1, 0)
truncatedTitle := tview.Escape(util.TruncateText(t.Title, titleAvailable))
row := fmt.Sprintf("%s %s %s%s[-]", statusIndicator, idText, tl.titleColor, truncatedTitle)
row := fmt.Sprintf("%s %s %s%s[-]", statusIndicator, idText, tl.titleColor.Tag().String(), truncatedTitle)
if selected {
row = tl.selectionColor + row
row = tl.selectionColor.Tag().WithBg(tl.selectionBgColor).String() + row
}
return row

View file

@ -27,6 +27,7 @@ func TestNewTaskList(t *testing.T) {
if tl == nil {
t.Fatal("NewTaskList returned nil")
return
}
if tl.maxVisibleRows != 5 {
t.Errorf("Expected maxVisibleRows=5, got %d", tl.maxVisibleRows)
@ -39,7 +40,7 @@ func TestNewTaskList(t *testing.T) {
if tl.idGradient != colors.TaskBoxIDColor {
t.Error("Expected ID gradient from config")
}
if tl.idFallback != config.FallbackTaskIDColor {
if tl.idFallback != config.GetColors().FallbackTaskIDColor {
t.Error("Expected ID fallback from config")
}
if tl.titleColor != colors.TaskBoxTitleColor {
@ -180,7 +181,7 @@ func TestFewerItemsThanViewport(t *testing.T) {
func TestSetIDColors(t *testing.T) {
tl := NewTaskList(10)
g := config.Gradient{Start: [3]int{255, 0, 0}, End: [3]int{0, 255, 0}}
fb := tcell.ColorRed
fb := config.NewColor(tcell.ColorRed)
result := tl.SetIDColors(g, fb)
if result != tl {
@ -196,12 +197,13 @@ func TestSetIDColors(t *testing.T) {
func TestSetTitleColor(t *testing.T) {
tl := NewTaskList(10)
result := tl.SetTitleColor("[#ff0000]")
c := config.NewColor(tcell.ColorRed)
result := tl.SetTitleColor(c)
if result != tl {
t.Error("SetTitleColor should return self for chaining")
}
if tl.titleColor != "[#ff0000]" {
t.Errorf("Expected [#ff0000], got %s", tl.titleColor)
if tl.titleColor != c {
t.Errorf("Expected color red, got %v", tl.titleColor)
}
}
@ -269,14 +271,16 @@ func TestBuildRow(t *testing.T) {
t.Run("selected row has selection color prefix", func(t *testing.T) {
row := tl.buildRow(pendingTask, true, width)
if !strings.HasPrefix(row, tl.selectionColor) {
t.Errorf("selected row should start with selection color %q", tl.selectionColor)
selTag := tl.selectionColor.Tag().WithBg(tl.selectionBgColor).String()
if !strings.HasPrefix(row, selTag) {
t.Errorf("selected row should start with selection color %q", selTag)
}
})
t.Run("unselected row has no selection prefix", func(t *testing.T) {
row := tl.buildRow(pendingTask, false, width)
if strings.HasPrefix(row, tl.selectionColor) {
selTag := tl.selectionColor.Tag().WithBg(tl.selectionBgColor).String()
if strings.HasPrefix(row, selTag) {
t.Error("unselected row should not start with selection color")
}
})

View file

@ -14,8 +14,8 @@ import (
type WordList struct {
*tview.Box
words []string
fgColor tcell.Color
bgColor tcell.Color
fgColor config.Color
bgColor config.Color
}
// NewWordList creates a new WordList component.
@ -43,7 +43,7 @@ func (w *WordList) GetWords() []string {
}
// SetColors sets the foreground and background colors.
func (w *WordList) SetColors(fg, bg tcell.Color) *WordList {
func (w *WordList) SetColors(fg, bg config.Color) *WordList {
w.fgColor = fg
w.bgColor = bg
return w
@ -58,8 +58,8 @@ func (w *WordList) Draw(screen tcell.Screen) {
return
}
wordStyle := tcell.StyleDefault.Foreground(w.fgColor).Background(w.bgColor)
spaceStyle := tcell.StyleDefault.Background(config.GetContentBackgroundColor())
wordStyle := tcell.StyleDefault.Foreground(w.fgColor.TCell()).Background(w.bgColor.TCell())
spaceStyle := tcell.StyleDefault.Background(config.GetColors().ContentBackgroundColor.TCell())
currentX := x
currentY := y

View file

@ -14,6 +14,7 @@ func TestNewWordList(t *testing.T) {
if wl == nil {
t.Fatal("NewWordList returned nil")
return
}
if !reflect.DeepEqual(wl.words, words) {
@ -59,8 +60,8 @@ func TestGetWords(t *testing.T) {
func TestSetColors(t *testing.T) {
wl := NewWordList([]string{"test"})
fg := tcell.ColorRed
bg := tcell.ColorGreen
fg := config.NewColor(tcell.ColorRed)
bg := config.NewColor(tcell.ColorGreen)
result := wl.SetColors(fg, bg)

77
config/aitools.go Normal file
View file

@ -0,0 +1,77 @@
package config
import "path"
// AITool defines a supported AI coding assistant.
// To add a new tool, add an entry to the aiTools slice below.
// 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"
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",
SettingsFile: ".claude/settings.local.json",
},
{
Key: "gemini",
DisplayName: "Gemini CLI",
Command: "gemini",
PromptFlag: "-i",
SkillDir: ".gemini/skills",
},
{
Key: "codex",
DisplayName: "OpenAI Codex",
Command: "codex",
PromptFlag: "",
SkillDir: ".codex/skills",
},
{
Key: "opencode",
DisplayName: "OpenCode",
Command: "opencode",
PromptFlag: "--prompt",
SkillDir: ".opencode/skill",
},
}
// PromptArgs returns CLI arguments to pass a prompt string to this tool.
// If PromptFlag is set, returns [flag, prompt]; otherwise returns [prompt].
func (t AITool) PromptArgs(prompt string) []string {
if t.PromptFlag != "" {
return []string{t.PromptFlag, prompt}
}
return []string{prompt}
}
// SkillPath returns the relative file path for a skill (e.g. "tiki" → ".claude/skills/tiki/SKILL.md").
func (t AITool) SkillPath(skill string) string {
return path.Join(t.SkillDir, skill, "SKILL.md")
}
// AITools returns all supported AI tools.
func AITools() []AITool {
return aiTools
}
// LookupAITool finds a tool by its config key. Returns false if not found.
func LookupAITool(key string) (AITool, bool) {
for _, t := range aiTools {
if t.Key == key {
return t, true
}
}
return AITool{}, false
}

84
config/aitools_test.go Normal file
View file

@ -0,0 +1,84 @@
package config
import (
"testing"
)
func TestAITools_ReturnsAllTools(t *testing.T) {
tools := AITools()
if len(tools) != 4 {
t.Fatalf("expected 4 tools, got %d", len(tools))
}
keys := make(map[string]bool)
for _, tool := range tools {
keys[tool.Key] = true
}
for _, expected := range []string{"claude", "gemini", "codex", "opencode"} {
if !keys[expected] {
t.Errorf("missing tool key %q", expected)
}
}
}
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)
}
if tool.DisplayName != "Claude Code" {
t.Errorf("expected display name 'Claude Code', got %q", tool.DisplayName)
}
}
func TestLookupAITool_NotFound(t *testing.T) {
_, ok := LookupAITool("unknown")
if ok {
t.Error("expected false for unknown tool")
}
}
func TestAITool_PromptArgs_WithFlag(t *testing.T) {
tool := AITool{PromptFlag: "--append-system-prompt"}
args := tool.PromptArgs("hello")
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d", len(args))
}
if args[0] != "--append-system-prompt" {
t.Errorf("expected flag '--append-system-prompt', got %q", args[0])
}
if args[1] != "hello" {
t.Errorf("expected prompt 'hello', got %q", args[1])
}
}
func TestAITool_PromptArgs_Positional(t *testing.T) {
tool := AITool{PromptFlag: ""}
args := tool.PromptArgs("hello")
if len(args) != 1 {
t.Fatalf("expected 1 arg, got %d", len(args))
}
if args[0] != "hello" {
t.Errorf("expected prompt 'hello', got %q", args[0])
}
}
func TestAITool_SkillPath(t *testing.T) {
tool := AITool{SkillDir: ".claude/skills"}
path := tool.SkillPath("tiki")
if path != ".claude/skills/tiki/SKILL.md" {
t.Errorf("expected '.claude/skills/tiki/SKILL.md', got %q", path)
}
}
func TestAITool_SkillPath_SingularDir(t *testing.T) {
tool := AITool{SkillDir: ".opencode/skill"}
path := tool.SkillPath("doki")
if path != ".opencode/skill/doki/SKILL.md" {
t.Errorf("expected '.opencode/skill/doki/SKILL.md', got %q", path)
}
}

View file

@ -7,68 +7,29 @@ import (
"strings"
)
//nolint:unused
const artFire = "▓▓▓▓▓▓╗ ▓▓ ▓▓ ▓▓ ▓▓\n╚═▒▒═╝ ▒▒ ▒▒ ▒▒ ▒▒\n ▒▒ ▒▒ ▒▒▒▒ ▒▒\n ░░ ░░ ░░ ░░ ░░\n ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝"
const artDots = "▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒\n▒ ● ● ● ▓ ● ▓ ● ▓ ● ▓ ● ▒\n▒ ▓ ● ▓ ▓ ● ▓ ● ● ▓ ▓ ● ▒\n▒ ▓ ● ▓ ▓ ● ▓ ● ▓ ● ▓ ● ▒\n▒ ▓ ● ▓ ▓ ● ▓ ● ▓ ● ▓ ● ▒\n▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒"
// fireGradient is the color scheme for artFire (yellow → orange → red)
//
//nolint:unused
var fireGradient = []string{"#FFDC00", "#FFAA00", "#FF7800", "#FF5000", "#B42800"}
// dotsGradient is the color scheme for artDots (bright cyan → blue gradient)
// Each character type gets a different color:
// ● (dot) = bright cyan (text)
// ▓ (dark shade) = medium blue (near)
// ▒ (medium shade) = dark blue (far)
var dotsGradient = []string{"#40E0D0", "#4682B4", "#324664"}
// var currentArt = artFire
// var currentGradient = fireGradient
var currentArt = artDots
var currentGradient = dotsGradient
// GetArtTView returns the art logo formatted for tview (with tview color codes)
// uses the current gradient colors
// GetArtTView returns the art logo formatted for tview (with tview color codes).
// Colors are sourced from the palette via ColorConfig.
func GetArtTView() string {
if currentArt == artDots {
// For dots art, color by character type, not by row
return getDotsArtTView()
}
colors := GetColors()
dotColor := colors.LogoDotColor.Hex()
shadeColor := colors.LogoShadeColor.Hex()
borderColor := colors.LogoBorderColor.Hex()
// For other art, color by row
lines := strings.Split(currentArt, "\n")
var result strings.Builder
for i, line := range lines {
// pick color based on line index (cycle if more lines than colors)
colorIdx := i
if colorIdx >= len(currentGradient) {
colorIdx = len(currentGradient) - 1
}
color := currentGradient[colorIdx]
fmt.Fprintf(&result, "[%s]%s[white]\n", color, line)
}
return result.String()
}
// getDotsArtTView colors the dots art by character type
func getDotsArtTView() string {
lines := strings.Split(artDots, "\n")
var result strings.Builder
// dotsGradient: [0]=● (text), [1]=▓ (near), [2]=▒ (far)
for _, line := range lines {
for _, char := range line {
var color string
switch char {
case '●':
color = dotsGradient[0] // bright cyan
color = dotColor
case '▓':
color = dotsGradient[1] // medium blue
color = shadeColor
case '▒':
color = dotsGradient[2] // dark blue
color = borderColor
default:
result.WriteRune(char)
continue
@ -79,8 +40,3 @@ func getDotsArtTView() string {
}
return result.String()
}
// GetFireIcon returns fire icon with tview color codes
func GetFireIcon() string {
return "[#FFDC00] ░ ▒ ░ \n[#FFAA00] ▒▓██▓█▒░ \n[#FF7800] ░▓████▓██▒░ \n[#FF5000] ▒▓██▓▓▒░ \n[#B42800] ▒▓░ \n[white]\n"
}

View file

@ -0,0 +1,48 @@
package config
import "testing"
func TestCaptionColorForIndex_Valid(t *testing.T) {
cc := ColorsFromPalette(DarkPalette())
for i := 0; i < 6; i++ {
pair := cc.CaptionColorForIndex(i)
if pair.Foreground.IsDefault() {
t.Errorf("index %d: foreground is default", i)
}
if pair.Background.IsDefault() {
t.Errorf("index %d: background is default", i)
}
}
}
func TestCaptionColorForIndex_Wraps(t *testing.T) {
cc := ColorsFromPalette(DarkPalette())
first := cc.CaptionColorForIndex(0)
wrapped := cc.CaptionColorForIndex(6)
if first.Foreground.Hex() != wrapped.Foreground.Hex() {
t.Errorf("expected index 6 to wrap to index 0: got fg %s vs %s", wrapped.Foreground.Hex(), first.Foreground.Hex())
}
if first.Background.Hex() != wrapped.Background.Hex() {
t.Errorf("expected index 6 to wrap to index 0: got bg %s vs %s", wrapped.Background.Hex(), first.Background.Hex())
}
}
func TestCaptionColorForIndex_Negative(t *testing.T) {
cc := ColorsFromPalette(DarkPalette())
pair := cc.CaptionColorForIndex(-1)
if !pair.Foreground.IsDefault() {
t.Errorf("expected default foreground for negative index, got %s", pair.Foreground.Hex())
}
if !pair.Background.IsDefault() {
t.Errorf("expected default background for negative index, got %s", pair.Background.Hex())
}
}
func TestAllThemesHaveCaptionColors(t *testing.T) {
for name, info := range themeRegistry {
p := info.Palette()
if len(p.CaptionColors) < 6 {
t.Errorf("theme %q: has %d caption colors, want at least 6", name, len(p.CaptionColors))
}
}
}

128
config/color.go Normal file
View file

@ -0,0 +1,128 @@
package config
// Unified color type that stores a single color and produces tcell, hex, and tview tag forms.
import (
"fmt"
"github.com/gdamore/tcell/v2"
)
// Color is a unified color representation backed by tcell.Color.
// Zero value wraps tcell.ColorDefault (transparent/inherit).
type Color struct {
color tcell.Color
}
// NewColor creates a Color from a tcell.Color value.
func NewColor(c tcell.Color) Color {
return Color{color: c}
}
// NewColorHex creates a Color from a hex string like "#rrggbb" or "rrggbb".
func NewColorHex(hex string) Color {
return Color{color: tcell.GetColor(hex)}
}
// NewColorRGB creates a Color from individual R, G, B components (0-255).
func NewColorRGB(r, g, b int32) Color {
return Color{color: tcell.NewRGBColor(r, g, b)}
}
// DefaultColor returns a Color wrapping tcell.ColorDefault (transparent/inherit).
func DefaultColor() Color {
return Color{color: tcell.ColorDefault}
}
// TCell returns the underlying tcell.Color for use with tview widget APIs.
func (c Color) TCell() tcell.Color {
return c.color
}
// RGB returns the red, green, blue components of the color.
func (c Color) RGB() (int32, int32, int32) {
return c.color.RGB()
}
// Hex returns the color as a "#rrggbb" hex string.
// Returns "-" for ColorDefault (tview's convention for default/transparent).
func (c Color) Hex() string {
if c.color == tcell.ColorDefault {
return "-"
}
r, g, b := c.color.RGB()
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
// tagColor returns the color's name (e.g. "green") if it has one, otherwise its hex string.
// Named colors are important for tview: "[green]" resolves to the terminal's ANSI palette,
// which is often brighter than the literal hex equivalent "[#008000]".
func (c Color) tagColor() string {
if c.color == tcell.ColorDefault {
return "-"
}
if name := c.color.Name(); name != "" {
return name
}
r, g, b := c.color.RGB()
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
// Tag returns a ColorTag builder for constructing tview color tags.
func (c Color) Tag() ColorTag {
return ColorTag{fg: c}
}
// IsDefault returns true if this is the default/transparent color.
func (c Color) IsDefault() bool {
return c.color == tcell.ColorDefault
}
// ColorTag is a composable builder for tview [fg:bg:attr] color tags.
// Use Color.Tag() to create one, then chain Bold() / WithBg() as needed.
type ColorTag struct {
fg Color
bg *Color
bold bool
}
// Bold returns a new ColorTag with the bold attribute set.
func (t ColorTag) Bold() ColorTag {
t.bold = true
return t
}
// WithBg returns a new ColorTag with the given background color.
func (t ColorTag) WithBg(c Color) ColorTag {
t.bg = &c
return t
}
// String renders the tview color tag string.
// Named colors (e.g. "green") are preserved so tview uses the terminal's ANSI palette.
//
// Examples:
//
// Color.Tag().String() → "[green]" or "[#rrggbb]"
// Color.Tag().Bold().String() → "[green::b]" or "[#rrggbb::b]"
// Color.Tag().WithBg(bg).String() → "[green:#rrggbb]"
func (t ColorTag) String() string {
fg := t.fg.tagColor()
hasBg := t.bg != nil
if !hasBg && !t.bold {
return "[" + fg + "]"
}
bg := "-"
if hasBg {
bg = t.bg.tagColor()
}
attr := ""
if t.bold {
attr = "b"
}
return "[" + fg + ":" + bg + ":" + attr + "]"
}

146
config/color_test.go Normal file
View file

@ -0,0 +1,146 @@
package config
import (
"testing"
"github.com/gdamore/tcell/v2"
)
func TestNewColor(t *testing.T) {
c := NewColor(tcell.ColorYellow)
if c.TCell() != tcell.ColorYellow {
t.Errorf("TCell() = %v, want %v", c.TCell(), tcell.ColorYellow)
}
}
func TestNewColorHex(t *testing.T) {
c := NewColorHex("#ff8000")
r, g, b := c.RGB()
if r != 255 || g != 128 || b != 0 {
t.Errorf("RGB() = (%d, %d, %d), want (255, 128, 0)", r, g, b)
}
}
func TestNewColorRGB(t *testing.T) {
c := NewColorRGB(10, 20, 30)
r, g, b := c.RGB()
if r != 10 || g != 20 || b != 30 {
t.Errorf("RGB() = (%d, %d, %d), want (10, 20, 30)", r, g, b)
}
}
func TestDefaultColor(t *testing.T) {
c := DefaultColor()
if !c.IsDefault() {
t.Error("DefaultColor().IsDefault() = false, want true")
}
if c.TCell() != tcell.ColorDefault {
t.Errorf("TCell() = %v, want ColorDefault", c.TCell())
}
}
func TestColor_Hex(t *testing.T) {
tests := []struct {
name string
c Color
want string
}{
{"black", NewColorRGB(0, 0, 0), "#000000"},
{"white", NewColorRGB(255, 255, 255), "#ffffff"},
{"red", NewColorRGB(255, 0, 0), "#ff0000"},
{"default", DefaultColor(), "-"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.c.Hex(); got != tt.want {
t.Errorf("Hex() = %q, want %q", got, tt.want)
}
})
}
}
func TestColor_IsDefault(t *testing.T) {
if NewColor(tcell.ColorWhite).IsDefault() {
t.Error("white.IsDefault() = true, want false")
}
if !NewColor(tcell.ColorDefault).IsDefault() {
t.Error("default.IsDefault() = false, want true")
}
}
func TestColorTag_String(t *testing.T) {
c := NewColorRGB(255, 128, 0)
got := c.Tag().String()
want := "[#ff8000]"
if got != want {
t.Errorf("Tag().String() = %q, want %q", got, want)
}
}
func TestColorTag_Bold(t *testing.T) {
c := NewColorRGB(255, 128, 0)
got := c.Tag().Bold().String()
want := "[#ff8000:-:b]"
if got != want {
t.Errorf("Tag().Bold().String() = %q, want %q", got, want)
}
}
func TestColorTag_WithBg(t *testing.T) {
fg := NewColorRGB(255, 255, 255)
bg := NewColorHex("#3a5f8a")
got := fg.Tag().WithBg(bg).String()
want := "[#ffffff:#3a5f8a:]"
if got != want {
t.Errorf("Tag().WithBg().String() = %q, want %q", got, want)
}
}
func TestColorTag_BoldWithBg(t *testing.T) {
fg := NewColorRGB(255, 128, 0)
bg := NewColorRGB(0, 0, 0)
got := fg.Tag().Bold().WithBg(bg).String()
want := "[#ff8000:#000000:b]"
if got != want {
t.Errorf("Tag().Bold().WithBg().String() = %q, want %q", got, want)
}
}
func TestColorTag_WithBgBold(t *testing.T) {
// order shouldn't matter
fg := NewColorRGB(255, 128, 0)
bg := NewColorRGB(0, 0, 0)
got := fg.Tag().WithBg(bg).Bold().String()
want := "[#ff8000:#000000:b]"
if got != want {
t.Errorf("Tag().WithBg().Bold().String() = %q, want %q", got, want)
}
}
func TestColorTag_DefaultFg(t *testing.T) {
c := DefaultColor()
got := c.Tag().String()
want := "[-]"
if got != want {
t.Errorf("DefaultColor().Tag().String() = %q, want %q", got, want)
}
}
func TestColorTag_DefaultBg(t *testing.T) {
fg := NewColorRGB(255, 0, 0)
bg := DefaultColor()
got := fg.Tag().WithBg(bg).String()
want := "[#ff0000:-:]"
if got != want {
t.Errorf("Tag().WithBg(default).String() = %q, want %q", got, want)
}
}
func TestColorHexRoundTrip(t *testing.T) {
original := "#5e81ac"
c := NewColorHex(original)
got := c.Hex()
if got != original {
t.Errorf("hex round-trip: NewColorHex(%q).Hex() = %q", original, got)
}
}

View file

@ -1,10 +1,6 @@
package config
// Color and style definitions for the UI: gradients, tcell colors, tview color tags.
import (
"github.com/gdamore/tcell/v2"
)
// Color and style definitions for the UI: gradients, unified Color values.
// Gradient defines a start and end RGB color for a gradient transition
type Gradient struct {
@ -12,203 +8,302 @@ type Gradient struct {
End [3]int // R, G, B (0-255)
}
// CaptionColorPair holds the foreground and background colors for a plugin caption row.
type CaptionColorPair struct {
Foreground Color
Background Color
}
// ColorConfig holds all color and style definitions per view
type ColorConfig struct {
// Caption colors
CaptionFallbackGradient Gradient
// Task box colors
TaskBoxSelectedBackground tcell.Color
TaskBoxSelectedText tcell.Color
TaskBoxSelectedBorder tcell.Color
TaskBoxUnselectedBorder tcell.Color
TaskBoxUnselectedBackground tcell.Color
TaskBoxSelectedBorder Color
TaskBoxUnselectedBorder Color
TaskBoxUnselectedBackground Color
TaskBoxIDColor Gradient
TaskBoxTitleColor string // tview color string like "[#b8b8b8]"
TaskBoxLabelColor string // tview color string like "[#767676]"
TaskBoxDescriptionColor string // tview color string like "[#767676]"
TaskBoxTagValueColor string // tview color string like "[#5a6f8f]"
TaskListSelectionColor string // tview color string for selected row highlight, e.g. "[white:#3a5f8a]"
TaskListStatusDoneColor string // tview color string for done status indicator, e.g. "[#00ff7f]"
TaskListStatusPendingColor string // tview color string for pending status indicator, e.g. "[white]"
TaskBoxTitleColor Color
TaskBoxLabelColor Color
TaskBoxDescriptionColor Color
TaskBoxTagValueColor Color
TaskListSelectionFg Color // selected row foreground
TaskListSelectionBg Color // selected row background
TaskListStatusDoneColor Color
TaskListStatusPendingColor Color
// Task detail view colors
TaskDetailIDColor Gradient
TaskDetailTitleText string // tview color string like "[yellow]"
TaskDetailLabelText string // tview color string like "[green]"
TaskDetailValueText string // tview color string like "[white]"
TaskDetailCommentAuthor string // tview color string like "[yellow]"
TaskDetailEditDimTextColor string // tview color string like "[#808080]"
TaskDetailEditDimLabelColor string // tview color string like "[#606060]"
TaskDetailEditDimValueColor string // tview color string like "[#909090]"
TaskDetailEditFocusMarker string // tview color string like "[yellow]"
TaskDetailEditFocusText string // tview color string like "[white]"
TaskDetailTagForeground tcell.Color
TaskDetailTagBackground tcell.Color
TaskDetailTitleText Color
TaskDetailLabelText Color
TaskDetailValueText Color
TaskDetailCommentAuthor Color
TaskDetailEditDimTextColor Color
TaskDetailEditDimLabelColor Color
TaskDetailEditDimValueColor Color
TaskDetailEditFocusMarker Color
TaskDetailEditFocusText Color
TaskDetailTagForeground Color
TaskDetailTagBackground Color
TaskDetailPlaceholderColor Color
// Search box colors
SearchBoxLabelColor tcell.Color
SearchBoxBackgroundColor tcell.Color
SearchBoxTextColor tcell.Color
// Content area colors (base canvas for editable/readable content)
ContentBackgroundColor Color
ContentTextColor Color
// Input box colors
InputBoxLabelColor Color
InputBoxBackgroundColor Color
InputBoxTextColor Color
// Input field colors (used in task detail edit mode)
InputFieldBackgroundColor tcell.Color
InputFieldTextColor tcell.Color
InputFieldBackgroundColor Color
InputFieldTextColor Color
// Completion prompt colors
CompletionHintColor tcell.Color
CompletionHintColor Color
// Burndown chart colors
BurndownChartAxisColor tcell.Color
BurndownChartLabelColor tcell.Color
BurndownChartValueColor tcell.Color
BurndownChartBarColor tcell.Color
BurndownChartAxisColor Color
BurndownChartLabelColor Color
BurndownChartValueColor Color
BurndownChartBarColor Color
BurndownChartGradientFrom Gradient
BurndownChartGradientTo Gradient
BurndownHeaderGradientFrom Gradient // Header-specific chart gradient
BurndownHeaderGradientTo Gradient
// Header view colors
HeaderInfoLabel string // tview color string for view name (bold)
HeaderInfoSeparator string // tview color string for horizontal rule below name
HeaderInfoDesc string // tview color string for view description
HeaderKeyBinding string // tview color string like "[yellow]"
HeaderKeyText string // tview color string like "[white]"
HeaderInfoLabel Color
HeaderInfoSeparator Color
HeaderInfoDesc Color
HeaderKeyBinding Color
HeaderKeyText Color
// Points visual bar colors
PointsFilledColor string // tview color string for filled segments
PointsUnfilledColor string // tview color string for unfilled segments
PointsFilledColor Color
PointsUnfilledColor Color
// Header context help action colors
HeaderActionGlobalKeyColor string // tview color string for global action keys
HeaderActionGlobalLabelColor string // tview color string for global action labels
HeaderActionPluginKeyColor string // tview color string for plugin action keys
HeaderActionPluginLabelColor string // tview color string for plugin action labels
HeaderActionViewKeyColor string // tview color string for view action keys
HeaderActionViewLabelColor string // tview color string for view action labels
HeaderActionGlobalKeyColor Color
HeaderActionGlobalLabelColor Color
HeaderActionPluginKeyColor Color
HeaderActionPluginLabelColor Color
HeaderActionViewKeyColor Color
HeaderActionViewLabelColor Color
// Plugin caption colors (auto-generated per theme)
CaptionColors []CaptionColorPair
// Plugin-specific colors
DepsEditorBackground Color // muted slate for dependency editor caption
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
FallbackTaskIDColor Color // Deep Sky Blue (end of task ID gradient)
FallbackBurndownColor Color // Purple (start of burndown gradient)
// Logo colors (header art)
LogoDotColor Color // bright turquoise (● dots)
LogoShadeColor Color // medium blue (▓ shade)
LogoBorderColor Color // dark blue (▒ border)
// Statusline colors (bottom bar, powerline style)
StatuslineBg string // hex color for stat segment background, e.g. "#3a3a5c"
StatuslineFg string // hex color for stat segment text, e.g. "#cccccc"
StatuslineAccentBg string // hex color for accent segment background (first segment), e.g. "#5f87af"
StatuslineAccentFg string // hex color for accent segment text, e.g. "#1c1c2e"
StatuslineInfoFg string // hex color for info message text
StatuslineInfoBg string // hex color for info message background
StatuslineErrorFg string // hex color for error message text
StatuslineErrorBg string // hex color for error message background
StatuslineFillBg string // hex color for empty statusline area between segments
StatuslineBg Color
StatuslineFg Color
StatuslineAccentBg Color
StatuslineAccentFg Color
StatuslineInfoFg Color
StatuslineInfoBg Color
StatuslineErrorFg Color
StatuslineErrorBg Color
StatuslineFillBg Color
}
// DefaultColors returns the default color configuration
func DefaultColors() *ColorConfig {
// Palette defines the base color values used throughout the UI.
// Each entry is a semantic name for a unique color; ColorConfig fields reference these.
// To change a color everywhere it appears, change it here.
type Palette struct {
HighlightColor Color // yellow — accents, focus markers, key bindings, borders
TextColor Color // white — primary text on dark background
TransparentColor Color // default/transparent — inherit background
MutedColor Color // #686868 — de-emphasized text, placeholders, hints, borders, dim values/labels, descriptions
SoftBorderColor Color // subtle border for unselected task boxes (dark: matches MutedColor, light: recedes)
SoftTextColor Color // #b4b4b4 — secondary readable text (task box titles, action labels)
AccentColor Color // #008000 — label text (green)
ValueColor Color // #8c92ac — field values (cool gray)
InfoLabelColor Color // #ffa500 — orange, header view name
// Selection
SelectionBgColor Color // #3a5f8a — steel blue selection row background
// Action key / accent blue
AccentBlue Color // #5fafff — cyan-blue (action keys, points bar, chart bars)
SlateColor Color // #5f6982 — muted blue-gray (tag values, unfilled bar segments)
// Logo
LogoDotColor Color // #40e0d0 — bright turquoise (● in header art)
LogoShadeColor Color // #4682b4 — steel blue (▓ in header art)
LogoBorderColor Color // #324664 — dark navy (▒ in header art)
// Gradients
CaptionFallbackGradient Gradient // Midnight Blue → Royal Blue
DeepSkyBlue Color // #00bfff — task ID base color + gradient fallback
DeepPurple Color // #865ad6 — fallback for burndown gradient
// Content area
ContentBackgroundColor Color // canvas background (transparent/default — inherits terminal bg)
// Statusline
StatuslineDarkBg Color // darkest statusline background (accent foreground)
StatuslineMidBg Color // mid statusline background (info/error/fill)
StatuslineBorderBg Color // statusline main background + deps editor background
StatuslineText Color // statusline primary text
StatuslineAccent Color // statusline accent background
StatuslineOk Color // statusline info/success foreground
// Plugin caption colors (6 curated fg/bg pairs per theme)
CaptionColors []CaptionColorPair
}
// darkenRGB returns a darkened version of an RGB triple. ratio 0 = no change, 1 = black.
func darkenRGB(rgb [3]int, ratio float64) [3]int {
return [3]int{
int(float64(rgb[0]) * (1 - ratio)),
int(float64(rgb[1]) * (1 - ratio)),
int(float64(rgb[2]) * (1 - ratio)),
}
}
// gradientFromColor derives a gradient from a single Color by darkening for the start.
func gradientFromColor(c Color, darkenRatio float64) Gradient {
r, g, b := c.RGB()
end := [3]int{int(r), int(g), int(b)}
return Gradient{Start: darkenRGB(end, darkenRatio), End: end}
}
// ColorsFromPalette builds a ColorConfig from a Palette.
func ColorsFromPalette(p Palette) *ColorConfig {
idGradient := gradientFromColor(p.DeepSkyBlue, 0.2)
deepPurpleSolid := Gradient{Start: [3]int{134, 90, 214}, End: [3]int{134, 90, 214}}
blueCyanSolid := Gradient{Start: [3]int{90, 170, 255}, End: [3]int{90, 170, 255}}
headerPurpleSolid := Gradient{Start: [3]int{160, 120, 230}, End: [3]int{160, 120, 230}}
headerCyanSolid := Gradient{Start: [3]int{110, 190, 255}, End: [3]int{110, 190, 255}}
return &ColorConfig{
// Caption fallback gradient
CaptionFallbackGradient: Gradient{
Start: [3]int{25, 25, 112}, // Midnight Blue (center)
End: [3]int{65, 105, 225}, // Royal Blue (edges)
},
CaptionFallbackGradient: p.CaptionFallbackGradient,
// Task box
TaskBoxSelectedBackground: tcell.PaletteColor(33), // Blue (ANSI 33)
TaskBoxSelectedText: tcell.PaletteColor(117), // Light Blue (ANSI 117)
TaskBoxSelectedBorder: tcell.ColorYellow,
TaskBoxUnselectedBorder: tcell.ColorGray,
TaskBoxUnselectedBackground: tcell.ColorDefault, // transparent/no background
TaskBoxIDColor: Gradient{
Start: [3]int{30, 144, 255}, // Dodger Blue
End: [3]int{0, 191, 255}, // Deep Sky Blue
},
TaskBoxTitleColor: "[#b8b8b8]", // Light gray
TaskBoxLabelColor: "[#767676]", // Darker gray for labels
TaskBoxDescriptionColor: "[#767676]", // Darker gray for description
TaskBoxTagValueColor: "[#5a6f8f]", // Blueish gray for tag values
TaskListSelectionColor: "[white:#3a5f8a]", // White text on steel blue background
TaskListStatusDoneColor: "[#00ff7f]", // Spring green for done checkmark
TaskListStatusPendingColor: "[white]", // White for pending circle
TaskBoxSelectedBorder: p.HighlightColor,
TaskBoxUnselectedBorder: p.SoftBorderColor,
TaskBoxUnselectedBackground: p.TransparentColor,
TaskBoxIDColor: idGradient,
TaskBoxTitleColor: p.SoftTextColor,
TaskBoxLabelColor: p.MutedColor,
TaskBoxDescriptionColor: p.MutedColor,
TaskBoxTagValueColor: p.SlateColor,
TaskListSelectionFg: p.TextColor,
TaskListSelectionBg: p.SelectionBgColor,
TaskListStatusDoneColor: p.AccentColor,
TaskListStatusPendingColor: p.TextColor,
// Task detail
TaskDetailIDColor: Gradient{
Start: [3]int{30, 144, 255}, // Dodger Blue (same as task box)
End: [3]int{0, 191, 255}, // Deep Sky Blue
},
TaskDetailTitleText: "[yellow]",
TaskDetailLabelText: "[green]",
TaskDetailValueText: "[#8c92ac]",
TaskDetailCommentAuthor: "[yellow]",
TaskDetailEditDimTextColor: "[#808080]", // Medium gray for dim text
TaskDetailEditDimLabelColor: "[#606060]", // Darker gray for dim labels
TaskDetailEditDimValueColor: "[#909090]", // Lighter gray for dim values
TaskDetailEditFocusMarker: "[yellow]", // Yellow arrow for focus
TaskDetailEditFocusText: "[white]", // White text after arrow
TaskDetailTagForeground: tcell.NewRGBColor(180, 200, 220), // Light blue-gray text
TaskDetailTagBackground: tcell.NewRGBColor(30, 50, 120), // Dark blue background (more bluish)
TaskDetailIDColor: idGradient,
TaskDetailTitleText: p.HighlightColor,
TaskDetailLabelText: p.AccentColor,
TaskDetailValueText: p.ValueColor,
TaskDetailCommentAuthor: p.HighlightColor,
TaskDetailEditDimTextColor: p.MutedColor,
TaskDetailEditDimLabelColor: p.MutedColor,
TaskDetailEditDimValueColor: p.SoftTextColor,
TaskDetailEditFocusMarker: p.HighlightColor,
TaskDetailEditFocusText: p.TextColor,
TaskDetailTagForeground: p.SoftTextColor,
TaskDetailTagBackground: p.SelectionBgColor,
TaskDetailPlaceholderColor: p.MutedColor,
// Search box
SearchBoxLabelColor: tcell.ColorWhite,
SearchBoxBackgroundColor: tcell.ColorDefault, // Transparent
SearchBoxTextColor: tcell.ColorWhite,
// Content area
ContentBackgroundColor: p.ContentBackgroundColor,
ContentTextColor: p.TextColor,
// Input field colors
InputFieldBackgroundColor: tcell.ColorDefault, // Transparent
InputFieldTextColor: tcell.ColorWhite,
// Input box
InputBoxLabelColor: p.TextColor,
InputBoxBackgroundColor: p.TransparentColor,
InputBoxTextColor: p.TextColor,
// Input field
InputFieldBackgroundColor: p.TransparentColor,
InputFieldTextColor: p.TextColor,
// Completion prompt
CompletionHintColor: tcell.NewRGBColor(128, 128, 128), // Medium gray for hint text
CompletionHintColor: p.MutedColor,
// Burndown chart
BurndownChartAxisColor: tcell.NewRGBColor(80, 80, 80), // Dark gray
BurndownChartLabelColor: tcell.NewRGBColor(200, 200, 200), // Light gray
BurndownChartValueColor: tcell.NewRGBColor(235, 235, 235), // Very light gray
BurndownChartBarColor: tcell.NewRGBColor(120, 170, 255), // Light blue
BurndownChartGradientFrom: Gradient{
Start: [3]int{134, 90, 214}, // Deep purple
End: [3]int{134, 90, 214}, // Deep purple (solid, not gradient)
},
BurndownChartGradientTo: Gradient{
Start: [3]int{90, 170, 255}, // Blue/cyan
End: [3]int{90, 170, 255}, // Blue/cyan (solid, not gradient)
},
BurndownHeaderGradientFrom: Gradient{
Start: [3]int{160, 120, 230}, // Purple base for header chart
End: [3]int{160, 120, 230}, // Purple base (solid)
},
BurndownHeaderGradientTo: Gradient{
Start: [3]int{110, 190, 255}, // Cyan top for header chart
End: [3]int{110, 190, 255}, // Cyan top (solid)
},
BurndownChartAxisColor: p.MutedColor,
BurndownChartLabelColor: p.MutedColor,
BurndownChartValueColor: p.MutedColor,
BurndownChartBarColor: p.AccentBlue,
BurndownChartGradientFrom: deepPurpleSolid,
BurndownChartGradientTo: blueCyanSolid,
BurndownHeaderGradientFrom: headerPurpleSolid,
BurndownHeaderGradientTo: headerCyanSolid,
// Points visual bar
PointsFilledColor: "[#508cff]", // Blue for filled segments
PointsUnfilledColor: "[#5f6982]", // Gray for unfilled segments
// Points bar
PointsFilledColor: p.AccentBlue,
PointsUnfilledColor: p.SlateColor,
// Header
HeaderInfoLabel: "[orange]",
HeaderInfoSeparator: "[#555555]",
HeaderInfoDesc: "[#888888]",
HeaderKeyBinding: "[yellow]",
HeaderKeyText: "[white]",
HeaderInfoLabel: p.InfoLabelColor,
HeaderInfoSeparator: p.MutedColor,
HeaderInfoDesc: p.MutedColor,
HeaderKeyBinding: p.HighlightColor,
HeaderKeyText: p.TextColor,
// Header context help actions
HeaderActionGlobalKeyColor: "#ffff00", // yellow for global actions
HeaderActionGlobalLabelColor: "#ffffff", // white for global action labels
HeaderActionPluginKeyColor: "#ff8c00", // orange for plugin actions
HeaderActionPluginLabelColor: "#b0b0b0", // light gray for plugin labels
HeaderActionViewKeyColor: "#5fafff", // cyan for view-specific actions
HeaderActionViewLabelColor: "#808080", // gray for view-specific labels
HeaderActionGlobalKeyColor: p.HighlightColor,
HeaderActionGlobalLabelColor: p.TextColor,
HeaderActionPluginKeyColor: p.InfoLabelColor,
HeaderActionPluginLabelColor: p.SoftTextColor,
HeaderActionViewKeyColor: p.AccentBlue,
HeaderActionViewLabelColor: p.MutedColor,
// Statusline (Nord theme)
StatuslineBg: "#434c5e", // Nord polar night 3
StatuslineFg: "#d8dee9", // Nord snow storm 1
StatuslineAccentBg: "#5e81ac", // Nord frost blue
StatuslineAccentFg: "#2e3440", // Nord polar night 1
StatuslineInfoFg: "#a3be8c", // Nord aurora green
StatuslineInfoBg: "#3b4252", // Nord polar night 2
StatuslineErrorFg: "#bf616a", // Nord aurora red
StatuslineErrorBg: "#3b4252", // Nord polar night 2
StatuslineFillBg: "#3b4252", // Nord polar night 2
// Plugin-specific
DepsEditorBackground: p.StatuslineBorderBg,
// Fallback solid colors
FallbackTaskIDColor: p.DeepSkyBlue,
FallbackBurndownColor: p.DeepPurple,
// Logo
LogoDotColor: p.LogoDotColor,
LogoShadeColor: p.LogoShadeColor,
LogoBorderColor: p.LogoBorderColor,
// Statusline
StatuslineBg: p.StatuslineBorderBg,
StatuslineFg: p.StatuslineText,
StatuslineAccentBg: p.StatuslineAccent,
StatuslineAccentFg: p.StatuslineDarkBg,
StatuslineInfoFg: p.StatuslineOk,
StatuslineInfoBg: p.StatuslineMidBg,
StatuslineErrorFg: p.HighlightColor,
StatuslineErrorBg: p.StatuslineMidBg,
StatuslineFillBg: p.StatuslineMidBg,
// Plugin caption colors
CaptionColors: p.CaptionColors,
}
}
// CaptionColorForIndex returns the caption color pair for a plugin at the given config index.
// Wraps modulo slice length. Returns zero-value for negative index or empty slice.
func (cc *ColorConfig) CaptionColorForIndex(index int) CaptionColorPair {
if index < 0 || len(cc.CaptionColors) == 0 {
return CaptionColorPair{}
}
return cc.CaptionColors[index%len(cc.CaptionColors)]
}
// Global color config instance
var globalColors *ColorConfig
var colorsInitialized bool
@ -221,36 +316,10 @@ var UseGradients bool
// Screen-wide gradients show more banding on 256-color terminals, so require truecolor
var UseWideGradients bool
// Plugin-specific background colors for code-only plugins
var (
// DepsEditorBackground: muted slate for the dependency editor caption
DepsEditorBackground = tcell.NewHexColor(0x4e5768)
)
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
var (
// Caption title fallback: Royal Blue (end of gradient)
FallbackTitleColor = tcell.NewRGBColor(65, 105, 225)
// Task ID fallback: Deep Sky Blue (end of gradient)
FallbackTaskIDColor = tcell.NewRGBColor(0, 191, 255)
// Burndown chart fallback: Purple (start of gradient)
FallbackBurndownColor = tcell.NewRGBColor(134, 90, 214)
// Caption row fallback: Midpoint of Midnight Blue to Royal Blue
FallbackCaptionColor = tcell.NewRGBColor(45, 65, 169)
)
// GetColors returns the global color configuration with theme-aware overrides
// GetColors returns the global color configuration for the effective theme
func GetColors() *ColorConfig {
if !colorsInitialized {
globalColors = DefaultColors()
// Apply theme-aware overrides for critical text colors
if GetEffectiveTheme() == "light" {
globalColors.SearchBoxLabelColor = tcell.ColorBlack
globalColors.SearchBoxTextColor = tcell.ColorBlack
globalColors.InputFieldTextColor = tcell.ColorBlack
globalColors.TaskDetailEditFocusText = "[black]"
globalColors.HeaderKeyText = "[black]"
}
globalColors = ColorsFromPalette(PaletteForTheme())
colorsInitialized = true
}
return globalColors

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
@ -7,7 +11,7 @@ statuses:
label: Ready
emoji: "📋"
active: true
- key: in_progress
- key: inProgress
label: "In Progress"
emoji: "⚙️"
active: true
@ -20,87 +24,157 @@ 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
foreground: "#87ceeb"
background: "#25496a"
key: "F1"
lanes:
- name: Ready
filter: status = 'ready' and type != 'epic'
action: status = 'ready'
- name: In Progress
filter: status = 'in_progress' and type != 'epic'
action: status = 'in_progress'
- name: Review
filter: status = 'review' and type != 'epic'
action: status = 'review'
- name: Done
filter: status = 'done' and type != 'epic'
action: status = 'done'
sort: Priority, CreatedAt
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
foreground: "#5fff87"
background: "#0b3d2e"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: status = 'backlog' and type != 'epic'
actions:
- key: "b"
label: "Add to board"
action: status = 'ready'
sort: Priority, ID
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
foreground: "#f4d6a6"
background: "#5a3d1b"
key: Ctrl-R
lanes:
- name: Recent
columns: 4
filter: NOW - UpdatedAt < 24hours
sort: UpdatedAt DESC
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
foreground: "#e2e8f0"
background: "#2a5f5a"
key: "F4"
lanes:
- name: Now
columns: 1
width: 25
filter: type = 'epic' AND status = 'ready'
action: status = 'ready'
- name: Next
columns: 1
width: 25
filter: type = 'epic' AND status = 'backlog' AND priority = 1
action: status = 'backlog', priority = 1
- name: Later
columns: 2
width: 50
filter: type = 'epic' AND status = 'backlog' AND priority > 1
action: status = 'backlog', priority = 2
sort: Priority, Points DESC
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
foreground: "#bcbcbc"
background: "#003399"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
foreground: "#ff9966"
background: "#2b3a42"
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
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"
- 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

@ -48,8 +48,6 @@ Just configuring multiple plugins. Create a file like `brainstorm.yaml`:
```text
name: Brainstorm
type: doki
foreground: "##ffff99"
background: "#996600"
key: "F6"
url: new-doc-root.md
```

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,18 +75,23 @@ 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.`
aiOptions := make([]huh.Option[string], 0, len(AITools()))
for _, t := range AITools() {
aiOptions = append(aiOptions, huh.NewOption(fmt.Sprintf("%s (%s/)", t.DisplayName, t.SkillDir), t.Key))
}
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Initialize project").
Description(description).
Options(
huh.NewOption("Claude Code (.claude/skills/)", "claude"),
huh.NewOption("OpenAI Codex (.codex/skills/)", "codex"),
huh.NewOption("OpenCode (.opencode/skill/)", "opencode"),
).
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).
@ -87,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.
@ -105,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)
}
@ -113,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, ", "))
}
}
@ -144,55 +157,45 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
return fmt.Errorf("embedded doki SKILL.md content is empty")
}
// Define target paths for both tiki and doki skills
type skillPaths struct {
tiki string
doki string
type skillDef struct {
name string
content string
}
skills := []skillDef{
{"tiki", tikiSkillMdContent},
{"doki", dokiSkillMdContent},
}
toolPaths := map[string]skillPaths{
"claude": {
tiki: ".claude/skills/tiki/SKILL.md",
doki: ".claude/skills/doki/SKILL.md",
},
"codex": {
tiki: ".codex/skills/tiki/SKILL.md",
doki: ".codex/skills/doki/SKILL.md",
},
"opencode": {
tiki: ".opencode/skill/tiki/SKILL.md",
doki: ".opencode/skill/doki/SKILL.md",
},
skillNames := make([]string, len(skills))
for i, s := range skills {
skillNames[i] = s.name
}
var errs []error
for _, tool := range selectedTools {
paths, ok := toolPaths[tool]
for _, toolKey := range selectedTools {
tool, ok := LookupAITool(toolKey)
if !ok {
errs = append(errs, fmt.Errorf("unknown tool: %s", tool))
errs = append(errs, fmt.Errorf("unknown tool: %s", toolKey))
continue
}
// Install tiki skill
tikiDir := filepath.Dir(paths.tiki)
//nolint:gosec // G301: 0755 is appropriate for user-owned skill directories
if err := os.MkdirAll(tikiDir, 0755); err != nil {
errs = append(errs, fmt.Errorf("failed to create tiki directory for %s: %w", tool, err))
} else if err := os.WriteFile(paths.tiki, []byte(tikiSkillMdContent), 0644); err != nil {
errs = append(errs, fmt.Errorf("failed to write tiki SKILL.md for %s: %w", tool, err))
} else {
slog.Info("installed tiki AI skill", "tool", tool, "path", paths.tiki)
for _, skill := range skills {
path := tool.SkillPath(skill.name)
dir := filepath.Dir(path)
//nolint:gosec // G301: 0755 is appropriate for user-owned skill directories
if err := os.MkdirAll(dir, 0755); err != nil {
errs = append(errs, fmt.Errorf("failed to create %s directory for %s: %w", skill.name, toolKey, err))
} else if err := os.WriteFile(path, []byte(skill.content), 0644); err != nil {
errs = append(errs, fmt.Errorf("failed to write %s SKILL.md for %s: %w", skill.name, toolKey, err))
} else {
slog.Info("installed AI skill", "tool", toolKey, "skill", skill.name, "path", path)
}
}
// Install doki skill
dokiDir := filepath.Dir(paths.doki)
//nolint:gosec // G301: 0755 is appropriate for user-owned skill directories
if err := os.MkdirAll(dokiDir, 0755); err != nil {
errs = append(errs, fmt.Errorf("failed to create doki directory for %s: %w", tool, err))
} else if err := os.WriteFile(paths.doki, []byte(dokiSkillMdContent), 0644); err != nil {
errs = append(errs, fmt.Errorf("failed to write doki SKILL.md for %s: %w", tool, err))
} else {
slog.Info("installed doki AI skill", "tool", tool, "path", paths.doki)
if tool.SettingsFile != "" {
if err := ensureSkillPermissions(tool.SettingsFile, skillNames); err != nil {
errs = append(errs, fmt.Errorf("failed to update %s settings: %w", toolKey, err))
}
}
}
@ -201,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

@ -1,6 +1,6 @@
package config
// Viper configuration loader: reads config.yaml from the binary's directory
// Viper configuration loader: merges config.yaml from multiple locations
import (
"fmt"
@ -10,14 +10,19 @@ import (
"path/filepath"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/muesli/termenv"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
// lastConfigFile tracks the most recently merged config file path for saveConfig().
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"
@ -41,7 +46,7 @@ type Config struct {
// Appearance configuration
Appearance struct {
Theme string `mapstructure:"theme"` // "dark", "light", "auto"
Theme string `mapstructure:"theme"` // "auto", "dark", "light", or a named theme (see ThemeNames())
GradientThreshold int `mapstructure:"gradientThreshold"` // Minimum color count for gradients (16, 256, 16777216)
CodeBlock struct {
Theme string `mapstructure:"theme"` // chroma syntax theme (e.g. "dracula", "monokai")
@ -49,44 +54,45 @@ type Config struct {
Border string `mapstructure:"border"` // hex "#6272a4" or ANSI "244"
} `mapstructure:"codeBlock"`
} `mapstructure:"appearance"`
// AI agent configuration — valid keys defined in aitools.go via AITools()
AI struct {
Agent string `mapstructure:"agent"`
} `mapstructure:"ai"`
}
var appConfig *Config
// LoadConfig loads configuration from config.yaml
// Priority order (first found wins): project config → user config → current directory (dev)
// If config.yaml doesn't exist, it uses default values
// LoadConfig loads configuration by merging config.yaml from multiple locations.
// Files are merged in precedence order (user → project → cwd); later files override
// earlier ones. Missing values fall back to built-in defaults.
func LoadConfig() (*Config, error) {
// Reset viper to clear any previous configuration
viper.Reset()
// Configure viper to look for config.yaml
// Viper uses first-found priority, so project config takes precedence
viper.SetConfigName("config")
viper.SetConfigType("yaml")
// Add search paths in priority order (first added = highest priority)
projectConfigDir := filepath.Dir(GetProjectConfigFile())
viper.AddConfigPath(projectConfigDir) // Project config (highest priority)
viper.AddConfigPath(GetConfigDir()) // User config
viper.AddConfigPath(".") // Current directory (development)
// Set default values
setDefaults()
lastConfigFile = ""
// Read the config file (if it exists)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
slog.Debug("no config.yaml found, using defaults")
} else {
slog.Error("error reading config file", "error", err)
return nil, err
// merge config files in precedence order (first = base, last = highest priority)
for _, path := range findConfigFiles() {
f, err := os.Open(path)
if err != nil {
slog.Warn("failed to open config file", "path", path, "error", err)
continue
}
} else {
slog.Debug("loaded configuration", "file", viper.ConfigFileUsed())
mergeErr := viper.MergeConfig(f)
_ = f.Close()
if mergeErr != nil {
return nil, fmt.Errorf("merging config from %s: %w", path, mergeErr)
}
lastConfigFile = path
slog.Debug("merged configuration", "file", path)
}
// Allow environment variables to override config file
if lastConfigFile == "" {
slog.Debug("no config.yaml found, using defaults")
}
// environment variables and flags override everything
viper.SetEnvPrefix("TIKI")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
@ -95,17 +101,47 @@ func LoadConfig() (*Config, error) {
slog.Warn("failed to bind command line flags", "error", err)
}
// Unmarshal config into struct
cfg := &Config{}
if err := viper.Unmarshal(cfg); err != nil {
slog.Error("failed to unmarshal config", "error", err)
return nil, err
return nil, fmt.Errorf("unmarshaling config: %w", err)
}
appConfig = cfg
return cfg, nil
}
// findConfigFiles returns existing config.yaml paths in merge order
// (user config → project → cwd). Deduplicates by absolute path.
func findConfigFiles() []string {
pm := mustGetPathManager()
candidates := []string{
pm.ConfigFile(), // user config (base)
filepath.Join(pm.ProjectConfigDir(), "config.yaml"), // project override
filepath.Join(".", "config.yaml"), // cwd override (highest)
}
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
}
// setDefaults sets default configuration values
func setDefaults() {
// Logging defaults
@ -121,7 +157,7 @@ func setDefaults() {
// Appearance defaults
viper.SetDefault("appearance.theme", "auto")
viper.SetDefault("appearance.gradientThreshold", 256)
viper.SetDefault("appearance.codeBlock.theme", "nord")
// code block theme resolved dynamically in GetCodeBlockTheme()
}
// bindFlags binds supported command line flags to viper so they can override config values.
@ -156,19 +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"`
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)
@ -176,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)
@ -214,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
@ -243,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
@ -257,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)
}
}
@ -300,14 +378,13 @@ func GetMaxImageRows() int {
return rows
}
// saveConfig writes the current viper configuration to config.yaml
// saveConfig writes the current viper configuration to config.yaml.
// Saves to the last merged config file, or the user config dir if none was loaded.
func saveConfig() error {
configFile := viper.ConfigFileUsed()
configFile := lastConfigFile
if configFile == "" {
// If no config file was loaded, save to user config directory
configFile = GetConfigFile()
}
return viper.WriteConfigAs(configFile)
}
@ -320,42 +397,28 @@ func GetTheme() string {
return theme
}
// GetEffectiveTheme resolves "auto" to actual theme based on terminal detection
var cachedEffectiveTheme string
// GetEffectiveTheme resolves "auto" to actual theme based on terminal detection.
// Uses termenv OSC 11 query to detect the terminal's actual background color,
// falling back to COLORFGBG env var, then dark.
// Result is cached — safe to call after tview takes over the terminal.
func GetEffectiveTheme() string {
if cachedEffectiveTheme != "" {
return cachedEffectiveTheme
}
theme := GetTheme()
if theme != "auto" {
cachedEffectiveTheme = theme
return theme
}
// Detect via COLORFGBG env var (format: "fg;bg")
if colorfgbg := os.Getenv("COLORFGBG"); colorfgbg != "" {
parts := strings.Split(colorfgbg, ";")
if len(parts) >= 2 {
bg := parts[len(parts)-1]
// 0-7 = dark colors, 8+ = light colors
if bg >= "8" {
return "light"
}
}
output := termenv.NewOutput(os.Stdout)
if output.HasDarkBackground() {
cachedEffectiveTheme = "dark"
} else {
cachedEffectiveTheme = "light"
}
return "dark" // default fallback
}
// GetContentBackgroundColor returns the background color for markdown content areas
// Dark theme needs black background for light text; light theme uses terminal default
func GetContentBackgroundColor() tcell.Color {
if GetEffectiveTheme() == "dark" {
return tcell.ColorBlack
}
return tcell.ColorDefault
}
// GetContentTextColor returns the appropriate text color for content areas
// Dark theme uses white text; light theme uses black text
func GetContentTextColor() tcell.Color {
if GetEffectiveTheme() == "dark" {
return tcell.ColorWhite
}
return tcell.ColorBlack
return cachedEffectiveTheme
}
// GetGradientThreshold returns the minimum color count required for gradients
@ -368,9 +431,13 @@ func GetGradientThreshold() int {
return threshold
}
// GetCodeBlockTheme returns the chroma syntax highlighting theme for code blocks
// GetCodeBlockTheme returns the chroma syntax highlighting theme for code blocks.
// Defaults to the theme registry's chroma mapping when not explicitly configured.
func GetCodeBlockTheme() string {
return viper.GetString("appearance.codeBlock.theme")
if t := viper.GetString("appearance.codeBlock.theme"); t != "" {
return t
}
return ChromaThemeForEffective()
}
// GetCodeBlockBackground returns the background color for code blocks
@ -382,3 +449,8 @@ func GetCodeBlockBackground() string {
func GetCodeBlockBorder() string {
return viper.GetString("appearance.codeBlock.border")
}
// GetAIAgent returns the configured AI agent tool name, or empty string if not configured
func GetAIAgent() string {
return viper.GetString("ai.agent")
}

View file

@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"gopkg.in/yaml.v3"
)
func TestLoadConfig(t *testing.T) {
@ -180,9 +182,13 @@ func TestLoadConfigCodeBlockDefaults(t *testing.T) {
t.Fatalf("LoadConfig failed: %v", err)
}
// codeBlock.theme defaults to "nord"; background and border have no defaults
if cfg.Appearance.CodeBlock.Theme != "nord" {
t.Errorf("expected default codeBlock.theme 'nord', got '%s'", cfg.Appearance.CodeBlock.Theme)
// codeBlock.theme is empty in config (resolved dynamically by GetCodeBlockTheme)
if cfg.Appearance.CodeBlock.Theme != "" {
t.Errorf("expected empty default codeBlock.theme, got '%s'", cfg.Appearance.CodeBlock.Theme)
}
// GetCodeBlockTheme resolves to "nord" for dark (default) theme
if got := GetCodeBlockTheme(); got != "nord" {
t.Errorf("expected GetCodeBlockTheme() 'nord' for dark theme, got '%s'", got)
}
if cfg.Appearance.CodeBlock.Background != "" {
t.Errorf("expected empty default codeBlock.background, got '%s'", cfg.Appearance.CodeBlock.Background)
@ -192,6 +198,355 @@ func TestLoadConfigCodeBlockDefaults(t *testing.T) {
}
}
func TestLoadConfig_ProjectOverridesUser(t *testing.T) {
// set up user config dir with base settings
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
userTikiDir := filepath.Join(userDir, "tiki")
if err := os.MkdirAll(userTikiDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(userTikiDir, "config.yaml"), []byte(`
logging:
level: error
header:
visible: false
`), 0644); err != nil {
t.Fatal(err)
}
// set up project dir with override for logging only
projectDir := t.TempDir()
docDir := filepath.Join(projectDir, ".doc")
if err := os.MkdirAll(docDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(docDir, "config.yaml"), []byte(`
logging:
level: debug
`), 0644); err != nil {
t.Fatal(err)
}
// use a clean cwd with no config
cwdDir := t.TempDir()
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(cwdDir)
appConfig = nil
ResetPathManager()
// override project root to our test project dir
pm := mustGetPathManager()
pm.projectRoot = projectDir
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
// project override wins
if cfg.Logging.Level != "debug" {
t.Errorf("expected logging.level 'debug' from project, got %q", cfg.Logging.Level)
}
// user setting preserved for fields not in project config
if cfg.Header.Visible != false {
t.Errorf("expected header.visible false from user config, got %v", cfg.Header.Visible)
}
}
func TestLoadConfig_CwdOverridesProject(t *testing.T) {
// user config
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
userTikiDir := filepath.Join(userDir, "tiki")
if err := os.MkdirAll(userTikiDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(userTikiDir, "config.yaml"), []byte(`
logging:
level: error
`), 0644); err != nil {
t.Fatal(err)
}
// project config
projectDir := t.TempDir()
docDir := filepath.Join(projectDir, ".doc")
if err := os.MkdirAll(docDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(docDir, "config.yaml"), []byte(`
logging:
level: warn
`), 0644); err != nil {
t.Fatal(err)
}
// cwd config (highest priority)
cwdDir := t.TempDir()
if err := os.WriteFile(filepath.Join(cwdDir, "config.yaml"), []byte(`
logging:
level: debug
`), 0644); err != nil {
t.Fatal(err)
}
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(cwdDir)
appConfig = nil
ResetPathManager()
pm := mustGetPathManager()
pm.projectRoot = projectDir
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
if cfg.Logging.Level != "debug" {
t.Errorf("expected logging.level 'debug' from cwd, got %q", cfg.Logging.Level)
}
}
func TestLoadConfig_UserOnlyFallback(t *testing.T) {
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
userTikiDir := filepath.Join(userDir, "tiki")
if err := os.MkdirAll(userTikiDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(userTikiDir, "config.yaml"), []byte(`
logging:
level: info
header:
visible: false
`), 0644); err != nil {
t.Fatal(err)
}
// cwd with no config, project with no config
cwdDir := t.TempDir()
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(cwdDir)
appConfig = nil
ResetPathManager()
pm := mustGetPathManager()
pm.projectRoot = cwdDir // no .doc/ dir here
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
if cfg.Logging.Level != "info" {
t.Errorf("expected logging.level 'info' from user config, got %q", cfg.Logging.Level)
}
if cfg.Header.Visible != false {
t.Errorf("expected header.visible false from user config, got %v", cfg.Header.Visible)
}
}
func TestLoadConfigAIAgent(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
configContent := `
ai:
agent: claude
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to create test config: %v", err)
}
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(tmpDir)
t.Setenv("XDG_CONFIG_HOME", tmpDir)
appConfig = nil
ResetPathManager()
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
if cfg.AI.Agent != "claude" {
t.Errorf("expected ai.agent 'claude', got '%s'", cfg.AI.Agent)
}
if got := GetAIAgent(); got != "claude" {
t.Errorf("GetAIAgent() = '%s', want 'claude'", got)
}
}
func TestLoadConfigAIAgentDefault(t *testing.T) {
tmpDir := t.TempDir()
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(tmpDir)
appConfig = nil
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
if cfg.AI.Agent != "" {
t.Errorf("expected empty ai.agent by default, got '%s'", cfg.AI.Agent)
}
if got := GetAIAgent(); got != "" {
t.Errorf("GetAIAgent() = '%s', want ''", got)
}
}
func TestSavePluginViewMode_PreservesTriggers(t *testing.T) {
tmpDir := t.TempDir()
// write a workflow.yaml that includes triggers
workflowContent := `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
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: no jumping from backlog to done
ruki: >
before update
where old.status = "backlog" and new.status = "done"
deny "cannot move directly from backlog to done"
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatal(err)
}
// simulate what SavePluginViewMode does: read → modify → write
wf, err := readWorkflowFile(workflowPath)
if err != nil {
t.Fatalf("readWorkflowFile failed: %v", err)
}
// modify a view mode (same as SavePluginViewMode logic)
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)
}
// verify triggers survived the round-trip by reading raw YAML
rawData, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("reading workflow.yaml after write: %v", err)
}
var raw map[string]interface{}
if err := yaml.Unmarshal(rawData, &raw); err != nil {
t.Fatalf("parsing raw YAML: %v", err)
}
triggers, ok := raw["triggers"]
if !ok {
t.Fatal("triggers section missing after round-trip write")
}
triggerList, ok := triggers.([]interface{})
if !ok {
t.Fatalf("triggers is not a list, got %T", triggers)
}
if len(triggerList) != 2 {
t.Fatalf("expected 2 triggers after round-trip, got %d", len(triggerList))
}
// also verify via typed struct
wf2, err := readWorkflowFile(workflowPath)
if err != nil {
t.Fatalf("readWorkflowFile after write failed: %v", err)
}
if len(wf2.Triggers) != 2 {
t.Fatalf("expected 2 triggers in struct after round-trip, got %d", len(wf2.Triggers))
}
desc0, _ := wf2.Triggers[0]["description"].(string)
if desc0 != "block completion with open dependencies" {
t.Errorf("trigger[0] description = %q, want %q", desc0, "block completion with open dependencies")
}
}
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:

708
config/palettes.go Normal file
View file

@ -0,0 +1,708 @@
package config
// Palette constructors for all built-in and named themes.
// Each function returns a Palette with canonical hex values from the theme's specification.
import (
"github.com/gdamore/tcell/v2"
)
// DarkPalette returns the color palette for dark backgrounds.
func DarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#ffff00"),
TextColor: NewColorHex("#ffffff"),
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#686868"),
SoftBorderColor: NewColorHex("#686868"),
SoftTextColor: NewColorHex("#b4b4b4"),
AccentColor: NewColor(tcell.ColorGreen),
ValueColor: NewColorHex("#8c92ac"),
InfoLabelColor: NewColorHex("#ffa500"),
SelectionBgColor: NewColorHex("#3a5f8a"),
AccentBlue: NewColorHex("#5fafff"),
SlateColor: NewColorHex("#5f6982"),
LogoDotColor: NewColorHex("#40e0d0"),
LogoShadeColor: NewColorHex("#4682b4"),
LogoBorderColor: NewColorHex("#324664"),
CaptionFallbackGradient: Gradient{
Start: [3]int{25, 25, 112},
End: [3]int{65, 105, 225},
},
DeepSkyBlue: NewColorRGB(0, 191, 255),
DeepPurple: NewColorRGB(134, 90, 214),
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#2e3440"),
StatuslineMidBg: NewColorHex("#3b4252"),
StatuslineBorderBg: NewColorHex("#434c5e"),
StatuslineText: NewColorHex("#d8dee9"),
StatuslineAccent: NewColorHex("#5e81ac"),
StatuslineOk: NewColorHex("#a3be8c"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#87ceeb"), Background: NewColorHex("#25496a")}, // steel-blue (Kanban signature)
{Foreground: NewColorHex("#8cd98c"), Background: NewColorHex("#003300")}, // green
{Foreground: NewColorHex("#ffd78c"), Background: NewColorHex("#4d3200")}, // orange
{Foreground: NewColorHex("#a9f1ea"), Background: NewColorHex("#13433e")}, // teal
{Foreground: NewColorHex("#b7bcc7"), Background: NewColorHex("#1d2027")}, // blue-gray
{Foreground: NewColorHex("#b0c4d4"), Background: NewColorHex("#1e2d3a")}, // slate blue
},
}
}
// LightPalette returns the color palette for light backgrounds.
func LightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#0055dd"),
TextColor: NewColor(tcell.ColorBlack),
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#808080"),
SoftBorderColor: NewColorHex("#b0b8c8"),
SoftTextColor: NewColorHex("#404040"),
AccentColor: NewColorHex("#006400"),
ValueColor: NewColorHex("#4a4e6a"),
InfoLabelColor: NewColorHex("#b85c00"),
SelectionBgColor: NewColorHex("#b8d4f0"),
AccentBlue: NewColorHex("#0060c0"),
SlateColor: NewColorHex("#7080a0"),
LogoDotColor: NewColorHex("#20a090"),
LogoShadeColor: NewColorHex("#3060a0"),
LogoBorderColor: NewColorHex("#6080a0"),
CaptionFallbackGradient: Gradient{
Start: [3]int{100, 140, 200},
End: [3]int{60, 100, 180},
},
DeepSkyBlue: NewColorRGB(0, 100, 180),
DeepPurple: NewColorRGB(90, 50, 160),
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#eceff4"),
StatuslineMidBg: NewColorHex("#e5e9f0"),
StatuslineBorderBg: NewColorHex("#d8dee9"),
StatuslineText: NewColorHex("#2e3440"),
StatuslineAccent: NewColorHex("#5e81ac"),
StatuslineOk: NewColorHex("#4c7a5a"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#e0f0ff"), Background: NewColorHex("#3a6a90")}, // steel-blue (Kanban signature)
{Foreground: NewColorHex("#d2ded6"), Background: NewColorHex("#467153")}, // green (StatuslineOk)
{Foreground: NewColorHex("#edd6bf"), Background: NewColorHex("#a45200")}, // orange (InfoLabelColor)
{Foreground: NewColorHex("#d2d3da"), Background: NewColorHex("#5a5e80")}, // indigo (ValueColor)
{Foreground: NewColorHex("#c7e7e3"), Background: NewColorHex("#1a8174")}, // teal (LogoDotColor)
{Foreground: NewColorHex("#dfdfdf"), Background: NewColorHex("#616161")}, // gray (MutedColor)
},
}
}
// DraculaPalette returns the Dracula theme palette.
// Ref: https://draculatheme.com/contribute
func DraculaPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#ff79c6"), // pink
TextColor: NewColorHex("#f8f8f2"), // foreground
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#6272a4"), // comment
SoftBorderColor: NewColorHex("#44475a"), // current line
SoftTextColor: NewColorHex("#bfbfbf"),
AccentColor: NewColorHex("#50fa7b"), // green
ValueColor: NewColorHex("#bd93f9"), // purple
InfoLabelColor: NewColorHex("#ffb86c"), // orange
SelectionBgColor: NewColorHex("#44475a"),
AccentBlue: NewColorHex("#8be9fd"), // cyan
SlateColor: NewColorHex("#6272a4"), // comment
LogoDotColor: NewColorHex("#8be9fd"),
LogoShadeColor: NewColorHex("#bd93f9"),
LogoBorderColor: NewColorHex("#44475a"),
CaptionFallbackGradient: Gradient{
Start: [3]int{40, 42, 54},
End: [3]int{68, 71, 90},
},
DeepSkyBlue: NewColorHex("#8be9fd"),
DeepPurple: NewColorHex("#bd93f9"),
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#21222c"),
StatuslineMidBg: NewColorHex("#282a36"),
StatuslineBorderBg: NewColorHex("#44475a"),
StatuslineText: NewColorHex("#f8f8f2"),
StatuslineAccent: NewColorHex("#bd93f9"),
StatuslineOk: NewColorHex("#50fa7b"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#cbf5fe"), Background: NewColorHex("#2a464c")}, // cyan
{Foreground: NewColorHex("#b0fdc4"), Background: NewColorHex("#184b25")}, // green
{Foreground: NewColorHex("#ffdfbd"), Background: NewColorHex("#4d3720")}, // orange
{Foreground: NewColorHex("#f9fdcb"), Background: NewColorHex("#484b2a")}, // yellow
{Foreground: NewColorHex("#b8c0d6"), Background: NewColorHex("#1d2231")}, // comment
{Foreground: NewColorHex("#ffc3e5"), Background: NewColorHex("#4d243b")}, // pink
},
}
}
// TokyoNightPalette returns the Tokyo Night theme palette.
// Ref: https://github.com/folke/tokyonight.nvim
func TokyoNightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#e0af68"), // yellow
TextColor: NewColorHex("#c0caf5"), // foreground
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#565f89"), // comment
SoftBorderColor: NewColorHex("#3b4261"),
SoftTextColor: NewColorHex("#a9b1d6"),
AccentColor: NewColorHex("#9ece6a"), // green
ValueColor: NewColorHex("#7aa2f7"), // blue
InfoLabelColor: NewColorHex("#ff9e64"), // orange
SelectionBgColor: NewColorHex("#283457"),
AccentBlue: NewColorHex("#7aa2f7"),
SlateColor: NewColorHex("#565f89"),
LogoDotColor: NewColorHex("#7dcfff"),
LogoShadeColor: NewColorHex("#7aa2f7"),
LogoBorderColor: NewColorHex("#3b4261"),
CaptionFallbackGradient: Gradient{
Start: [3]int{26, 27, 38},
End: [3]int{59, 66, 97},
},
DeepSkyBlue: NewColorHex("#7dcfff"),
DeepPurple: NewColorHex("#bb9af7"),
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#16161e"),
StatuslineMidBg: NewColorHex("#1a1b26"),
StatuslineBorderBg: NewColorHex("#24283b"),
StatuslineText: NewColorHex("#c0caf5"),
StatuslineAccent: NewColorHex("#7aa2f7"),
StatuslineOk: NewColorHex("#9ece6a"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#c5e9ff"), Background: NewColorHex("#263e4d")}, // sky-blue
{Foreground: NewColorHex("#d3e9bc"), Background: NewColorHex("#2f3e20")}, // green
{Foreground: NewColorHex("#ffd3b9"), Background: NewColorHex("#4d2f1e")}, // orange
{Foreground: NewColorHex("#efdbba"), Background: NewColorHex("#44341f")}, // yellow
{Foreground: NewColorHex("#b3b7ca"), Background: NewColorHex("#1a1d29")}, // comment
{Foreground: NewColorHex("#fbc2cc"), Background: NewColorHex("#4a232a")}, // red
},
}
}
// GruvboxDarkPalette returns the Gruvbox Dark theme palette.
// Ref: https://github.com/morhetz/gruvbox
func GruvboxDarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#fabd2f"), // yellow
TextColor: NewColorHex("#ebdbb2"), // fg
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#928374"), // gray
SoftBorderColor: NewColorHex("#504945"), // bg2
SoftTextColor: NewColorHex("#bdae93"), // fg3
AccentColor: NewColorHex("#b8bb26"), // green
ValueColor: NewColorHex("#83a598"), // blue
InfoLabelColor: NewColorHex("#fe8019"), // orange
SelectionBgColor: NewColorHex("#504945"),
AccentBlue: NewColorHex("#83a598"),
SlateColor: NewColorHex("#665c54"), // bg3
LogoDotColor: NewColorHex("#8ec07c"), // aqua
LogoShadeColor: NewColorHex("#83a598"),
LogoBorderColor: NewColorHex("#3c3836"), // bg1
CaptionFallbackGradient: Gradient{
Start: [3]int{40, 40, 40},
End: [3]int{80, 73, 69},
},
DeepSkyBlue: NewColorHex("#83a598"),
DeepPurple: NewColorHex("#d3869b"), // purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#1d2021"), // bg0_h
StatuslineMidBg: NewColorHex("#282828"), // bg0
StatuslineBorderBg: NewColorHex("#3c3836"), // bg1
StatuslineText: NewColorHex("#ebdbb2"),
StatuslineAccent: NewColorHex("#689d6a"), // dark aqua
StatuslineOk: NewColorHex("#b8bb26"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#c7d7d1"), Background: NewColorHex("#27322e")}, // aqua-blue
{Foreground: NewColorHex("#dfe09d"), Background: NewColorHex("#37380b")}, // green
{Foreground: NewColorHex("#ffc698"), Background: NewColorHex("#4c2608")}, // orange
{Foreground: NewColorHex("#fcdfaa"), Background: NewColorHex("#4b390e")}, // yellow
{Foreground: NewColorHex("#bab6b2"), Background: NewColorHex("#1f1c19")}, // gray
{Foreground: NewColorHex("#fd9a90"), Background: NewColorHex("#4b1610")}, // red
},
}
}
// CatppuccinMochaPalette returns the Catppuccin Mocha theme palette.
// Ref: https://catppuccin.com/palette
func CatppuccinMochaPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#f9e2af"), // yellow
TextColor: NewColorHex("#cdd6f4"), // text
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#6c7086"), // overlay0
SoftBorderColor: NewColorHex("#45475a"), // surface0
SoftTextColor: NewColorHex("#bac2de"), // subtext1
AccentColor: NewColorHex("#a6e3a1"), // green
ValueColor: NewColorHex("#89b4fa"), // blue
InfoLabelColor: NewColorHex("#fab387"), // peach
SelectionBgColor: NewColorHex("#45475a"),
AccentBlue: NewColorHex("#89b4fa"),
SlateColor: NewColorHex("#585b70"), // surface2
LogoDotColor: NewColorHex("#94e2d5"), // teal
LogoShadeColor: NewColorHex("#89b4fa"),
LogoBorderColor: NewColorHex("#313244"), // surface0
CaptionFallbackGradient: Gradient{
Start: [3]int{30, 30, 46},
End: [3]int{69, 71, 90},
},
DeepSkyBlue: NewColorHex("#89dceb"), // sky
DeepPurple: NewColorHex("#cba6f7"), // mauve
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#11111b"), // crust
StatuslineMidBg: NewColorHex("#1e1e2e"), // base
StatuslineBorderBg: NewColorHex("#313244"), // surface0
StatuslineText: NewColorHex("#cdd6f4"),
StatuslineAccent: NewColorHex("#89b4fa"),
StatuslineOk: NewColorHex("#a6e3a1"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#c9dcfd"), Background: NewColorHex("#293648")}, // blue
{Foreground: NewColorHex("#d7f2d5"), Background: NewColorHex("#324430")}, // green
{Foreground: NewColorHex("#fdddc9"), Background: NewColorHex("#4b3629")}, // peach
{Foreground: NewColorHex("#fcf0d9"), Background: NewColorHex("#4b4435")}, // yellow
{Foreground: NewColorHex("#f9eaea"), Background: NewColorHex("#483d3d")}, // flamingo
{Foreground: NewColorHex("#cff2ec"), Background: NewColorHex("#2c4440")}, // teal
},
}
}
// SolarizedDarkPalette returns the Solarized Dark theme palette.
// Ref: https://ethanschoonover.com/solarized/
func SolarizedDarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#b58900"), // yellow
TextColor: NewColorHex("#839496"), // base0
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#586e75"), // base01
SoftBorderColor: NewColorHex("#073642"), // base02
SoftTextColor: NewColorHex("#93a1a1"), // base1
AccentColor: NewColorHex("#859900"), // green
ValueColor: NewColorHex("#268bd2"), // blue
InfoLabelColor: NewColorHex("#cb4b16"), // orange
SelectionBgColor: NewColorHex("#073642"),
AccentBlue: NewColorHex("#268bd2"),
SlateColor: NewColorHex("#586e75"),
LogoDotColor: NewColorHex("#2aa198"), // cyan
LogoShadeColor: NewColorHex("#268bd2"),
LogoBorderColor: NewColorHex("#073642"),
CaptionFallbackGradient: Gradient{
Start: [3]int{0, 43, 54},
End: [3]int{7, 54, 66},
},
DeepSkyBlue: NewColorHex("#268bd2"),
DeepPurple: NewColorHex("#6c71c4"), // violet
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#002b36"), // base03
StatuslineMidBg: NewColorHex("#073642"), // base02
StatuslineBorderBg: NewColorHex("#073642"),
StatuslineText: NewColorHex("#839496"),
StatuslineAccent: NewColorHex("#268bd2"),
StatuslineOk: NewColorHex("#859900"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#9dcbeb"), Background: NewColorHex("#0b2a3f")}, // blue
{Foreground: NewColorHex("#c8d18c"), Background: NewColorHex("#282e00")}, // green
{Foreground: NewColorHex("#e8ae96"), Background: NewColorHex("#3d1707")}, // orange
{Foreground: NewColorHex("#9fd5d1"), Background: NewColorHex("#0d302e")}, // cyan
{Foreground: NewColorHex("#b4bec1"), Background: NewColorHex("#1a2123")}, // base01
{Foreground: NewColorHex("#ec908f"), Background: NewColorHex("#420f0e")}, // red
},
}
}
// NordPalette returns the Nord theme palette.
// Ref: https://www.nordtheme.com/docs/colors-and-palettes
func NordPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#ebcb8b"), // nord13 — yellow
TextColor: NewColorHex("#eceff4"), // nord6 — snow storm
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#4c566a"), // nord3
SoftBorderColor: NewColorHex("#434c5e"), // nord2
SoftTextColor: NewColorHex("#d8dee9"), // nord4
AccentColor: NewColorHex("#a3be8c"), // nord14 — green
ValueColor: NewColorHex("#81a1c1"), // nord9 — blue
InfoLabelColor: NewColorHex("#d08770"), // nord12 — orange
SelectionBgColor: NewColorHex("#434c5e"),
AccentBlue: NewColorHex("#88c0d0"), // nord8 — frost cyan
SlateColor: NewColorHex("#4c566a"),
LogoDotColor: NewColorHex("#8fbcbb"), // nord7 — frost teal
LogoShadeColor: NewColorHex("#81a1c1"),
LogoBorderColor: NewColorHex("#3b4252"), // nord1
CaptionFallbackGradient: Gradient{
Start: [3]int{46, 52, 64},
End: [3]int{59, 66, 82},
},
DeepSkyBlue: NewColorHex("#88c0d0"),
DeepPurple: NewColorHex("#b48ead"), // nord15 — purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#2e3440"), // nord0
StatuslineMidBg: NewColorHex("#3b4252"), // nord1
StatuslineBorderBg: NewColorHex("#434c5e"), // nord2
StatuslineText: NewColorHex("#d8dee9"), // nord4
StatuslineAccent: NewColorHex("#5e81ac"), // nord10
StatuslineOk: NewColorHex("#a3be8c"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#c9e3ea"), Background: NewColorHex("#293a3e")}, // frost-cyan
{Foreground: NewColorHex("#d6e2cb"), Background: NewColorHex("#31392a")}, // green
{Foreground: NewColorHex("#eac9bf"), Background: NewColorHex("#3e2922")}, // orange
{Foreground: NewColorHex("#f4e3c3"), Background: NewColorHex("#473d2a")}, // yellow
{Foreground: NewColorHex("#aeb3bc"), Background: NewColorHex("#171a20")}, // nord3
{Foreground: NewColorHex("#dca8ac"), Background: NewColorHex("#391d20")}, // red
},
}
}
// MonokaiPalette returns the Monokai theme palette.
// Ref: https://monokai.pro/
func MonokaiPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#e6db74"), // yellow
TextColor: NewColorHex("#f8f8f2"), // foreground
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#75715e"), // comment
SoftBorderColor: NewColorHex("#49483e"),
SoftTextColor: NewColorHex("#cfcfc2"),
AccentColor: NewColorHex("#a6e22e"), // green
ValueColor: NewColorHex("#66d9ef"), // cyan
InfoLabelColor: NewColorHex("#fd971f"), // orange
SelectionBgColor: NewColorHex("#49483e"),
AccentBlue: NewColorHex("#66d9ef"),
SlateColor: NewColorHex("#75715e"),
LogoDotColor: NewColorHex("#a6e22e"),
LogoShadeColor: NewColorHex("#66d9ef"),
LogoBorderColor: NewColorHex("#3e3d32"),
CaptionFallbackGradient: Gradient{
Start: [3]int{39, 40, 34},
End: [3]int{73, 72, 62},
},
DeepSkyBlue: NewColorHex("#66d9ef"),
DeepPurple: NewColorHex("#ae81ff"), // purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#1e1f1c"),
StatuslineMidBg: NewColorHex("#272822"), // bg
StatuslineBorderBg: NewColorHex("#3e3d32"),
StatuslineText: NewColorHex("#f8f8f2"),
StatuslineAccent: NewColorHex("#66d9ef"),
StatuslineOk: NewColorHex("#a6e22e"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#baeef8"), Background: NewColorHex("#1f4148")}, // cyan
{Foreground: NewColorHex("#d7f2a1"), Background: NewColorHex("#32440e")}, // green
{Foreground: NewColorHex("#fed09a"), Background: NewColorHex("#4c2d09")}, // orange
{Foreground: NewColorHex("#f2eebc"), Background: NewColorHex("#454223")}, // yellow
{Foreground: NewColorHex("#c1bfb7"), Background: NewColorHex("#23221c")}, // comment
{Foreground: NewColorHex("#e08ea5"), Background: NewColorHex("#4b0c22")}, // pink-red
},
}
}
// OneDarkPalette returns the Atom One Dark theme palette.
// Ref: https://github.com/Binaryify/OneDark-Pro
func OneDarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#e5c07b"), // yellow
TextColor: NewColorHex("#abb2bf"), // foreground
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#5c6370"), // comment
SoftBorderColor: NewColorHex("#3e4452"),
SoftTextColor: NewColorHex("#9da5b4"),
AccentColor: NewColorHex("#98c379"), // green
ValueColor: NewColorHex("#61afef"), // blue
InfoLabelColor: NewColorHex("#d19a66"), // orange
SelectionBgColor: NewColorHex("#3e4452"),
AccentBlue: NewColorHex("#61afef"),
SlateColor: NewColorHex("#5c6370"),
LogoDotColor: NewColorHex("#56b6c2"), // cyan
LogoShadeColor: NewColorHex("#61afef"),
LogoBorderColor: NewColorHex("#3b4048"),
CaptionFallbackGradient: Gradient{
Start: [3]int{40, 44, 52},
End: [3]int{62, 68, 82},
},
DeepSkyBlue: NewColorHex("#61afef"),
DeepPurple: NewColorHex("#c678dd"), // purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#21252b"),
StatuslineMidBg: NewColorHex("#282c34"), // bg
StatuslineBorderBg: NewColorHex("#3b4048"),
StatuslineText: NewColorHex("#abb2bf"),
StatuslineAccent: NewColorHex("#61afef"),
StatuslineOk: NewColorHex("#98c379"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#b8dbf8"), Background: NewColorHex("#1d3548")}, // blue
{Foreground: NewColorHex("#d1e4c3"), Background: NewColorHex("#2e3b24")}, // green
{Foreground: NewColorHex("#ead2ba"), Background: NewColorHex("#3f2e1f")}, // orange
{Foreground: NewColorHex("#f1deba"), Background: NewColorHex("#453a25")}, // yellow
{Foreground: NewColorHex("#b6b9bf"), Background: NewColorHex("#1c1e22")}, // comment
{Foreground: NewColorHex("#eeb2b6"), Background: NewColorHex("#432123")}, // red
},
}
}
// --- Light themes ---
// CatppuccinLattePalette returns the Catppuccin Latte (light) theme palette.
// Ref: https://catppuccin.com/palette
func CatppuccinLattePalette() Palette {
return Palette{
HighlightColor: NewColorHex("#df8e1d"), // yellow
TextColor: NewColorHex("#4c4f69"), // text
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#9ca0b0"), // overlay0
SoftBorderColor: NewColorHex("#ccd0da"), // surface0
SoftTextColor: NewColorHex("#5c5f77"), // subtext1
AccentColor: NewColorHex("#40a02b"), // green
ValueColor: NewColorHex("#1e66f5"), // blue
InfoLabelColor: NewColorHex("#fe640b"), // peach
SelectionBgColor: NewColorHex("#ccd0da"),
AccentBlue: NewColorHex("#1e66f5"),
SlateColor: NewColorHex("#acb0be"), // surface2
LogoDotColor: NewColorHex("#179299"), // teal
LogoShadeColor: NewColorHex("#1e66f5"),
LogoBorderColor: NewColorHex("#bcc0cc"), // surface1
CaptionFallbackGradient: Gradient{
Start: [3]int{239, 241, 245},
End: [3]int{204, 208, 218},
},
DeepSkyBlue: NewColorHex("#04a5e5"), // sky
DeepPurple: NewColorHex("#8839ef"), // mauve
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#eff1f5"), // base
StatuslineMidBg: NewColorHex("#e6e9ef"), // mantle
StatuslineBorderBg: NewColorHex("#dce0e8"), // crust
StatuslineText: NewColorHex("#4c4f69"),
StatuslineAccent: NewColorHex("#1e66f5"),
StatuslineOk: NewColorHex("#40a02b"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#c7d9fd"), Background: NewColorHex("#1e66f5")}, // blue (ValueColor)
{Foreground: NewColorHex("#f7e3c7"), Background: NewColorHex("#8d5a12")}, // yellow (HighlightColor)
{Foreground: NewColorHex("#ffd8c2"), Background: NewColorHex("#b54708")}, // peach (InfoLabelColor)
{Foreground: NewColorHex("#dedfe4"), Background: NewColorHex("#5e606f")}, // overlay0 (MutedColor)
{Foreground: NewColorHex("#c5e4e6"), Background: NewColorHex("#148187")}, // teal (LogoDotColor)
{Foreground: NewColorHex("#c0e9f9"), Background: NewColorHex("#0381b3")}, // sky (DeepSkyBlue)
},
}
}
// SolarizedLightPalette returns the Solarized Light theme palette.
// Ref: https://ethanschoonover.com/solarized/
func SolarizedLightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#b58900"), // yellow (same accent colors as dark)
TextColor: NewColorHex("#657b83"), // base00
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#93a1a1"), // base1
SoftBorderColor: NewColorHex("#eee8d5"), // base2
SoftTextColor: NewColorHex("#586e75"), // base01
AccentColor: NewColorHex("#859900"), // green
ValueColor: NewColorHex("#268bd2"), // blue
InfoLabelColor: NewColorHex("#cb4b16"), // orange
SelectionBgColor: NewColorHex("#eee8d5"),
AccentBlue: NewColorHex("#268bd2"),
SlateColor: NewColorHex("#93a1a1"),
LogoDotColor: NewColorHex("#2aa198"), // cyan
LogoShadeColor: NewColorHex("#268bd2"),
LogoBorderColor: NewColorHex("#eee8d5"),
CaptionFallbackGradient: Gradient{
Start: [3]int{253, 246, 227},
End: [3]int{238, 232, 213},
},
DeepSkyBlue: NewColorHex("#268bd2"),
DeepPurple: NewColorHex("#6c71c4"), // violet
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#fdf6e3"), // base3
StatuslineMidBg: NewColorHex("#eee8d5"), // base2
StatuslineBorderBg: NewColorHex("#eee8d5"),
StatuslineText: NewColorHex("#657b83"),
StatuslineAccent: NewColorHex("#268bd2"),
StatuslineOk: NewColorHex("#859900"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#c9e2f4"), Background: NewColorHex("#2073ae")}, // blue (ValueColor)
{Foreground: NewColorHex("#ede2bf"), Background: NewColorHex("#826300")}, // yellow (HighlightColor)
{Foreground: NewColorHex("#f2d2c5"), Background: NewColorHex("#b74414")}, // orange (InfoLabelColor)
{Foreground: NewColorHex("#d5dbdd"), Background: NewColorHex("#52666d")}, // base01 (SoftTextColor)
{Foreground: NewColorHex("#cae8e5"), Background: NewColorHex("#217d76")}, // cyan (LogoDotColor)
{Foreground: NewColorHex("#e1e6bf"), Background: NewColorHex("#637200")}, // green (AccentColor)
},
}
}
// GruvboxLightPalette returns the Gruvbox Light theme palette.
// Ref: https://github.com/morhetz/gruvbox
func GruvboxLightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#9d6104"), // dark yellow (deepened for light-bg contrast)
TextColor: NewColorHex("#3c3836"), // fg (dark0_hard)
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#928374"), // gray
SoftBorderColor: NewColorHex("#d5c4a1"), // bg2
SoftTextColor: NewColorHex("#504945"), // fg3 (dark2)
AccentColor: NewColorHex("#79740e"), // dark green
ValueColor: NewColorHex("#076678"), // dark blue
InfoLabelColor: NewColorHex("#af3a03"), // dark orange
SelectionBgColor: NewColorHex("#d5c4a1"),
AccentBlue: NewColorHex("#076678"),
SlateColor: NewColorHex("#bdae93"), // bg3
LogoDotColor: NewColorHex("#427b58"), // dark aqua
LogoShadeColor: NewColorHex("#076678"),
LogoBorderColor: NewColorHex("#ebdbb2"), // bg1
CaptionFallbackGradient: Gradient{
Start: [3]int{251, 241, 199},
End: [3]int{235, 219, 178},
},
DeepSkyBlue: NewColorHex("#076678"),
DeepPurple: NewColorHex("#8f3f71"), // dark purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#fbf1c7"), // bg0
StatuslineMidBg: NewColorHex("#ebdbb2"), // bg1
StatuslineBorderBg: NewColorHex("#d5c4a1"), // bg2
StatuslineText: NewColorHex("#3c3836"),
StatuslineAccent: NewColorHex("#427b58"),
StatuslineOk: NewColorHex("#79740e"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#e2d6c3"), Background: NewColorHex("#8b5b0f")}, // amber/ochre
{Foreground: NewColorHex("#dedcc3"), Background: NewColorHex("#6f6a0d")}, // green
{Foreground: NewColorHex("#ebcec0"), Background: NewColorHex("#c44103")}, // orange
{Foreground: NewColorHex("#d0ded5"), Background: NewColorHex("#3f7554")}, // aqua
{Foreground: NewColorHex("#dedbd8"), Background: NewColorHex("#6a5f55")}, // gray
{Foreground: NewColorHex("#e2d7d2"), Background: NewColorHex("#8b5e4b")}, // warm brown
},
}
}
// GithubLightPalette returns the GitHub Light theme palette.
// Ref: https://github.com/primer/github-vscode-theme
func GithubLightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#0550ae"), // blue accent
TextColor: NewColorHex("#1f2328"), // fg.default
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#656d76"), // fg.muted
SoftBorderColor: NewColorHex("#d0d7de"), // border.default
SoftTextColor: NewColorHex("#424a53"),
AccentColor: NewColorHex("#116329"), // green
ValueColor: NewColorHex("#0969da"), // blue
InfoLabelColor: NewColorHex("#953800"), // orange
SelectionBgColor: NewColorHex("#ddf4ff"),
AccentBlue: NewColorHex("#0969da"),
SlateColor: NewColorHex("#8c959f"),
LogoDotColor: NewColorHex("#0969da"),
LogoShadeColor: NewColorHex("#0550ae"),
LogoBorderColor: NewColorHex("#d0d7de"),
CaptionFallbackGradient: Gradient{
Start: [3]int{255, 255, 255},
End: [3]int{246, 248, 250},
},
DeepSkyBlue: NewColorHex("#0969da"),
DeepPurple: NewColorHex("#8250df"), // purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#ffffff"),
StatuslineMidBg: NewColorHex("#f6f8fa"), // canvas.subtle
StatuslineBorderBg: NewColorHex("#eaeef2"),
StatuslineText: NewColorHex("#1f2328"),
StatuslineAccent: NewColorHex("#0969da"),
StatuslineOk: NewColorHex("#116329"),
CaptionColors: []CaptionColorPair{
{Foreground: NewColorHex("#c2daf6"), Background: NewColorHex("#0a72ed")}, // blue
{Foreground: NewColorHex("#c4d8ca"), Background: NewColorHex("#188d3b")}, // green
{Foreground: NewColorHex("#e5cdbf"), Background: NewColorHex("#ba4600")}, // orange
{Foreground: NewColorHex("#e6d9bf"), Background: NewColorHex("#9a6700")}, // amber
{Foreground: NewColorHex("#d9dbdd"), Background: NewColorHex("#5b626a")}, // muted
{Foreground: NewColorHex("#e6d2d2"), Background: NewColorHex("#9b4a4a")}, // muted red
},
}
}

50
config/palettes_test.go Normal file
View file

@ -0,0 +1,50 @@
package config
import "testing"
func TestAllPalettesHaveNonDefaultCriticalFields(t *testing.T) {
for name, info := range themeRegistry {
p := info.Palette()
critical := map[string]Color{
"TextColor": p.TextColor,
"HighlightColor": p.HighlightColor,
"AccentColor": p.AccentColor,
"MutedColor": p.MutedColor,
"AccentBlue": p.AccentBlue,
"InfoLabelColor": p.InfoLabelColor,
}
for field, c := range critical {
if c.IsDefault() {
t.Errorf("theme %q: %s is default/transparent", name, field)
}
}
}
}
func TestLightPalettesHaveDarkText(t *testing.T) {
for name, info := range themeRegistry {
if !info.Light {
continue
}
p := info.Palette()
r, g, b := p.TextColor.RGB()
luminance := 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)
if luminance > 160 {
t.Errorf("light theme %q: TextColor luminance %.0f is too bright (expected dark text)", name, luminance)
}
}
}
func TestDarkPalettesHaveLightText(t *testing.T) {
for name, info := range themeRegistry {
if info.Light {
continue
}
p := info.Palette()
r, g, b := p.TextColor.RGB()
luminance := 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)
if luminance < 128 {
t.Errorf("dark theme %q: TextColor luminance %.0f is too dark (expected light text)", name, luminance)
}
}
}

View file

@ -324,9 +324,15 @@ 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"
// templateFilename is the default name for the task template file
const templateFilename = "new.md"
// FindWorkflowFiles returns all workflow.yaml files that exist and have non-empty views.
// Ordering: user config file first (base), then project config file (overrides), then cwd.
// This lets LoadPlugins load the base and merge overrides on top.
@ -369,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.
@ -397,9 +411,38 @@ func FindWorkflowFile() string {
return files[0]
}
// GetTemplateFile returns the path to the user's custom new.md template
func GetTemplateFile() string {
return mustGetPathManager().TemplateFile()
// FindTemplateFile returns the highest-priority new.md file that exists,
// searching user config → .doc/ (project) → cwd. Returns empty string if
// none found, in which case the caller should fall back to the embedded template.
func FindTemplateFile() string {
pm := mustGetPathManager()
// candidate paths in discovery order: user config (base) → project → cwd (highest)
candidates := []string{
pm.TemplateFile(),
filepath.Join(pm.ProjectConfigDir(), templateFilename),
templateFilename,
}
var best 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
best = path
}
return best
}
// EnsureDirs creates all necessary directories with appropriate permissions

View file

@ -293,7 +293,6 @@ func TestGlobalAccessorFunctions(t *testing.T) {
{"GetTaskDir", GetTaskDir},
{"GetDokiDir", GetDokiDir},
{"GetProjectConfigFile", GetProjectConfigFile},
{"GetTemplateFile", GetTemplateFile},
}
for _, tt := range tests {
@ -344,6 +343,142 @@ func TestInitPaths(t *testing.T) {
}
}
func TestFindTemplateFile_CwdOverridesProject(t *testing.T) {
// user config with new.md
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
userTikiDir := filepath.Join(userDir, "tiki")
if err := os.MkdirAll(userTikiDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), []byte("---\npriority: 1\n---"), 0644); err != nil {
t.Fatal(err)
}
// project .doc/ with new.md
projectDir := t.TempDir()
docDir := filepath.Join(projectDir, ".doc")
if err := os.MkdirAll(docDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(docDir, "new.md"), []byte("---\npriority: 2\n---"), 0644); err != nil {
t.Fatal(err)
}
// cwd with new.md (highest priority)
cwdDir := t.TempDir()
if err := os.WriteFile(filepath.Join(cwdDir, "new.md"), []byte("---\npriority: 3\n---"), 0644); err != nil {
t.Fatal(err)
}
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(cwdDir)
ResetPathManager()
pm := mustGetPathManager()
pm.projectRoot = projectDir
got := FindTemplateFile()
gotAbs, _ := filepath.Abs(got)
wantAbs, _ := filepath.Abs("new.md")
if gotAbs != wantAbs {
t.Errorf("FindTemplateFile() = %q, want cwd file %q", gotAbs, wantAbs)
}
}
func TestFindTemplateFile_ProjectOverridesUser(t *testing.T) {
// user config with new.md
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
userTikiDir := filepath.Join(userDir, "tiki")
if err := os.MkdirAll(userTikiDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), []byte("---\npriority: 1\n---"), 0644); err != nil {
t.Fatal(err)
}
// project .doc/ with new.md
projectDir := t.TempDir()
docDir := filepath.Join(projectDir, ".doc")
if err := os.MkdirAll(docDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(docDir, "new.md"), []byte("---\npriority: 2\n---"), 0644); err != nil {
t.Fatal(err)
}
// cwd with NO new.md
cwdDir := t.TempDir()
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(cwdDir)
ResetPathManager()
pm := mustGetPathManager()
pm.projectRoot = projectDir
got := FindTemplateFile()
want := filepath.Join(docDir, "new.md")
if got != want {
t.Errorf("FindTemplateFile() = %q, want project file %q", got, want)
}
}
func TestFindTemplateFile_UserOnlyFallback(t *testing.T) {
// user config with new.md
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
userTikiDir := filepath.Join(userDir, "tiki")
if err := os.MkdirAll(userTikiDir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), []byte("---\npriority: 1\n---"), 0644); err != nil {
t.Fatal(err)
}
// no project .doc/, no cwd new.md
cwdDir := t.TempDir()
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(cwdDir)
ResetPathManager()
pm := mustGetPathManager()
pm.projectRoot = cwdDir
got := FindTemplateFile()
want := filepath.Join(userTikiDir, "new.md")
if got != want {
t.Errorf("FindTemplateFile() = %q, want user config file %q", got, want)
}
}
func TestFindTemplateFile_NoneFound(t *testing.T) {
// empty user config dir (no new.md)
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
t.Fatal(err)
}
// empty cwd
cwdDir := t.TempDir()
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(cwdDir)
ResetPathManager()
pm := mustGetPathManager()
pm.projectRoot = cwdDir
got := FindTemplateFile()
if got != "" {
t.Errorf("FindTemplateFile() = %q, want empty string when no file exists", got)
}
}
func TestResetPathManager(t *testing.T) {
// Save original XDG
origXDG := os.Getenv("XDG_CONFIG_HOME")

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

@ -4,159 +4,168 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"sync"
"github.com/boolean-maybe/tiki/workflow"
"gopkg.in/yaml.v3"
)
// StatusDef defines a single workflow status loaded from workflow.yaml.
type StatusDef struct {
Key string `yaml:"key"`
Label string `yaml:"label"`
Emoji string `yaml:"emoji"`
Active bool `yaml:"active"`
Default bool `yaml:"default"`
Done bool `yaml:"done"`
}
// StatusDef is a type alias for workflow.StatusDef.
// Kept for backward compatibility during migration.
type StatusDef = workflow.StatusDef
// StatusRegistry is the central, ordered collection of valid statuses.
// It is loaded once from workflow.yaml during bootstrap and accessed globally.
type StatusRegistry struct {
statuses []StatusDef
byKey map[string]StatusDef
defaultKey string
doneKey string
// StatusRegistry is a type alias for workflow.StatusRegistry.
type StatusRegistry = workflow.StatusRegistry
// NormalizeStatusKey delegates to workflow.NormalizeStatusKey.
func NormalizeStatusKey(key string) string {
return string(workflow.NormalizeStatusKey(key))
}
var (
globalRegistry *StatusRegistry
registryMu sync.RWMutex
globalStatusRegistry *workflow.StatusRegistry
globalTypeRegistry *workflow.TypeRegistry
registryMu sync.RWMutex
)
// LoadStatusRegistry reads the statuses: section from workflow.yaml files.
// The first file from FindWorkflowFiles() that contains a non-empty statuses list wins.
// 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")
}
statusReg, statusPath, err := loadStatusRegistryFromFiles(files)
if err != nil {
return err
}
if statusReg == nil {
return fmt.Errorf("no statuses defined in workflow.yaml; add a statuses: section")
}
typeReg, typePath, err := loadTypeRegistryFromFiles(files)
if err != nil {
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
}
// loadStatusRegistryFromFiles iterates workflow files and returns the registry
// from the last file that contains a non-empty statuses section.
// Returns a parse error immediately if any file is malformed.
func loadStatusRegistryFromFiles(files []string) (*workflow.StatusRegistry, string, error) {
var lastReg *workflow.StatusRegistry
var lastFile string
for _, path := range files {
reg, err := loadStatusesFromFile(path)
if err != nil {
return fmt.Errorf("loading statuses from %s: %w", path, err)
return nil, "", fmt.Errorf("loading statuses from %s: %w", path, err)
}
if reg != nil {
registryMu.Lock()
globalRegistry = reg
registryMu.Unlock()
slog.Debug("loaded status registry", "file", path, "count", len(reg.statuses))
return nil
lastReg = reg
lastFile = path
}
}
return fmt.Errorf("no statuses defined in workflow.yaml; add a statuses: section")
return lastReg, lastFile, nil
}
// GetStatusRegistry returns the global StatusRegistry.
// Panics if LoadStatusRegistry() was never called — this is a programming error,
// not a user-facing path.
func GetStatusRegistry() *StatusRegistry {
func GetStatusRegistry() *workflow.StatusRegistry {
registryMu.RLock()
defer registryMu.RUnlock()
if globalRegistry == nil {
if globalStatusRegistry == nil {
panic("config: GetStatusRegistry called before LoadStatusRegistry")
}
return globalRegistry
return globalStatusRegistry
}
// GetTypeRegistry returns the global TypeRegistry.
// Panics if LoadStatusRegistry() was never called.
func GetTypeRegistry() *workflow.TypeRegistry {
registryMu.RLock()
defer registryMu.RUnlock()
if globalTypeRegistry == nil {
panic("config: GetTypeRegistry called before LoadStatusRegistry")
}
return globalTypeRegistry
}
// MaybeGetTypeRegistry returns the global TypeRegistry if it has been
// initialized, or (nil, false) when LoadStatusRegistry() has not run yet.
func MaybeGetTypeRegistry() (*workflow.TypeRegistry, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
return globalTypeRegistry, globalTypeRegistry != nil
}
// ResetStatusRegistry replaces the global registry with one built from the given defs.
// Intended for tests only.
func ResetStatusRegistry(defs []StatusDef) {
reg, err := buildRegistry(defs)
// 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 {
panic(fmt.Sprintf("ResetStatusRegistry: %v", err))
}
typeReg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
if err != nil {
panic(fmt.Sprintf("ResetStatusRegistry: type registry: %v", err))
}
registryMu.Lock()
globalRegistry = reg
globalStatusRegistry = reg
globalTypeRegistry = typeReg
registryMu.Unlock()
workflow.ClearCustomFields()
registriesLoaded.Store(true)
}
// 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 registry. Intended for test teardown.
// ClearStatusRegistry removes the global registries and clears custom fields.
// Intended for test teardown.
func ClearStatusRegistry() {
registryMu.Lock()
globalRegistry = nil
globalStatusRegistry = nil
globalTypeRegistry = nil
registryMu.Unlock()
workflow.ClearCustomFields()
registriesLoaded.Store(false)
}
// --- Registry methods ---
// All returns the ordered list of status definitions.
func (r *StatusRegistry) All() []StatusDef {
return r.statuses
}
// Lookup returns the StatusDef for a given key (normalized) and whether it exists.
func (r *StatusRegistry) Lookup(key string) (StatusDef, bool) {
def, ok := r.byKey[NormalizeStatusKey(key)]
return def, ok
}
// IsValid reports whether key is a recognized status.
func (r *StatusRegistry) IsValid(key string) bool {
_, ok := r.byKey[NormalizeStatusKey(key)]
return ok
}
// IsActive reports whether the status has the active flag set.
func (r *StatusRegistry) IsActive(key string) bool {
def, ok := r.byKey[NormalizeStatusKey(key)]
return ok && def.Active
}
// IsDone reports whether the status has the done flag set.
func (r *StatusRegistry) IsDone(key string) bool {
def, ok := r.byKey[NormalizeStatusKey(key)]
return ok && def.Done
}
// DefaultKey returns the key of the status with default: true.
func (r *StatusRegistry) DefaultKey() string {
return r.defaultKey
}
// DoneKey returns the key of the status with done: true.
func (r *StatusRegistry) DoneKey() string {
return r.doneKey
}
// Keys returns all status keys in definition order.
func (r *StatusRegistry) Keys() []string {
keys := make([]string, len(r.statuses))
for i, s := range r.statuses {
keys[i] = s.Key
}
return keys
}
// NormalizeStatusKey lowercases, trims, and normalizes separators in a status key.
func NormalizeStatusKey(key string) string {
normalized := strings.ToLower(strings.TrimSpace(key))
normalized = strings.ReplaceAll(normalized, "-", "_")
normalized = strings.ReplaceAll(normalized, " ", "_")
return normalized
}
// --- internal ---
// --- internal: statuses ---
// workflowStatusData is the YAML shape we unmarshal to extract just the statuses key.
type workflowStatusData struct {
Statuses []StatusDef `yaml:"statuses"`
Statuses []workflow.StatusDef `yaml:"statuses"`
}
func loadStatusesFromFile(path string) (*StatusRegistry, error) {
func loadStatusesFromFile(path string) (*workflow.StatusRegistry, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
@ -171,55 +180,101 @@ func loadStatusesFromFile(path string) (*StatusRegistry, error) {
return nil, nil // no statuses in this file, try next
}
return buildRegistry(ws.Statuses)
return workflow.NewStatusRegistry(ws.Statuses)
}
func buildRegistry(defs []StatusDef) (*StatusRegistry, error) {
if len(defs) == 0 {
return nil, fmt.Errorf("statuses list is empty")
// --- 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)
}
reg := &StatusRegistry{
statuses: make([]StatusDef, 0, len(defs)),
byKey: make(map[string]StatusDef, len(defs)),
// 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)
}
for i, def := range defs {
if def.Key == "" {
return nil, fmt.Errorf("status at index %d has empty key", i)
}
rawTypes, exists := raw["types"]
if !exists {
return nil, false, nil // absent — no opinion
}
normalized := NormalizeStatusKey(def.Key)
def.Key = normalized
// 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")
}
if _, exists := reg.byKey[normalized]; exists {
return nil, fmt.Errorf("duplicate status key %q", normalized)
// 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)
}
if def.Default {
if reg.defaultKey != "" {
slog.Warn("multiple statuses marked default; using first", "first", reg.defaultKey, "duplicate", normalized)
} else {
reg.defaultKey = normalized
}
}
if def.Done {
if reg.doneKey != "" {
slog.Warn("multiple statuses marked done; using first", "first", reg.doneKey, "duplicate", normalized)
} else {
reg.doneKey = normalized
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)
}
}
reg.byKey[normalized] = def
reg.statuses = append(reg.statuses, def)
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)
}
// If no explicit default, use the first status
if reg.defaultKey == "" {
reg.defaultKey = reg.statuses[0].Key
slog.Warn("no status marked default; using first status", "key", reg.defaultKey)
reg, err := workflow.NewTypeRegistry(defs)
if err != nil {
return nil, true, err
}
return reg, nil
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

@ -1,20 +1,24 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/boolean-maybe/tiki/workflow"
)
func defaultTestStatuses() []StatusDef {
return []StatusDef{
func defaultTestStatuses() []workflow.StatusDef {
return []workflow.StatusDef{
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
{Key: "inProgress", Label: "In Progress", Emoji: "⚙️", Active: true},
{Key: "review", Label: "Review", Emoji: "👀", Active: true},
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
}
}
func setupTestRegistry(t *testing.T, defs []StatusDef) {
func setupTestRegistry(t *testing.T, defs []workflow.StatusDef) {
t.Helper()
ResetStatusRegistry(defs)
t.Cleanup(func() { ClearStatusRegistry() })
@ -38,7 +42,7 @@ func TestBuildRegistry_DefaultStatuses(t *testing.T) {
}
func TestBuildRegistry_CustomStatuses(t *testing.T) {
custom := []StatusDef{
custom := []workflow.StatusDef{
{Key: "new", Label: "New", Emoji: "🆕", Default: true},
{Key: "wip", Label: "Work In Progress", Emoji: "🔧", Active: true},
{Key: "closed", Label: "Closed", Emoji: "🔒", Done: true},
@ -67,7 +71,7 @@ func TestRegistry_IsValid(t *testing.T) {
}{
{"backlog", true},
{"ready", true},
{"in_progress", true},
{"inProgress", true},
{"In-Progress", true}, // normalization
{"review", true},
{"done", true},
@ -95,7 +99,7 @@ func TestRegistry_IsActive(t *testing.T) {
}{
{"backlog", false},
{"ready", true},
{"in_progress", true},
{"inProgress", true},
{"review", true},
{"done", false},
}
@ -116,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)
@ -135,7 +140,7 @@ func TestRegistry_Keys(t *testing.T) {
reg := GetStatusRegistry()
keys := reg.Keys()
expected := []string{"backlog", "ready", "in_progress", "review", "done"}
expected := []workflow.StatusKey{"backlog", "ready", "inProgress", "review", "done"}
if len(keys) != len(expected) {
t.Fatalf("expected %d keys, got %d", len(expected), len(keys))
@ -147,61 +152,144 @@ func TestRegistry_Keys(t *testing.T) {
}
}
func TestRegistry_NormalizesKeys(t *testing.T) {
custom := []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("in_progress") {
t.Error("expected 'in_progress' to be valid after normalization")
if !reg.IsValid("inProgress") {
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")
}
}
func TestBuildRegistry_EmptyKey(t *testing.T) {
defs := []StatusDef{
defs := []workflow.StatusDef{
{Key: "", Label: "No Key"},
}
_, err := buildRegistry(defs)
_, err := workflow.NewStatusRegistry(defs)
if err == nil {
t.Error("expected error for empty key")
}
}
func TestBuildRegistry_DuplicateKey(t *testing.T) {
defs := []StatusDef{
defs := []workflow.StatusDef{
{Key: "ready", Label: "Ready", Default: true},
{Key: "ready", Label: "Ready 2"},
}
_, err := buildRegistry(defs)
_, err := workflow.NewStatusRegistry(defs)
if err == nil {
t.Error("expected error for duplicate key")
}
}
func TestBuildRegistry_Empty(t *testing.T) {
_, err := buildRegistry(nil)
_, err := workflow.NewStatusRegistry(nil)
if err == nil {
t.Error("expected error for empty statuses")
}
}
func TestBuildRegistry_DefaultFallsToFirst(t *testing.T) {
defs := []StatusDef{
{Key: "alpha", Label: "Alpha"},
func TestBuildRegistry_RequiresExplicitDefault(t *testing.T) {
defs := []workflow.StatusDef{
{Key: "alpha", Label: "Alpha", Done: true},
{Key: "beta", Label: "Beta"},
}
reg, err := buildRegistry(defs)
_, 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")
}
}
@ -212,10 +300,10 @@ func TestNormalizeStatusKey(t *testing.T) {
}{
{"backlog", "backlog"},
{"BACKLOG", "backlog"},
{"In-Progress", "in_progress"},
{"in progress", "in_progress"},
{"In-Progress", "inProgress"},
{"in progress", "inProgress"},
{" DONE ", "done"},
{"In_Review", "in_review"},
{"In_Review", "inReview"},
}
for _, tt := range tests {
@ -227,6 +315,218 @@ func TestNormalizeStatusKey(t *testing.T) {
}
}
// writeTempWorkflow creates a temp workflow.yaml with the given content and returns its path.
func writeTempWorkflow(t *testing.T, dir, content string) string {
t.Helper()
path := filepath.Join(dir, "workflow.yaml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("writing workflow file: %v", err)
}
return path
}
// setupLoadRegistryTest creates temp dirs and configures the path manager so
// LoadStatusRegistry can discover workflow.yaml files via FindWorkflowFiles.
func setupLoadRegistryTest(t *testing.T) (cwdDir string) {
t.Helper()
ClearStatusRegistry()
t.Cleanup(func() { ResetStatusRegistry(defaultTestStatuses()) })
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 TestLoadStatusRegistry_HappyPath(t *testing.T) {
cwdDir := setupLoadRegistryTest(t)
content := `
statuses:
- key: open
label: Open
emoji: "🔓"
default: true
- key: closed
label: Closed
emoji: "🔒"
done: true
views:
- name: board
lanes:
- name: Open
filter: "status = 'open'"
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := LoadStatusRegistry(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg := GetStatusRegistry()
if reg.DefaultKey() != "open" {
t.Errorf("expected default key 'open', got %q", reg.DefaultKey())
}
if reg.DoneKey() != "closed" {
t.Errorf("expected done key 'closed', got %q", reg.DoneKey())
}
// type registry should also be initialized
typeReg := GetTypeRegistry()
if !typeReg.IsValid("story") {
t.Error("expected type 'story' to be valid after LoadStatusRegistry")
}
}
func TestLoadStatusRegistry_NoWorkflowFiles(t *testing.T) {
_ = setupLoadRegistryTest(t)
// no workflow.yaml files anywhere
err := LoadStatusRegistry()
if err == nil {
t.Fatal("expected error when no workflow files found")
}
}
func TestLoadStatusRegistry_NoStatusesDefined(t *testing.T) {
cwdDir := setupLoadRegistryTest(t)
// workflow.yaml exists with views but no statuses
content := `
views:
- name: board
lanes:
- name: All
filter: "status = 'ready'"
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
err := LoadStatusRegistry()
if err == nil {
t.Fatal("expected error when no statuses defined")
}
}
func TestLoadStatusRegistryFromFiles_LastFileWins(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
f1 := writeTempWorkflow(t, dir1, `
statuses:
- key: alpha
label: Alpha
default: true
- key: beta
label: Beta
done: true
`)
f2 := writeTempWorkflow(t, dir2, `
statuses:
- key: gamma
label: Gamma
default: true
- key: delta
label: Delta
done: true
`)
reg, path, err := loadStatusRegistryFromFiles([]string{f1, f2})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg == nil {
t.Fatal("expected non-nil registry")
}
if path != f2 {
t.Errorf("expected path %q, got %q", f2, path)
}
if reg.DefaultKey() != "gamma" {
t.Errorf("expected default key 'gamma' from last file, got %q", reg.DefaultKey())
}
if len(reg.All()) != 2 {
t.Errorf("expected 2 statuses from last file, got %d", len(reg.All()))
}
}
func TestLoadStatusRegistryFromFiles_SkipsFileWithoutStatuses(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
f1 := writeTempWorkflow(t, dir1, `
statuses:
- key: alpha
label: Alpha
default: true
- key: beta
label: Beta
done: true
`)
// second file has views but no statuses
f2 := writeTempWorkflow(t, dir2, `
views:
- name: backlog
`)
reg, path, err := loadStatusRegistryFromFiles([]string{f1, f2})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg == nil {
t.Fatal("expected non-nil registry")
}
if path != f1 {
t.Errorf("expected path %q (first file with statuses), got %q", f1, path)
}
if reg.DefaultKey() != "alpha" {
t.Errorf("expected default key 'alpha', got %q", reg.DefaultKey())
}
}
func TestLoadStatusRegistryFromFiles_ParseErrorStopsEarly(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
f1 := writeTempWorkflow(t, dir1, `
statuses:
- key: alpha
label: Alpha
default: true
- key: beta
label: Beta
done: true
`)
f2 := writeTempWorkflow(t, dir2, `
statuses: [[[invalid yaml
`)
_, _, err := loadStatusRegistryFromFiles([]string{f1, f2})
if err == nil {
t.Fatal("expected error for malformed YAML in second file")
}
}
func TestRegistry_IsDone(t *testing.T) {
setupTestRegistry(t, defaultTestStatuses())
reg := GetStatusRegistry()
@ -238,3 +538,403 @@ func TestRegistry_IsDone(t *testing.T) {
t.Error("expected 'backlog' to not be marked as done")
}
}
func TestGetTypeRegistry(t *testing.T) {
setupTestRegistry(t, defaultTestStatuses())
reg := GetTypeRegistry()
if !reg.IsValid("story") {
t.Error("expected 'story' to be valid")
}
if !reg.IsValid("bug") {
t.Error("expected 'bug' to be valid")
}
}
func TestMaybeGetTypeRegistry_Initialized(t *testing.T) {
setupTestRegistry(t, defaultTestStatuses())
reg, ok := MaybeGetTypeRegistry()
if !ok {
t.Fatal("expected MaybeGetTypeRegistry to return true after init")
}
if reg == nil {
t.Fatal("expected non-nil registry")
}
if !reg.IsValid("story") {
t.Error("expected 'story' to be valid")
}
}
func TestMaybeGetTypeRegistry_Uninitialized(t *testing.T) {
ClearStatusRegistry()
t.Cleanup(func() {
ResetStatusRegistry(defaultTestStatuses())
})
reg, ok := MaybeGetTypeRegistry()
if ok {
t.Error("expected MaybeGetTypeRegistry to return false when uninitialized")
}
if reg != nil {
t.Error("expected nil registry when uninitialized")
}
}
func TestGetStatusRegistry_PanicsWhenUninitialized(t *testing.T) {
ClearStatusRegistry()
t.Cleanup(func() {
ResetStatusRegistry(defaultTestStatuses())
})
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic from GetStatusRegistry when uninitialized")
}
}()
GetStatusRegistry()
}
func TestGetTypeRegistry_PanicsWhenUninitialized(t *testing.T) {
ClearStatusRegistry()
t.Cleanup(func() {
ResetStatusRegistry(defaultTestStatuses())
})
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic from GetTypeRegistry when uninitialized")
}
}()
GetTypeRegistry()
}
func TestLoadStatusRegistryFromFiles_NoStatuses(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `
views:
- name: backlog
`)
reg, path, err := loadStatusRegistryFromFiles([]string{f})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg != nil {
t.Error("expected nil registry when no statuses defined")
}
if path != "" {
t.Errorf("expected empty path, got %q", path)
}
}
func TestLoadStatusRegistryFromFiles_ReadError(t *testing.T) {
_, _, err := loadStatusRegistryFromFiles([]string{"/nonexistent/path/workflow.yaml"})
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}
func TestLoadStatusesFromFile_InvalidYAML(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "workflow.yaml")
if err := os.WriteFile(f, []byte("{{{{invalid yaml"), 0644); err != nil {
t.Fatal(err)
}
_, err := loadStatusesFromFile(f)
if err == nil {
t.Fatal("expected error for invalid YAML")
}
}
func TestLoadStatusesFromFile_EmptyStatuses(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "workflow.yaml")
if err := os.WriteFile(f, []byte("statuses: []\n"), 0644); err != nil {
t.Fatal(err)
}
reg, err := loadStatusesFromFile(f)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg != nil {
t.Error("expected nil registry for empty statuses list")
}
}
func TestLoadStatusRegistry_MalformedFile(t *testing.T) {
cwdDir := setupLoadRegistryTest(t)
// write a workflow.yaml with invalid YAML so loadStatusRegistryFromFiles returns an error
content := `statuses: [[[not valid yaml`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
err := LoadStatusRegistry()
if err == nil {
t.Fatal("expected error for malformed workflow.yaml")
}
}
func TestLoadStatusRegistryFromFiles_AllFilesEmpty(t *testing.T) {
dir := t.TempDir()
f1 := filepath.Join(dir, "workflow1.yaml")
f2 := filepath.Join(dir, "workflow2.yaml")
if err := os.WriteFile(f1, []byte("other_key: true\n"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
if err := os.WriteFile(f2, []byte("statuses: []\n"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
reg, path, err := loadStatusRegistryFromFiles([]string{f1, f2})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg != nil {
t.Error("expected nil registry when all files have empty statuses")
}
if path != "" {
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 {

84
config/themes.go Normal file
View file

@ -0,0 +1,84 @@
package config
// Theme registry: maps theme names to palette constructors, dark/light classification,
// chroma syntax theme, and navidown markdown renderer style.
import (
"log/slog"
"sort"
)
// ThemeInfo holds all metadata for a named theme.
type ThemeInfo struct {
Light bool // true = light base, false = dark base
ChromaTheme string // chroma syntax theme for code blocks
NavidownStyle string // navidown markdown renderer style name
Palette func() Palette // palette constructor
}
// themeRegistry maps theme names to their ThemeInfo.
// "dark" and "light" are the built-in base themes; named themes extend this.
var themeRegistry = map[string]ThemeInfo{
// built-in base themes
"dark": {Light: false, ChromaTheme: "nord", NavidownStyle: "dark", Palette: DarkPalette},
"light": {Light: true, ChromaTheme: "github", NavidownStyle: "light", Palette: LightPalette},
// named dark themes
"dracula": {Light: false, ChromaTheme: "dracula", NavidownStyle: "dracula", Palette: DraculaPalette},
"tokyo-night": {Light: false, ChromaTheme: "tokyonight-night", NavidownStyle: "tokyo-night", Palette: TokyoNightPalette},
"gruvbox-dark": {Light: false, ChromaTheme: "gruvbox", NavidownStyle: "gruvbox-dark", Palette: GruvboxDarkPalette},
"catppuccin-mocha": {Light: false, ChromaTheme: "catppuccin-mocha", NavidownStyle: "catppuccin-mocha", Palette: CatppuccinMochaPalette},
"solarized-dark": {Light: false, ChromaTheme: "solarized-dark256", NavidownStyle: "solarized-dark", Palette: SolarizedDarkPalette},
"nord": {Light: false, ChromaTheme: "nord", NavidownStyle: "nord", Palette: NordPalette},
"monokai": {Light: false, ChromaTheme: "monokai", NavidownStyle: "monokai", Palette: MonokaiPalette},
"one-dark": {Light: false, ChromaTheme: "onedark", NavidownStyle: "one-dark", Palette: OneDarkPalette},
// named light themes
"catppuccin-latte": {Light: true, ChromaTheme: "catppuccin-latte", NavidownStyle: "catppuccin-latte", Palette: CatppuccinLattePalette},
"solarized-light": {Light: true, ChromaTheme: "solarized-light", NavidownStyle: "solarized-light", Palette: SolarizedLightPalette},
"gruvbox-light": {Light: true, ChromaTheme: "gruvbox-light", NavidownStyle: "gruvbox-light", Palette: GruvboxLightPalette},
"github-light": {Light: true, ChromaTheme: "github", NavidownStyle: "github-light", Palette: GithubLightPalette},
}
var defaultTheme = themeRegistry["dark"]
// lookupTheme returns the ThemeInfo for the effective theme.
// Logs a warning and returns the dark theme for unrecognized names.
func lookupTheme() ThemeInfo {
name := GetEffectiveTheme()
if info, ok := themeRegistry[name]; ok {
return info
}
slog.Warn("unknown theme, falling back to dark", "theme", name)
return defaultTheme
}
// IsLightTheme returns true if the effective theme has a light background.
func IsLightTheme() bool {
return lookupTheme().Light
}
// GetNavidownStyle returns the navidown markdown renderer style for the effective theme.
func GetNavidownStyle() string {
return lookupTheme().NavidownStyle
}
// PaletteForTheme returns the Palette for the effective theme.
func PaletteForTheme() Palette {
return lookupTheme().Palette()
}
// ChromaThemeForEffective returns the chroma syntax theme name for the effective theme.
func ChromaThemeForEffective() string {
return lookupTheme().ChromaTheme
}
// ThemeNames returns a sorted list of all registered theme names.
func ThemeNames() []string {
names := make([]string, 0, len(themeRegistry))
for name := range themeRegistry {
names = append(names, name)
}
sort.Strings(names)
return names
}

120
config/themes_test.go Normal file
View file

@ -0,0 +1,120 @@
package config
import (
"testing"
chromaStyles "github.com/alecthomas/chroma/v2/styles"
)
func TestThemeRegistryComplete(t *testing.T) {
names := ThemeNames()
if len(names) != 14 {
t.Fatalf("expected 14 themes, got %d: %v", len(names), names)
}
}
func TestThemeNamesAreSorted(t *testing.T) {
names := ThemeNames()
for i := 1; i < len(names); i++ {
if names[i] < names[i-1] {
t.Errorf("ThemeNames() not sorted: %q before %q", names[i-1], names[i])
}
}
}
func TestAllPalettesResolve(t *testing.T) {
for name, info := range themeRegistry {
// calling Palette() must not panic
p := info.Palette()
if p.TextColor.IsDefault() {
t.Errorf("theme %q: TextColor is default/transparent", name)
}
if p.HighlightColor.IsDefault() {
t.Errorf("theme %q: HighlightColor is default/transparent", name)
}
if p.AccentColor.IsDefault() {
t.Errorf("theme %q: AccentColor is default/transparent", name)
}
}
}
func TestIsLightThemeClassification(t *testing.T) {
expectedLight := map[string]bool{
"dark": false,
"light": true,
"dracula": false,
"tokyo-night": false,
"gruvbox-dark": false,
"catppuccin-mocha": false,
"solarized-dark": false,
"nord": false,
"monokai": false,
"one-dark": false,
"catppuccin-latte": true,
"solarized-light": true,
"gruvbox-light": true,
"github-light": true,
}
for name, wantLight := range expectedLight {
info, ok := themeRegistry[name]
if !ok {
t.Errorf("theme %q not in registry", name)
continue
}
if info.Light != wantLight {
t.Errorf("theme %q: Light = %v, want %v", name, info.Light, wantLight)
}
}
}
func TestUnknownThemeFallsToDark(t *testing.T) {
// simulate unknown theme by looking up directly in registry
_, ok := themeRegistry["nonexistent-theme"]
if ok {
t.Error("expected nonexistent-theme to not be in registry")
}
// lookupTheme() falls back to dark — verify via default
if defaultTheme.Light {
t.Error("default theme should be dark (Light=false)")
}
if defaultTheme.ChromaTheme != "nord" {
t.Errorf("default chroma theme = %q, want nord", defaultTheme.ChromaTheme)
}
}
func TestChromaThemesExist(t *testing.T) {
for name, info := range themeRegistry {
style := chromaStyles.Get(info.ChromaTheme)
if style == nil {
t.Errorf("theme %q: chroma theme %q not found in chroma registry", name, info.ChromaTheme)
}
}
}
func TestNavidownStylesValid(t *testing.T) {
// navidown supports these style names; unknown names fall back to "dark"
validNavidown := map[string]bool{
"dark": true, "light": true,
"dracula": true, "tokyo-night": true,
"pink": true, "ascii": true, "notty": true,
// additional named themes
"gruvbox-dark": true, "catppuccin-mocha": true,
"solarized-dark": true, "nord": true,
"monokai": true, "one-dark": true,
"catppuccin-latte": true, "solarized-light": true,
"gruvbox-light": true, "github-light": true,
}
for name, info := range themeRegistry {
if !validNavidown[info.NavidownStyle] {
t.Errorf("theme %q: navidown style %q is not a known navidown style", name, info.NavidownStyle)
}
}
}
func TestChromaThemeForEffectiveNonEmpty(t *testing.T) {
for name, info := range themeRegistry {
if info.ChromaTheme == "" {
t.Errorf("theme %q: ChromaTheme is empty", name)
}
}
}

91
config/triggers.go Normal file
View file

@ -0,0 +1,91 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// TriggerDef represents a single trigger entry in workflow.yaml.
type TriggerDef struct {
Description string `yaml:"description"`
Ruki string `yaml:"ruki"`
}
// triggerFileData is the minimal YAML structure for reading triggers from workflow.yaml.
type triggerFileData struct {
Triggers []TriggerDef `yaml:"triggers"`
}
// LoadTriggerDefs discovers and returns raw trigger definitions from workflow.yaml files.
// Uses its own discovery path (not FindWorkflowFiles) to avoid the empty-views filter.
// Override semantics: last file with a triggers: section wins (cwd > project > user).
// An explicit empty list (triggers: []) overrides inherited triggers.
// The caller is responsible for parsing each TriggerDef.Ruki with ruki.ParseTrigger.
func LoadTriggerDefs() ([]TriggerDef, error) {
pm := mustGetPathManager()
// candidate paths in discovery order: user config → project config → cwd
candidates := []string{
pm.UserConfigWorkflowFile(),
filepath.Join(pm.ProjectConfigDir(), defaultWorkflowFilename),
defaultWorkflowFilename, // relative to cwd
}
var winningDefs []TriggerDef
seen := make(map[string]bool)
for _, path := range candidates {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
if seen[abs] {
continue
}
seen[abs] = true
defs, found, err := readTriggersFromFile(path)
if err != nil {
return nil, fmt.Errorf("reading triggers from %s: %w", path, err)
}
if found {
// last file with a triggers: section wins
winningDefs = defs
}
}
return winningDefs, nil
}
// readTriggersFromFile reads a workflow.yaml and returns its triggers section.
// Returns (defs, true, nil) if the file exists and has a triggers: key.
// Returns (nil, false, nil) if the file doesn't exist or has no triggers: key.
func readTriggersFromFile(path string) ([]TriggerDef, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
// check whether the YAML contains a triggers: key at all
// (absent section = no opinion, does not override)
var raw map[string]interface{}
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, false, fmt.Errorf("parsing YAML: %w", err)
}
if _, hasTriggers := raw["triggers"]; !hasTriggers {
return nil, false, nil
}
var tf triggerFileData
if err := yaml.Unmarshal(data, &tf); err != nil {
return nil, false, fmt.Errorf("parsing triggers: %w", err)
}
return tf.Triggers, true, nil
}

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