Compare commits

...

67 commits
v0.4.0 ... 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
231 changed files with 16907 additions and 3026 deletions

View file

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

View file

@ -61,17 +61,20 @@ Search order: user config dir (base) → `.doc/workflow.yaml` (project) → cwd
**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, colors, view mode)
- 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: []
views:
plugins: []
```
### config.yaml
@ -100,16 +103,15 @@ 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:
@ -148,82 +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: 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"
foreground: "#5fff87"
background: "#0b3d2e"
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"
foreground: "#f4d6a6"
background: "#5a3d1b"
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"
foreground: "#e2e8f0"
background: "#2a5f5a"
key: "F4"
lanes:
- name: Now
columns: 1
width: 25
filter: select where type = "epic" and status = "ready" order by priority, points desc
action: update where id = id() set status="ready"
- name: Next
columns: 1
width: 25
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=1
- name: Later
columns: 2
width: 50
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
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,15 +47,43 @@ statuses:
```
Each status has:
- `key` — canonical identifier (lowercase, underscores). Used in filters, actions, and frontmatter.
- `label` — display name shown in the UI
- `key` — canonical camelCase identifier. Used in filters, actions, and frontmatter.
- `label` — display name shown in the UI (defaults to key when omitted)
- `emoji` — emoji shown alongside the label
- `active` — marks the status as "active work" (used for activity tracking)
- `default` — the status assigned to new tikis (exactly one status should have this)
- `done` — marks the status as "completed" (used for completion tracking)
- `default` — the status assigned to new tikis (exactly one required)
- `done` — marks the status as "completed" (exactly one required)
You can customize these to match your team's workflow. All filters and actions in view definitions (see below) must reference valid status keys.
## Types
Task types are defined in `workflow.yaml` under the `types:` key. If omitted, built-in defaults are used.
See [Custom statuses and types](custom-status-type.md) for the full validation and inheritance rules. The default `workflow.yaml` ships with:
```yaml
types:
- key: story
label: Story
emoji: "🌀"
- key: bug
label: Bug
emoji: "💥"
- key: spike
label: Spike
emoji: "🔍"
- key: epic
label: Epic
emoji: "🗂️"
```
Each type has:
- `key` — canonical lowercase identifier. Used in filters, actions, and frontmatter.
- `label` — display name shown in the UI (defaults to key when omitted)
- `emoji` — emoji shown alongside the label
The first configured type is used as the default for new tikis.
## Task Template
When you create a new tiki — whether in the TUI or command line — field defaults come from a template file.
@ -53,8 +94,7 @@ The file uses YAML frontmatter for field defaults
```markdown
---
type: story
status: backlog
title:
points: 1
priority: 3
tags:
@ -62,6 +102,8 @@ tags:
---
```
Type and status are omitted but can be added, otherwise they default to the first configured type and the status marked `default: true`.
## Plugins
tiki TUI app is much like a lego - everything is a customizable view. Here is, for example,
@ -69,37 +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: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
plugins:
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
```
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
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, 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
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`
@ -115,8 +155,6 @@ definition that roughly mimics the board:
```yaml
name: Custom
foreground: "#5fff87"
background: "#005f00"
key: "F4"
lanes:
- name: Ready
@ -157,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.
@ -174,15 +233,75 @@ actions:
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - a `ruki` `update` statement (same syntax as lane actions, see below)
- `label` - description shown in the header and action palette
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
- `hot` - (optional) controls header visibility. `hot: true` shows the action in the header, `hot: false` hides it. When absent, actions default to visible in the header. This does not affect the action palette — all actions are always discoverable via `?` regardless of the `hot` setting
- `input` - (optional) declares that the action prompts for user input before executing. The value is the scalar type of the input: `string`, `int`, `bool`, `date`, `timestamp`, or `duration`. The action's `ruki` statement must use `input()` to reference the value
Example — keeping a verbose action out of the header but still accessible from the palette:
```yaml
actions:
- key: "x"
label: "Archive and notify"
action: update where id = id() set status="done"
hot: false
```
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki.
### Input-backed actions
Actions with `input:` prompt the user for a value before executing. When the action key is pressed, a modal input box opens with the action label as the prompt. The user types a value and presses Enter to execute, or Esc to cancel.
```yaml
actions:
- key: "A"
label: "Assign to..."
action: update where id = id() set assignee = input()
input: string
- key: "t"
label: "Add tag"
action: update where id = id() set tags = tags + [input()]
input: string
- key: "T"
label: "Remove tag"
action: update where id = id() set tags = tags - [input()]
input: string
- key: "p"
label: "Set points"
action: update where id = id() set points = input()
input: int
- key: "D"
label: "Set due date"
action: update where id = id() set due = input()
input: date
```
The input box is modal while editing — other actions are blocked until Enter or Esc. If the entered value is invalid for the declared type (e.g. non-numeric text for `int`), an error appears in the statusline and the prompt stays open for correction.
Supported `input:` types: `string`, `int`, `bool`, `date` (YYYY-MM-DD), `timestamp` (RFC3339 or YYYY-MM-DD), `duration` (e.g. `2day`, `1week`).
Validation rules:
- An action with `input:` must use `input()` in its `ruki` statement
- An action using `input()` must declare `input:` — otherwise the workflow fails to load
- `input()` may only appear once per action
### Search and input box interaction
The input box serves both search and action-input, with explicit mode tracking:
- **Search editing**: pressing `/` opens the input box focused for typing. Enter with text applies the search and transitions to **search passive** mode. Enter on empty text is a no-op. Esc clears search and closes the box.
- **Search passive**: the search box remains visible as a non-editable indicator showing the active query, while normal task navigation and actions are re-enabled. Pressing `/` again is blocked — dismiss the active search with Esc first, then open a new search. Esc clears the search results and closes the box.
- **Action input**: pressing an input-backed action key opens a modal prompt. If search was passive, the prompt temporarily replaces the search indicator. Valid Enter executes the action and restores the passive search indicator (or closes if no prior search). Esc cancels and likewise restores passive search. Invalid Enter keeps the prompt open for correction.
- **Modal blocking**: while search editing or action input is active, all other plugin actions and keyboard shortcuts are blocked. The action palette cannot open while the input box is editing.
### ruki expressions
Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements and actions use `update` statements.
Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
#### Filter (select)
@ -221,7 +340,7 @@ update where id = id() set assignee=user()
- `id` - task identifier (e.g., "TIKI-M7N2XK")
- `title` - task title text
- `type` - task type: "story", "bug", "spike", or "epic"
- `type` - task type (must match a key defined in `workflow.yaml` types)
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
- `assignee` - assigned user
- `priority` - numeric priority value (1-5)
@ -252,6 +371,7 @@ update where id = id() set assignee=user()
- `user()` — current user
- `now()` — current timestamp
- `id()` — currently selected tiki (in plugin context)
- `input()` — user-supplied value (in actions with `input:` declaration)
- `count(select where ...)` — count matching tikis
For the full language reference, see the [ruki documentation](ruki/index.md).
For the full language reference, see the [ruki documentation](ruki/index.md).

View file

@ -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

@ -2,6 +2,29 @@
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.

View file

@ -1,12 +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 collaboration](ai.md)
- [Recipes](ideas/triggers.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:

View file

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

View file

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

View file

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

View file

@ -14,13 +14,20 @@ This section documents the `ruki` language. `ruki` is a small language for findi
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
- [Recipes](../ideas/triggers.md): ready-to-use trigger examples for common workflow patterns.
- [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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -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

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

View file

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

View file

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

View file

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

BIN
assets/light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

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.GetColors().ContentBackgroundColor,
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.GetColors().FallbackBurndownColor
return config.GetColors().FallbackBurndownColor.TCell()
}
t := float64(row) / float64(total-1)

View file

@ -26,12 +26,12 @@ func NewCompletionPrompt(words []string) *CompletionPrompt {
// Configure the input field
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
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

@ -28,8 +28,8 @@ type DateEdit struct {
func NewDateEdit() *DateEdit {
inputField := tview.NewInputField()
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
de := &DateEdit{
InputField: inputField,

View file

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

View file

@ -38,8 +38,8 @@ func NewIntEditSelect(min, max int, allowTyping bool) *IntEditSelect {
inputField := tview.NewInputField()
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
ies := &IntEditSelect{
InputField: inputField,

View file

@ -32,8 +32,8 @@ type RecurrenceEdit struct {
func NewRecurrenceEdit() *RecurrenceEdit {
inputField := tview.NewInputField()
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
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.
@ -39,7 +40,8 @@ func NewTaskList(maxVisibleRows int) *TaskList {
idGradient: colors.TaskBoxIDColor,
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

@ -181,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 {
@ -197,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)
}
}
@ -270,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.GetColors().ContentBackgroundColor)
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

@ -60,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)

View file

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

View file

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

View file

@ -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,227 +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
TaskDetailPlaceholderColor 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
// Content area colors (base canvas for editable/readable content)
ContentBackgroundColor tcell.Color
ContentTextColor tcell.Color
ContentBackgroundColor Color
ContentTextColor Color
// Search box colors
SearchBoxLabelColor tcell.Color
SearchBoxBackgroundColor tcell.Color
SearchBoxTextColor tcell.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 tcell.Color // muted slate for dependency editor caption
DepsEditorBackground Color // muted slate for dependency editor caption
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
FallbackTaskIDColor tcell.Color // Deep Sky Blue (end of task ID gradient)
FallbackBurndownColor tcell.Color // Purple (start of burndown gradient)
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)
TaskDetailPlaceholderColor: tcell.ColorGray, // Gray for placeholder text in edit fields
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,
// Content area (base canvas)
ContentBackgroundColor: tcell.ColorBlack, // dark theme: explicit black
ContentTextColor: tcell.ColorWhite, // dark theme: white text
// Content area
ContentBackgroundColor: p.ContentBackgroundColor,
ContentTextColor: p.TextColor,
// Search box
SearchBoxLabelColor: tcell.ColorWhite,
SearchBoxBackgroundColor: tcell.ColorDefault, // Transparent
SearchBoxTextColor: tcell.ColorWhite,
// Input box
InputBoxLabelColor: p.TextColor,
InputBoxBackgroundColor: p.TransparentColor,
InputBoxTextColor: p.TextColor,
// Input field colors
InputFieldBackgroundColor: tcell.ColorDefault, // Transparent
InputFieldTextColor: tcell.ColorWhite,
// 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,
// Plugin-specific
DepsEditorBackground: tcell.NewHexColor(0x4e5768), // Muted slate
DepsEditorBackground: p.StatuslineBorderBg,
// Fallback solid colors (no-gradient terminals)
FallbackTaskIDColor: tcell.NewRGBColor(0, 191, 255), // Deep Sky Blue
FallbackBurndownColor: tcell.NewRGBColor(134, 90, 214), // Purple
// Fallback solid colors
FallbackTaskIDColor: p.DeepSkyBlue,
FallbackBurndownColor: p.DeepPurple,
// 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: "#ffff00", // yellow, matches header global key color
StatuslineErrorBg: "#3b4252", // Nord polar night 2
StatuslineFillBg: "#3b4252", // Nord polar night 2
// 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
@ -245,20 +316,10 @@ var UseGradients bool
// Screen-wide gradients show more banding on 256-color terminals, so require truecolor
var UseWideGradients bool
// 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.ContentBackgroundColor = tcell.ColorDefault
globalColors.ContentTextColor = tcell.ColorBlack
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
@ -20,86 +24,118 @@ statuses:
emoji: "✅"
done: true
types:
- key: story
label: Story
emoji: "🌀"
- key: bug
label: Bug
emoji: "💥"
- key: spike
label: Spike
emoji: "🔍"
- key: epic
label: Epic
emoji: "🗂️"
views:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
default: true
foreground: "#87ceeb"
background: "#25496a"
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"
foreground: "#5fff87"
background: "#0b3d2e"
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"
foreground: "#f4d6a6"
background: "#5a3d1b"
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"
foreground: "#e2e8f0"
background: "#2a5f5a"
key: "F4"
lanes:
- name: Now
columns: 1
width: 25
filter: select where type = "epic" and status = "ready" order by priority, points desc
action: update where id = id() set status="ready"
- name: Next
columns: 1
width: 25
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=1
- name: Later
columns: 2
width: 50
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
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
@ -136,3 +172,9 @@ triggers:
before delete
where old.status = "inProgress"
deny "cannot delete an in-progress task — move to backlog or done first"
- description: spawn next occurrence when recurring task completes
ruki: >
after update
where new.status = "done" and old.recurrence is not empty
create title=old.title priority=old.priority tags=old.tags
recurrence=old.recurrence due=next_date(old.recurrence) status="backlog"

View file

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

272
config/fields.go Normal file
View file

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

225
config/fields_test.go Normal file
View file

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

View file

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

182
config/init_test.go Normal file
View file

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

121
config/install.go Normal file
View file

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

213
config/install_test.go Normal file
View file

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

View file

@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"github.com/muesli/termenv"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
@ -20,6 +21,8 @@ var lastConfigFile string
// Config holds all application configuration loaded from config.yaml
type Config struct {
Version string `mapstructure:"version"`
// Logging configuration
Logging struct {
Level string `mapstructure:"level"` // "debug", "info", "warn", "error"
@ -43,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")
@ -154,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.
@ -189,21 +192,44 @@ func GetConfig() *Config {
return appConfig
}
// viewsFileData represents the views section of workflow.yaml for read-modify-write.
type viewsFileData struct {
Actions []map[string]interface{} `yaml:"actions,omitempty"`
Plugins []map[string]interface{} `yaml:"plugins"`
}
// workflowFileData represents the YAML structure of workflow.yaml for read-modify-write.
// kept in config package to avoid import cycle with plugin package.
// all top-level sections must be listed here to survive round-trip serialization.
type workflowFileData struct {
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
Plugins []map[string]interface{} `yaml:"views"`
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
Version string `yaml:"version,omitempty"`
Description string `yaml:"description,omitempty"`
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
Types []map[string]interface{} `yaml:"types,omitempty"`
Views viewsFileData `yaml:"views,omitempty"`
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
Fields []map[string]interface{} `yaml:"fields,omitempty"`
}
// readWorkflowFile reads and unmarshals workflow.yaml from the given path.
// Handles both old list format (views: [...]) and new map format (views: {plugins: [...]}).
func readWorkflowFile(path string) (*workflowFileData, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading workflow.yaml: %w", err)
}
// convert legacy views list format to map format before unmarshaling
var raw map[string]interface{}
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing workflow.yaml: %w", err)
}
ConvertViewsListToMap(raw)
data, err = yaml.Marshal(raw)
if err != nil {
return nil, fmt.Errorf("re-marshaling workflow.yaml: %w", err)
}
var wf workflowFileData
if err := yaml.Unmarshal(data, &wf); err != nil {
return nil, fmt.Errorf("parsing workflow.yaml: %w", err)
@ -211,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)
@ -249,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
@ -278,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
@ -292,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)
}
}
@ -354,24 +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
return cachedEffectiveTheme
}
// GetGradientThreshold returns the minimum color count required for gradients
@ -384,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

View file

@ -182,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)
@ -446,8 +450,8 @@ triggers:
}
// modify a view mode (same as SavePluginViewMode logic)
if len(wf.Plugins) > 0 {
wf.Plugins[0]["view"] = "compact"
if len(wf.Views.Plugins) > 0 {
wf.Views.Plugins[0]["view"] = "compact"
}
if err := writeWorkflowFile(workflowPath, wf); err != nil {
@ -490,6 +494,59 @@ triggers:
}
}
func TestSavePluginViewMode_PreservesDescription(t *testing.T) {
tmpDir := t.TempDir()
workflowContent := `description: |
Release workflow. Coordinate feature rollout through
Planned Building Staging Canary Released.
statuses:
- key: backlog
label: Backlog
default: true
- key: done
label: Done
done: true
views:
- name: Kanban
default: true
key: "F1"
lanes:
- name: Done
filter: status = 'done'
action: status = 'done'
sort: Priority, CreatedAt
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatal(err)
}
wf, err := readWorkflowFile(workflowPath)
if err != nil {
t.Fatalf("readWorkflowFile failed: %v", err)
}
wantDesc := "Release workflow. Coordinate feature rollout through\nPlanned → Building → Staging → Canary → Released.\n"
if wf.Description != wantDesc {
t.Errorf("description after read = %q, want %q", wf.Description, wantDesc)
}
if len(wf.Views.Plugins) > 0 {
wf.Views.Plugins[0]["view"] = "compact"
}
if err := writeWorkflowFile(workflowPath, wf); err != nil {
t.Fatalf("writeWorkflowFile failed: %v", err)
}
wf2, err := readWorkflowFile(workflowPath)
if err != nil {
t.Fatalf("readWorkflowFile after write failed: %v", err)
}
if wf2.Description != wantDesc {
t.Errorf("description after round-trip = %q, want %q", wf2.Description, wantDesc)
}
}
func TestGetConfig(t *testing.T) {
// Reset appConfig
appConfig = nil

View file

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

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

168
config/reset.go Normal file
View file

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

268
config/reset_test.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

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)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -381,9 +381,10 @@ func (c *TaskEditCoordinator) prepareView(activeView View, focus model.EditField
_ = c.FocusNextField(activeView)
}
// rejectionMessage extracts clean rejection reasons from an error.
// If the error wraps a RejectionError, returns just the reasons without
// the "failed to update task:" / "validation failed:" prefixes.
// rejectionMessage extracts a clean user-facing message from an error.
// For RejectionError: returns just the rejection reasons.
// For other errors: unwraps to the root cause to strip wrapper prefixes
// like "failed to update task: failed to save task:".
func rejectionMessage(err error) string {
var re *service.RejectionError
if errors.As(err, &re) {
@ -393,5 +394,13 @@ func rejectionMessage(err error) string {
}
return strings.Join(reasons, "; ")
}
// unwrap to the innermost error for a clean message
for {
inner := errors.Unwrap(err)
if inner == nil {
break
}
err = inner
}
return err.Error()
}

View file

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

View file

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

8
go.mod
View file

@ -3,8 +3,10 @@ module github.com/boolean-maybe/tiki
go 1.25.0
require (
github.com/alecthomas/chroma/v2 v2.14.0
github.com/alecthomas/participle/v2 v2.1.4
github.com/boolean-maybe/navidown v0.4.16
github.com/atotto/clipboard v0.1.4
github.com/boolean-maybe/navidown v0.4.18
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.0
@ -13,6 +15,7 @@ require (
github.com/go-git/go-git/v5 v5.16.4
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-runewidth v0.0.16
github.com/muesli/termenv v0.16.0
github.com/rivo/tview v0.42.0
github.com/spf13/pflag v1.0.6
github.com/spf13/viper v1.20.1
@ -23,8 +26,6 @@ require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
@ -57,7 +58,6 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect

4
go.sum
View file

@ -27,8 +27,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/boolean-maybe/navidown v0.4.16 h1:KfanPIG6vm26+Y7rjT5ztrERkZa6FDiSZ6Z0e0wfZeI=
github.com/boolean-maybe/navidown v0.4.16/go.mod h1:uF4Z/5uTnEtC6ZWyfKRv5Qw8Nir1nfp4kUraggTRfrk=
github.com/boolean-maybe/navidown v0.4.18 h1:sSjWWN2GK4DrkoSHURDmE8db6Zx/xnoEsHfqBTS4Nfk=
github.com/boolean-maybe/navidown v0.4.18/go.mod h1:uF4Z/5uTnEtC6ZWyfKRv5Qw8Nir1nfp4kUraggTRfrk=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=

View file

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

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