Compare commits
67 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bf8bd4d9 | ||
|
|
41883b510d | ||
|
|
4891de4163 | ||
|
|
8a68544ac9 | ||
|
|
f4e425b08b | ||
|
|
129f847622 | ||
|
|
7abddcd4c6 | ||
|
|
ef25f5ff98 | ||
|
|
48e796877e | ||
|
|
8838c29149 | ||
|
|
0312ad8fca | ||
|
|
232737940b | ||
|
|
f377bb54dd | ||
|
|
8cc1614724 | ||
|
|
025342c3af | ||
|
|
2175551e38 | ||
|
|
2a6c1201f8 | ||
|
|
936942d7b5 | ||
|
|
2a50feb6fb | ||
|
|
50a3d8ca20 | ||
|
|
1596bc9c39 | ||
|
|
db019108be | ||
|
|
7b7136ce5e | ||
|
|
80e7f1e510 | ||
|
|
f7ca8b44fa | ||
|
|
11b4ae2a7b | ||
|
|
529835df1c | ||
|
|
a63ad74845 | ||
|
|
47a57afc1c | ||
|
|
351d7817e3 | ||
|
|
56d3b64c22 | ||
|
|
6713b5b7c5 | ||
|
|
952c095372 | ||
|
|
73a95e2c5b | ||
|
|
7169f53c20 | ||
|
|
a226f433d4 | ||
|
|
3f86eb93ce | ||
|
|
3b33659338 | ||
|
|
80444f1848 | ||
|
|
31cfd46453 | ||
|
|
14d46d4802 | ||
|
|
ae3634da1b | ||
|
|
70d572b7a4 | ||
|
|
62468209bc | ||
|
|
692e559d5f | ||
|
|
b52e20d30f | ||
|
|
d2c28655bd | ||
|
|
aefb6a757d | ||
|
|
3c658f7332 | ||
|
|
e275604e85 | ||
|
|
12a0ea86f3 | ||
|
|
9ec6588f6b | ||
|
|
7f6f3654c3 | ||
|
|
404b4be3be | ||
|
|
335743b874 | ||
|
|
db900cfa5e | ||
|
|
8cbce760e3 | ||
|
|
9e40a0f56b | ||
|
|
9eee3ea019 | ||
|
|
a5a7c2d124 | ||
|
|
6f0ecf93b3 | ||
|
|
ab06d1a08e | ||
|
|
0fdeed41a5 | ||
|
|
7656ae91ac | ||
|
|
e93675c34f | ||
|
|
0be985a077 | ||
|
|
b5f2ad66fa |
159
.doc/doki/doc/command-line.md
Normal 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` |
|
||||
|
|
@ -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"
|
||||
```
|
||||
249
.doc/doki/doc/custom-fields.md
Normal 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`).
|
||||
125
.doc/doki/doc/custom-status-type.md
Normal 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.
|
||||
|
|
@ -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).
|
||||
322
.doc/doki/doc/ideas/plugins.md
Normal 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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# Markdown viewer
|
||||
|
||||

|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
175
.doc/doki/doc/ruki/custom-fields-reference.md
Normal 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 |
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>`.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
### dracula
|
||||
|
||||

|
||||
|
||||
### tokyo-night
|
||||
|
||||

|
||||
|
||||
### gruvbox-dark
|
||||
|
||||

|
||||
|
||||
### catppuccin-mocha
|
||||
|
||||

|
||||
|
||||
### solarized-dark
|
||||
|
||||

|
||||
|
||||
### nord
|
||||
|
||||

|
||||
|
||||
### monokai
|
||||
|
||||

|
||||
|
||||
### one-dark
|
||||
|
||||

|
||||
|
||||
## Light themes
|
||||
|
||||
### light
|
||||
|
||||
The built-in light base theme. Used by `auto` when a light terminal background is detected.
|
||||
|
||||

|
||||
|
||||
### catppuccin-latte
|
||||
|
||||

|
||||
|
||||
### solarized-light
|
||||
|
||||

|
||||
|
||||
### gruvbox-light
|
||||
|
||||

|
||||
|
||||
### github-light
|
||||
|
||||

|
||||
BIN
.doc/doki/doc/themes/catppuccin-latte.png
vendored
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
.doc/doki/doc/themes/catppuccin-mocha.png
vendored
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
.doc/doki/doc/themes/dark.png
vendored
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
.doc/doki/doc/themes/dracula.png
vendored
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
.doc/doki/doc/themes/github-light.png
vendored
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
.doc/doki/doc/themes/gruvbox-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
.doc/doki/doc/themes/gruvbox-light.png
vendored
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
.doc/doki/doc/themes/light.png
vendored
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
.doc/doki/doc/themes/monokai.png
vendored
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
.doc/doki/doc/themes/nord.png
vendored
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
.doc/doki/doc/themes/one-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
.doc/doki/doc/themes/solarized-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
.doc/doki/doc/themes/solarized-light.png
vendored
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
.doc/doki/doc/themes/tokyo-night.png
vendored
Normal file
|
After Width: | Height: | Size: 260 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
2
Makefile
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ Follow me on X: [
|
||||
**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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` | 1–5 (1=high, 5=low), default 3 |
|
||||
| `points` | `int` | story points 1–10 |
|
||||
| `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 
|
||||
- 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
|
After Width: | Height: | Size: 286 KiB |
251
cmd_workflow.go
Normal 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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
48
config/caption_colors_test.go
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
415
config/colors.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
136
config/init.go
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
101
config/loader.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
---
|
||||
title:
|
||||
type: story
|
||||
status: backlog
|
||||
points: 1
|
||||
priority: 3
|
||||
tags:
|
||||
|
|
|
|||
708
config/palettes.go
Normal 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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
47
config/shipped_workflows_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
config/system.go
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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=
|
||||
|
|
|
|||
164
integration/action_palette_test.go
Normal 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)
|
||||
}
|
||||