Merge pull request #100 from boolean-maybe/dev

0.5.0
This commit is contained in:
boolean-maybe 2026-04-19 22:09:28 -04:00 committed by GitHub
commit 4891de4163
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 6424 additions and 1742 deletions

View file

@ -32,16 +32,16 @@ tiki exec 'select where status = "ready" order by priority'
tiki exec 'update where id = "TIKI-ABC123" set status="done"'
```
### config
### workflow
Manage configuration files.
Manage workflow configuration files.
#### config reset
#### workflow reset
Reset configuration files to their defaults.
```bash
tiki config reset [target] --scope
tiki workflow reset [target] [--scope]
```
**Targets** (omit to reset all three files):
@ -49,7 +49,7 @@ tiki config reset [target] --scope
- `workflow` — workflow.yaml
- `new` — new.md (task template)
**Scopes** (required):
**Scopes** (default: `--local`):
- `--global` — user config directory
- `--local` — project config directory (`.doc/`)
- `--current` — current working directory
@ -60,13 +60,54 @@ For `--local` and `--current`, files are deleted so the next tier in the [preced
```bash
# restore all global config to defaults
tiki config reset --global
tiki workflow reset --global
# remove project workflow overrides (falls back to global)
tiki config reset workflow --local
tiki workflow reset workflow --local
# remove cwd config override
tiki config reset config --current
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

View file

@ -154,6 +154,10 @@ views:
- 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"
@ -209,12 +213,6 @@ views:
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki

View file

@ -4,6 +4,19 @@ 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
@ -220,14 +233,72 @@ actions:
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `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. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
@ -300,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).

View file

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

View file

@ -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(...)`

View file

@ -317,3 +317,51 @@ select where select = 1
create title=select
```
## input() errors
`input()` without `input:` declaration on the action:
```sql
update where id = id() set assignee = input()
```
Fails at workflow load time with: `input() requires 'input:' declaration on action`.
`input:` declared but `input()` not used:
```yaml
- key: "a"
label: "Ready"
action: update where id = id() set status="ready"
input: string
```
Fails at workflow load time: `declares 'input: string' but does not use input()`.
Duplicate `input()` (more than one call per action):
```sql
update where id = id() set assignee=input(), title=input()
```
Fails with: `input() may only be used once per action`.
Type mismatch (declared type incompatible with target field):
```yaml
- key: "a"
label: "Assign to"
action: update where id = id() set assignee = input()
input: int
```
Fails at workflow load time: `int` is not assignable to string field `assignee`.
`input()` with arguments:
```sql
update where id = id() set assignee = input("name")
```
Fails with: `input() takes no arguments`.

View file

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

View file

@ -92,7 +92,7 @@ this will clone and show a demo project. Once done you can try your own:
`cd` into your **git** repo and run `tiki init` to initialize.
Move your tiki around the board with `Shift ←/Shift →`.
Make sure to press `?` for help.
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
### AI skills
@ -142,7 +142,7 @@ Store your notes in remotes!
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
Read more by pressing `?` for help
Press `?` to open the Action Palette and discover all available actions
## AI skills

View file

@ -1,125 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"github.com/boolean-maybe/tiki/config"
)
// runConfig dispatches config subcommands. Returns an exit code.
func runConfig(args []string) int {
if len(args) == 0 {
printConfigUsage()
return exitUsage
}
switch args[0] {
case "reset":
return runConfigReset(args[1:])
case "--help", "-h":
printConfigUsage()
return exitOK
default:
_, _ = fmt.Fprintf(os.Stderr, "unknown config command: %s\n", args[0])
printConfigUsage()
return exitUsage
}
}
// runConfigReset implements `tiki config reset [target] --scope`.
func runConfigReset(args []string) int {
target, scope, err := parseConfigResetArgs(args)
if errors.Is(err, errHelpRequested) {
printConfigResetUsage()
return exitOK
}
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
printConfigResetUsage()
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
}
// parseConfigResetArgs parses arguments for `tiki config reset`.
func parseConfigResetArgs(args []string) (config.ResetTarget, config.ResetScope, error) {
var targetStr 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 targetStr != "" {
return "", "", fmt.Errorf("multiple targets specified: %q and %q", targetStr, arg)
}
targetStr = arg
}
}
if scopeStr == "" {
return "", "", fmt.Errorf("scope required: --global, --local, or --current")
}
target := config.ResetTarget(targetStr)
switch target {
case config.TargetAll, config.TargetConfig, config.TargetWorkflow, config.TargetNew:
// valid
default:
return "", "", fmt.Errorf("unknown target: %q (use config, workflow, or new)", targetStr)
}
return target, config.ResetScope(scopeStr), nil
}
func printConfigUsage() {
fmt.Print(`Usage: tiki config <command>
Commands:
reset [target] --scope Reset config files to defaults
Run 'tiki config reset --help' for details.
`)
}
func printConfigResetUsage() {
fmt.Print(`Usage: tiki config 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 (required):
--global User config directory
--local Project config directory (.doc/)
--current Current working directory
`)
}

View file

@ -1,117 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
"github.com/boolean-maybe/tiki/config"
)
func TestParseConfigResetArgs(t *testing.T) {
tests := []struct {
name string
args []string
target config.ResetTarget
scope config.ResetScope
wantErr error // sentinel match (nil = no error)
errSubstr string // substring match for non-sentinel errors
}{
{
name: "global all",
args: []string{"--global"},
target: config.TargetAll,
scope: config.ScopeGlobal,
},
{
name: "local workflow",
args: []string{"workflow", "--local"},
target: config.TargetWorkflow,
scope: config.ScopeLocal,
},
{
name: "current config",
args: []string{"--current", "config"},
target: config.TargetConfig,
scope: config.ScopeCurrent,
},
{
name: "global new",
args: []string{"new", "--global"},
target: config.TargetNew,
scope: config.ScopeGlobal,
},
{
name: "help flag",
args: []string{"--help"},
wantErr: errHelpRequested,
},
{
name: "short help flag",
args: []string{"-h"},
wantErr: errHelpRequested,
},
{
name: "missing scope",
args: []string{"config"},
errSubstr: "scope required",
},
{
name: "unknown flag",
args: []string{"--verbose"},
errSubstr: "unknown flag",
},
{
name: "unknown target",
args: []string{"themes", "--global"},
errSubstr: "unknown target",
},
{
name: "multiple targets",
args: []string{"config", "workflow", "--global"},
errSubstr: "multiple targets",
},
{
name: "duplicate scopes",
args: []string{"--global", "--local"},
errSubstr: "only one scope allowed",
},
{
name: "no args",
args: nil,
errSubstr: "scope required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
target, scope, err := parseConfigResetArgs(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 target != tt.target {
t.Errorf("target = %q, want %q", target, tt.target)
}
if scope != tt.scope {
t.Errorf("scope = %q, want %q", scope, tt.scope)
}
})
}
}

251
cmd_workflow.go Normal file
View file

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

414
cmd_workflow_test.go Normal file
View file

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

View file

@ -4,7 +4,7 @@ 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"

View file

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

View file

@ -1,3 +1,7 @@
version: 0.5.0
description: |
Default tiki workflow. A lightweight kanban-style flow with
Backlog → Ready → In Progress → Review → Done, plus Story / Bug / Spike / Epic task types.
statuses:
- key: backlog
label: Backlog
@ -45,9 +49,34 @@ views:
- 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 new status, search, create or delete"
description: "Move tiki to change status, search, create or delete\nShift Left/Right to move"
default: true
key: "F1"
lanes:
@ -101,12 +130,6 @@ views:
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki

View file

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

View file

@ -90,7 +90,7 @@ Press Esc to cancel project initialization.`
Value(&opts.AITools),
huh.NewConfirm().
Title("Create sample tasks").
Description("Seed project with example tikis (incompatible samples are skipped automatically)").
Description("Create example tikis in project").
Value(&opts.SampleTasks),
),
).WithTheme(theme).

121
config/install.go Normal file
View file

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

213
config/install_test.go Normal file
View file

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

View file

@ -21,6 +21,8 @@ var lastConfigFile string
// Config holds all application configuration loaded from config.yaml
type Config struct {
Version string `mapstructure:"version"`
// Logging configuration
Logging struct {
Level string `mapstructure:"level"` // "debug", "info", "warn", "error"
@ -200,11 +202,13 @@ type viewsFileData struct {
// 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"`
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"`
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.

View file

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

View file

@ -7,16 +7,16 @@ import (
"path/filepath"
)
// ResetScope identifies which config tier to reset.
type ResetScope string
// Scope identifies which config tier to operate on.
type Scope string
// ResetTarget identifies which config file to reset.
type ResetTarget string
const (
ScopeGlobal ResetScope = "global"
ScopeLocal ResetScope = "local"
ScopeCurrent ResetScope = "current"
ScopeGlobal Scope = "global"
ScopeLocal Scope = "local"
ScopeCurrent Scope = "current"
TargetAll ResetTarget = ""
TargetConfig ResetTarget = "config"
@ -40,7 +40,7 @@ var resetEntries = []resetEntry{
// 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 ResetScope, target ResetTarget) ([]string, error) {
func ResetConfig(scope Scope, target ResetTarget) ([]string, error) {
dir, err := resolveDir(scope)
if err != nil {
return nil, err
@ -67,7 +67,7 @@ func ResetConfig(scope ResetScope, target ResetTarget) ([]string, error) {
}
// resolveDir returns the directory path for the given scope.
func resolveDir(scope ResetScope) (string, error) {
func resolveDir(scope Scope) (string, error) {
switch scope {
case ScopeGlobal:
return GetConfigDir(), nil
@ -87,6 +87,16 @@ func resolveDir(scope ResetScope) (string, error) {
}
}
// 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 {
@ -101,7 +111,7 @@ func filterEntries(target ResetTarget) ([]resetEntry, error) {
case TargetNew:
filename = templateFilename
default:
return nil, fmt.Errorf("unknown reset target: %q", target)
return nil, fmt.Errorf("unknown target: %q (use config, workflow, or new)", target)
}
for _, e := range resetEntries {
if e.filename == filename {
@ -114,21 +124,25 @@ func filterEntries(target ResetTarget) ([]resetEntry, error) {
// 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 ResetScope, defaultContent string) (bool, error) {
func resetFile(path string, scope Scope, defaultContent string) (bool, error) {
if scope == ScopeGlobal && defaultContent != "" {
return writeDefault(path, defaultContent)
return writeFileIfChanged(path, defaultContent)
}
return deleteIfExists(path)
}
// writeDefault writes defaultContent to path, creating parent dirs if needed.
// Returns true if the file was actually changed (skips write when content already matches).
func writeDefault(path string, content string) (bool, error) {
// 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 {

View file

@ -24,8 +24,8 @@ func setupResetTest(t *testing.T) string {
return tikiDir
}
// writeFile is a test helper that writes content to path.
func writeFile(t *testing.T, path, content string) {
// 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)
@ -36,9 +36,9 @@ func TestResetConfig_GlobalAll(t *testing.T) {
tikiDir := setupResetTest(t)
// seed all three files with custom content
writeFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n")
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n")
writeFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n")
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 {
@ -87,7 +87,7 @@ func TestResetConfig_GlobalSingleTarget(t *testing.T) {
t.Run(string(tt.target), func(t *testing.T) {
tikiDir := setupResetTest(t)
writeFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
writeTestFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
affected, err := ResetConfig(ScopeGlobal, tt.target)
if err != nil {
@ -125,12 +125,12 @@ func TestResetConfig_LocalDeletesFiles(t *testing.T) {
pm.projectRoot = projectDir
// seed project config files
writeFile(t, filepath.Join(docDir, "config.yaml"), "custom\n")
writeFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n")
writeFile(t, filepath.Join(docDir, "new.md"), "custom\n")
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
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
affected, err := ResetConfig(ScopeLocal, TargetAll)
if err != nil {
@ -165,7 +165,7 @@ func TestResetConfig_CurrentDeletesFiles(t *testing.T) {
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(cwdDir)
writeFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
writeTestFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
affected, err := ResetConfig(ScopeCurrent, TargetWorkflow)
if err != nil {
@ -222,7 +222,7 @@ func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
tikiDir := setupResetTest(t)
// write the embedded default content — reset should detect no change
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
if err != nil {
@ -233,6 +233,21 @@ func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
}
}
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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -55,13 +55,17 @@ func NewPluginController(
"plugin", pluginDef.Name, "key", string(a.Rune),
"plugin_action", a.Label, "built_in_action", existing.Label)
}
pc.registry.Register(Action{
action := Action{
ID: pluginActionID(a.Rune),
Key: tcell.KeyRune,
Rune: a.Rune,
Label: a.Label,
ShowInHeader: true,
})
ShowInHeader: a.ShowInHeader,
}
if a.Action != nil && (a.Action.IsUpdate() || a.Action.IsDelete() || a.Action.IsPipe()) {
action.IsEnabled = selectionRequired
}
pc.registry.Register(action)
}
return pc
@ -139,34 +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.IsSelect() && !pa.Action.IsPipe() {
// plain SELECT actions are side-effect only — pass task ID if available but don't require it
if taskID != "" {
input.SelectedTaskID = taskID
}
} else if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() {
if taskID == "" {
return false
return input, false
}
input.SelectedTaskID = taskID
}
@ -174,22 +177,33 @@ 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", taskID, "key", string(r),
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:
@ -228,7 +242,6 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
// non-fatal: continue remaining rows
}
}
case result.Clipboard != nil:
@ -244,10 +257,71 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
}
}
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
slog.Info("plugin action applied", "task_id", input.SelectedTaskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
return true
}
// handlePluginAction applies a plugin shortcut action to the currently selected task.
func (pc *PluginController) handlePluginAction(r rune) bool {
pa, _, ok := pc.getPluginAction(pluginActionID(r))
if !ok {
return false
}
input, ok := pc.buildExecutionInput(pa)
if !ok {
return false
}
return pc.executeAndApply(pa, input, r)
}
// GetActionInputSpec returns the prompt and input type for an action, if it has input.
func (pc *PluginController) GetActionInputSpec(actionID ActionID) (string, ruki.ValueType, bool) {
pa, _, ok := pc.getPluginAction(actionID)
if !ok || !pa.HasInput {
return "", 0, false
}
return pa.Label + ": ", pa.InputType, true
}
// CanStartActionInput checks whether an input-backed action can currently run
// (selection/create-template preflight passes).
func (pc *PluginController) CanStartActionInput(actionID ActionID) (string, ruki.ValueType, bool) {
pa, _, ok := pc.getPluginAction(actionID)
if !ok || !pa.HasInput {
return "", 0, false
}
if _, ok := pc.buildExecutionInput(pa); !ok {
return "", 0, false
}
return pa.Label + ": ", pa.InputType, true
}
// HandleActionInput handles submitted text for an input-backed action.
func (pc *PluginController) HandleActionInput(actionID ActionID, text string) InputSubmitResult {
pa, r, ok := pc.getPluginAction(actionID)
if !ok || !pa.HasInput {
return InputKeepEditing
}
val, err := ruki.ParseScalarValue(pa.InputType, text)
if err != nil {
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return InputKeepEditing
}
input, ok := pc.buildExecutionInput(pa)
if !ok {
return InputClose
}
input.InputValue = val
input.HasInput = true
pc.executeAndApply(pa, input, r)
return InputClose
}
func (pc *PluginController) handleMoveTask(offset int) bool {
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if taskID == "" {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

14
main.go
View file

@ -62,9 +62,9 @@ func main() {
os.Exit(1)
}
// Handle config command
if len(os.Args) > 1 && os.Args[1] == "config" {
os.Exit(runConfig(os.Args[2:]))
// Handle workflow command
if len(os.Args) > 1 && os.Args[1] == "workflow" {
os.Exit(runWorkflow(os.Args[2:]))
}
// Handle exec command: execute ruki statement and exit
@ -88,7 +88,7 @@ func main() {
// Handle viewer mode (standalone markdown viewer)
// "init" is reserved to prevent treating it as a markdown file
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "config": {}})
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "workflow": {}})
if err != nil {
if errors.Is(err, viewer.ErrMultipleInputs) {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
@ -126,10 +126,11 @@ func main() {
defer result.App.Stop()
defer result.HeaderWidget.Cleanup()
defer result.RootLayout.Cleanup()
defer result.ActionPalette.Cleanup()
defer result.CancelFunc()
// Run application
if err := app.Run(result.App, result.RootLayout); err != nil {
if err := app.Run(result.App, result.AppRoot); err != nil {
slog.Error("application error", "error", err)
os.Exit(1)
}
@ -263,7 +264,8 @@ Usage:
tiki Launch TUI in initialized repo
tiki init Initialize project in current git repo
tiki exec '<statement>' Execute a ruki query and exit
tiki config reset [target] Reset config files (--global, --local, --current)
tiki workflow reset [target] Reset config files (--global, --local, --current)
tiki workflow install <name> Install a workflow (--global, --local, --current)
tiki demo Clone demo project and launch TUI
tiki file.md/URL View markdown file or image
echo "Title" | tiki Create task from piped input

View file

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

View file

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

View file

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

View file

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

96
model/view_context.go Normal file
View file

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

View file

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

View file

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

View file

@ -13,7 +13,9 @@ import (
// WorkflowFile represents the YAML structure of a workflow.yaml file
type WorkflowFile struct {
Views viewsSectionConfig `yaml:"views"`
Version string `yaml:"version,omitempty"`
Description string `yaml:"description,omitempty"`
Views viewsSectionConfig `yaml:"views"`
}
// loadPluginsFromFile loads plugins from a single workflow.yaml file.
@ -45,7 +47,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct
return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)}
}
if len(wf.Views.Plugins) == 0 {
if len(wf.Views.Plugins) == 0 && len(wf.Views.Actions) == 0 {
return nil, nil, nil
}
@ -54,6 +56,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct
for i := range wf.Views.Plugins {
totalConverted += transformer.ConvertPluginConfig(&wf.Views.Plugins[i])
}
totalConverted += convertLegacyGlobalActions(transformer, wf.Views.Actions)
if totalConverted > 0 {
slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path)
}
@ -256,6 +259,27 @@ func mergeGlobalActionsIntoPlugins(plugins []Plugin, globalActions []PluginActio
}
}
// convertLegacyGlobalActions converts legacy action expressions in global views.actions
// to ruki format, matching the same conversion applied to per-plugin actions.
func convertLegacyGlobalActions(transformer *LegacyConfigTransformer, actions []PluginActionConfig) int {
count := 0
for i := range actions {
action := &actions[i]
if action.Action != "" && !isRukiAction(action.Action) {
newAction, err := transformer.ConvertAction(action.Action)
if err != nil {
slog.Warn("failed to convert legacy global action, passing through",
"error", err, "action", action.Action, "key", action.Key)
continue
}
slog.Debug("converted legacy global action", "old", action.Action, "new", newAction, "key", action.Key)
action.Action = newAction
count++
}
}
return count
}
// DefaultPlugin returns the first plugin marked as default, or the first plugin
// in the list if none are marked. The caller must ensure plugins is non-empty.
func DefaultPlugin(plugins []Plugin) Plugin {

View file

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

View file

@ -761,3 +761,168 @@ foreground: "#00ff00"
t.Errorf("Expected URL, got %q", dokiPlugin.URL)
}
}
func TestParsePluginActions_HotDefault(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !actions[0].ShowInHeader {
t.Error("absent hot should default to ShowInHeader=true")
}
}
func TestParsePluginActions_HotExplicitFalse(t *testing.T) {
parser := testParser()
hotFalse := false
configs := []PluginActionConfig{
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotFalse},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if actions[0].ShowInHeader {
t.Error("hot: false should set ShowInHeader=false")
}
}
func TestParsePluginActions_HotExplicitTrue(t *testing.T) {
parser := testParser()
hotTrue := true
configs := []PluginActionConfig{
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotTrue},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !actions[0].ShowInHeader {
t.Error("hot: true should set ShowInHeader=true")
}
}
func TestParsePluginYAML_HotFlagFromYAML(t *testing.T) {
yamlData := []byte(`
name: Test
key: T
lanes:
- name: Backlog
filter: select where status = "backlog"
actions:
- key: "b"
label: "Board"
action: update where id = id() set status = "ready"
hot: false
- key: "a"
label: "Assign"
action: update where id = id() set assignee = user()
`)
p, err := parsePluginYAML(yamlData, "test.yaml", testSchema())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tiki, ok := p.(*TikiPlugin)
if !ok {
t.Fatalf("expected TikiPlugin, got %T", p)
}
if tiki.Actions[0].ShowInHeader {
t.Error("action with hot: false should have ShowInHeader=false")
}
if !tiki.Actions[1].ShowInHeader {
t.Error("action without hot should default to ShowInHeader=true")
}
}
func TestParsePluginActions_InputValid(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "string"},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(actions) != 1 {
t.Fatalf("expected 1 action, got %d", len(actions))
}
if !actions[0].HasInput {
t.Error("expected HasInput=true")
}
if actions[0].InputType != ruki.ValueString {
t.Errorf("expected InputType=ValueString, got %d", actions[0].InputType)
}
}
func TestParsePluginActions_InputIntValid(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "p", Label: "Set points", Action: `update where id = id() set points=input()`, Input: "int"},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !actions[0].HasInput {
t.Error("expected HasInput=true")
}
if actions[0].InputType != ruki.ValueInt {
t.Errorf("expected InputType=ValueInt, got %d", actions[0].InputType)
}
}
func TestParsePluginActions_InputTypeMismatch(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "int"},
}
_, err := parsePluginActions(configs, parser)
if err == nil {
t.Fatal("expected error for input type mismatch (int into string field)")
}
}
func TestParsePluginActions_InputWithoutInputFunc(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Ready", Action: `update where id = id() set status="ready"`, Input: "string"},
}
_, err := parsePluginActions(configs, parser)
if err == nil {
t.Fatal("expected error: input: declared but input() not used")
}
if !strings.Contains(err.Error(), "does not use input()") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestParsePluginActions_InputUnsupportedType(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "enum"},
}
_, err := parsePluginActions(configs, parser)
if err == nil {
t.Fatal("expected error for unsupported input type")
}
}
func TestParsePluginActions_NoInputField_NoHasInput(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Ready", Action: `update where id = id() set status="ready"`},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if actions[0].HasInput {
t.Error("expected HasInput=false for action without input: field")
}
}

View file

@ -780,6 +780,8 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
return e.evalNextDate(fc, t, allTasks)
case "blocks":
return e.evalBlocks(fc, t, allTasks)
case "input":
return e.evalInput()
case "call":
return nil, fmt.Errorf("call() is not supported yet")
default:
@ -787,6 +789,13 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
}
}
func (e *Executor) evalInput() (interface{}, error) {
if !e.currentInput.HasInput {
return nil, &MissingInputValueError{}
}
return e.currentInput.InputValue, nil
}
func (e *Executor) evalID() (interface{}, error) {
if e.runtime.Mode != ExecutorRuntimePlugin {
return nil, fmt.Errorf("id() is only available in plugin runtime")

View file

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

275
ruki/input_builtin_test.go Normal file
View file

@ -0,0 +1,275 @@
package ruki
import (
"errors"
"testing"
"github.com/boolean-maybe/tiki/task"
)
func TestInputBuiltin_WithoutDeclaredType_ValidationError(t *testing.T) {
p := newTestParser()
_, err := p.ParseAndValidateStatement("update where id = id() set assignee = input()", ExecutorRuntimePlugin)
if err == nil {
t.Fatal("expected error for input() without declared type")
}
if got := err.Error(); got == "" {
t.Fatal("expected non-empty error message")
}
}
func TestInputBuiltin_StringIntoStringField_OK(t *testing.T) {
p := newTestParser()
vs, err := p.ParseAndValidateStatementWithInput(
"update where id = id() set assignee = input()",
ExecutorRuntimePlugin,
ValueString,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !vs.UsesInputBuiltin() {
t.Fatal("expected UsesInputBuiltin() = true")
}
if !vs.UsesIDBuiltin() {
t.Fatal("expected UsesIDBuiltin() = true")
}
}
func TestInputBuiltin_IntIntoIntField_OK(t *testing.T) {
p := newTestParser()
_, err := p.ParseAndValidateStatementWithInput(
"update where id = id() set priority = input()",
ExecutorRuntimePlugin,
ValueInt,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestInputBuiltin_TypeMismatch_Error(t *testing.T) {
p := newTestParser()
_, err := p.ParseAndValidateStatementWithInput(
"update where id = id() set assignee = input()",
ExecutorRuntimePlugin,
ValueInt,
)
if err == nil {
t.Fatal("expected type mismatch error for int input into string field")
}
}
func TestInputBuiltin_DuplicateInput_Error(t *testing.T) {
p := newTestParser()
_, err := p.ParseAndValidateStatementWithInput(
"update where id = id() set assignee = input(), title = input()",
ExecutorRuntimePlugin,
ValueString,
)
if err == nil {
t.Fatal("expected error for duplicate input()")
}
}
func TestInputBuiltin_WithArguments_Error(t *testing.T) {
p := newTestParser()
_, err := p.ParseAndValidateStatementWithInput(
`update where id = id() set assignee = input("x")`,
ExecutorRuntimePlugin,
ValueString,
)
if err == nil {
t.Fatal("expected error for input() with arguments")
}
}
func TestInputBuiltin_Executor_ReturnsValue(t *testing.T) {
p := newTestParser()
vs, err := p.ParseAndValidateStatementWithInput(
"update where id = id() set assignee = input()",
ExecutorRuntimePlugin,
ValueString,
)
if err != nil {
t.Fatal(err)
}
e := NewExecutor(testSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimePlugin})
testTask := &task.Task{ID: "TIKI-000001", Title: "test", Status: "ready", Type: "task", Priority: 3}
result, err := e.Execute(vs, []*task.Task{testTask}, ExecutionInput{
SelectedTaskID: "TIKI-000001",
InputValue: "bob",
HasInput: true,
})
if err != nil {
t.Fatalf("execution error: %v", err)
}
if result.Update == nil {
t.Fatal("expected update result")
}
if len(result.Update.Updated) == 0 {
t.Fatal("expected at least one updated task")
}
updated := result.Update.Updated[0]
if updated.Assignee != "bob" {
t.Fatalf("expected assignee = bob, got %v", updated.Assignee)
}
}
func TestInputBuiltin_Executor_MissingInput(t *testing.T) {
p := newTestParser()
vs, err := p.ParseAndValidateStatementWithInput(
"update where id = id() set assignee = input()",
ExecutorRuntimePlugin,
ValueString,
)
if err != nil {
t.Fatal(err)
}
e := NewExecutor(testSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimePlugin})
testTask := &task.Task{ID: "TIKI-000001", Title: "test", Status: "ready", Type: "task", Priority: 3}
_, err = e.Execute(vs, []*task.Task{testTask}, ExecutionInput{
SelectedTaskID: "TIKI-000001",
})
if err == nil {
t.Fatal("expected error for missing input")
}
var missingInput *MissingInputValueError
if !errors.As(err, &missingInput) {
t.Fatalf("expected MissingInputValueError, got %T: %v", err, err)
}
}
func TestInputBuiltin_InWhereClause_Detected(t *testing.T) {
p := newTestParser()
vs, err := p.ParseAndValidateStatementWithInput(
`update where assignee = input() set status = "ready"`,
ExecutorRuntimePlugin,
ValueString,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !vs.UsesInputBuiltin() {
t.Fatal("expected UsesInputBuiltin() = true for input() in where clause")
}
}
func TestInputBuiltin_DuplicateAcrossWhereAndSet(t *testing.T) {
p := newTestParser()
_, err := p.ParseAndValidateStatementWithInput(
"update where assignee = input() set title = input()",
ExecutorRuntimePlugin,
ValueString,
)
if err == nil {
t.Fatal("expected error for duplicate input() across where and set")
}
}
func TestInputBuiltin_InSelectWhere_Detected(t *testing.T) {
p := newTestParser()
vs, err := p.ParseAndValidateStatementWithInput(
`select where assignee = input()`,
ExecutorRuntimePlugin,
ValueString,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !vs.UsesInputBuiltin() {
t.Fatal("expected UsesInputBuiltin() = true for input() in select where")
}
}
func TestInputBuiltin_InDeleteWhere_Detected(t *testing.T) {
p := newTestParser()
vs, err := p.ParseAndValidateStatementWithInput(
`delete where assignee = input()`,
ExecutorRuntimePlugin,
ValueString,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !vs.UsesInputBuiltin() {
t.Fatal("expected UsesInputBuiltin() = true for input() in delete where")
}
}
func TestInputBuiltin_InSubquery_Detected(t *testing.T) {
p := newTestParser()
vs, err := p.ParseAndValidateStatementWithInput(
`select where count(select where assignee = input()) >= 1`,
ExecutorRuntimePlugin,
ValueString,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !vs.UsesInputBuiltin() {
t.Fatal("expected UsesInputBuiltin() = true for input() inside subquery")
}
}
func TestInputBuiltin_DuplicateAcrossWhereAndSubquery(t *testing.T) {
p := newTestParser()
_, err := p.ParseAndValidateStatementWithInput(
`select where assignee = input() and count(select where assignee = input()) >= 1`,
ExecutorRuntimePlugin,
ValueString,
)
if err == nil {
t.Fatal("expected error for duplicate input() across where and subquery")
}
}
func TestInputBuiltin_InPipeCommand_Detected(t *testing.T) {
p := newTestParser()
vs, err := p.ParseAndValidateStatementWithInput(
`select id where id = id() | run(input())`,
ExecutorRuntimePlugin,
ValueString,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !vs.UsesInputBuiltin() {
t.Fatal("expected UsesInputBuiltin() = true for input() in pipe command")
}
}
func TestInputBuiltin_DuplicateAcrossWhereAndPipe(t *testing.T) {
p := newTestParser()
_, err := p.ParseAndValidateStatementWithInput(
`select id where assignee = input() | run(input())`,
ExecutorRuntimePlugin,
ValueString,
)
if err == nil {
t.Fatal("expected error for duplicate input() across where and pipe")
}
}
func TestInputBuiltin_InputTypeNotLeaked(t *testing.T) {
p := newTestParser()
_, err := p.ParseAndValidateStatementWithInput(
"update where id = id() set assignee = input()",
ExecutorRuntimePlugin,
ValueString,
)
if err != nil {
t.Fatal(err)
}
// after the call, inputType should be cleared — next parse without input should fail
_, err = p.ParseAndValidateStatement("update where id = id() set assignee = input()", ExecutorRuntimePlugin)
if err == nil {
t.Fatal("expected error: inputType should not leak across calls")
}
}

View file

@ -4,9 +4,6 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/boolean-maybe/tiki/util/duration"
)
// lower.go converts participle grammar structs into clean AST types.
@ -215,7 +212,7 @@ func lowerRule(g *ruleGrammar) (*Rule, error) {
// --- time trigger lowering ---
func lowerTimeTrigger(g *timeTriggerGrammar) (*TimeTrigger, error) {
val, unit, err := duration.Parse(g.Interval)
val, unit, err := ParseDurationString(g.Interval)
if err != nil {
return nil, fmt.Errorf("invalid interval: %w", err)
}
@ -481,7 +478,7 @@ func unquoteString(s string) string {
}
func parseDateLiteral(s string) (Expr, error) {
t, err := time.Parse("2006-01-02", s)
t, err := ParseDateString(s)
if err != nil {
return nil, fmt.Errorf("invalid date literal %q: %w", s, err)
}
@ -489,7 +486,7 @@ func parseDateLiteral(s string) (Expr, error) {
}
func parseDurationLiteral(s string) (Expr, error) {
val, unit, err := duration.Parse(s)
val, unit, err := ParseDurationString(s)
if err != nil {
return nil, err
}

102
ruki/parse_scalar.go Normal file
View file

@ -0,0 +1,102 @@
package ruki
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/boolean-maybe/tiki/util/duration"
)
const dateFormat = "2006-01-02"
// ParseDateString parses a YYYY-MM-DD date string into a time.Time.
// Shared by DSL literal parsing (lower.go) and user input parsing.
func ParseDateString(s string) (time.Time, error) {
return time.Parse(dateFormat, s)
}
// ParseDurationString parses a duration string like "2day" into its (value, unit) components.
// Shared by DSL literal parsing (lower.go) and user input parsing.
func ParseDurationString(s string) (int, string, error) {
return duration.Parse(s)
}
// ParseScalarTypeName maps a canonical scalar type name to a ValueType.
// Only the 6 user-inputtable scalar types are accepted.
func ParseScalarTypeName(name string) (ValueType, error) {
switch strings.ToLower(name) {
case "string":
return ValueString, nil
case "int":
return ValueInt, nil
case "bool":
return ValueBool, nil
case "date":
return ValueDate, nil
case "timestamp":
return ValueTimestamp, nil
case "duration":
return ValueDuration, nil
default:
return 0, fmt.Errorf("unsupported input type %q (supported: string, int, bool, date, timestamp, duration)", name)
}
}
// ParseScalarValue parses user-supplied text into a native runtime value
// matching what the ruki executor produces for the given type.
func ParseScalarValue(typ ValueType, text string) (interface{}, error) {
switch typ {
case ValueString:
return text, nil
case ValueInt:
n, err := strconv.Atoi(strings.TrimSpace(text))
if err != nil {
return nil, fmt.Errorf("expected integer, got %q", text)
}
return n, nil
case ValueBool:
b, err := parseBoolString(strings.TrimSpace(text))
if err != nil {
return nil, fmt.Errorf("expected true or false, got %q", text)
}
return b, nil
case ValueDate:
t, err := ParseDateString(strings.TrimSpace(text))
if err != nil {
return nil, fmt.Errorf("expected date (YYYY-MM-DD), got %q", text)
}
return t, nil
case ValueTimestamp:
trimmed := strings.TrimSpace(text)
t, err := time.Parse(time.RFC3339, trimmed)
if err == nil {
return t, nil
}
t, err = ParseDateString(trimmed)
if err != nil {
return nil, fmt.Errorf("expected timestamp (RFC3339 or YYYY-MM-DD), got %q", text)
}
return t, nil
case ValueDuration:
trimmed := strings.TrimSpace(text)
val, unit, err := ParseDurationString(trimmed)
if err != nil {
return nil, fmt.Errorf("expected duration (e.g. 2day, 1week), got %q", text)
}
d, err := duration.ToDuration(val, unit)
if err != nil {
return nil, err
}
return d, nil
default:
return nil, fmt.Errorf("type %s is not a supported input scalar", typeName(typ))
}
}

180
ruki/parse_scalar_test.go Normal file
View file

@ -0,0 +1,180 @@
package ruki
import (
"testing"
"time"
)
func TestParseScalarTypeName(t *testing.T) {
tests := []struct {
input string
want ValueType
wantErr bool
}{
{"string", ValueString, false},
{"int", ValueInt, false},
{"bool", ValueBool, false},
{"date", ValueDate, false},
{"timestamp", ValueTimestamp, false},
{"duration", ValueDuration, false},
{"String", ValueString, false},
{"INT", ValueInt, false},
{"enum", 0, true},
{"list", 0, true},
{"", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseScalarTypeName(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error for %q", tt.input)
}
return
}
if err != nil {
t.Fatalf("unexpected error for %q: %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("ParseScalarTypeName(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestParseScalarValue_String(t *testing.T) {
val, err := ParseScalarValue(ValueString, "hello world")
if err != nil {
t.Fatal(err)
}
if val != "hello world" {
t.Fatalf("got %v, want %q", val, "hello world")
}
}
func TestParseScalarValue_Int(t *testing.T) {
val, err := ParseScalarValue(ValueInt, "42")
if err != nil {
t.Fatal(err)
}
if val != 42 {
t.Fatalf("got %v, want 42", val)
}
val, err = ParseScalarValue(ValueInt, " 7 ")
if err != nil {
t.Fatal(err)
}
if val != 7 {
t.Fatalf("got %v, want 7", val)
}
_, err = ParseScalarValue(ValueInt, "abc")
if err == nil {
t.Fatal("expected error for non-integer")
}
}
func TestParseScalarValue_Bool(t *testing.T) {
val, err := ParseScalarValue(ValueBool, "true")
if err != nil {
t.Fatal(err)
}
if val != true {
t.Fatalf("got %v, want true", val)
}
val, err = ParseScalarValue(ValueBool, "False")
if err != nil {
t.Fatal(err)
}
if val != false {
t.Fatalf("got %v, want false", val)
}
_, err = ParseScalarValue(ValueBool, "yes")
if err == nil {
t.Fatal("expected error for non-bool string")
}
}
func TestParseScalarValue_Date(t *testing.T) {
val, err := ParseScalarValue(ValueDate, "2025-03-15")
if err != nil {
t.Fatal(err)
}
tv, ok := val.(time.Time)
if !ok {
t.Fatalf("expected time.Time, got %T", val)
}
if tv.Year() != 2025 || tv.Month() != 3 || tv.Day() != 15 {
t.Fatalf("got %v", tv)
}
_, err = ParseScalarValue(ValueDate, "not-a-date")
if err == nil {
t.Fatal("expected error for invalid date")
}
}
func TestParseScalarValue_Timestamp_RFC3339(t *testing.T) {
val, err := ParseScalarValue(ValueTimestamp, "2025-03-15T10:30:00Z")
if err != nil {
t.Fatal(err)
}
tv, ok := val.(time.Time)
if !ok {
t.Fatalf("expected time.Time, got %T", val)
}
if tv.Hour() != 10 || tv.Minute() != 30 {
t.Fatalf("got %v", tv)
}
}
func TestParseScalarValue_Timestamp_DateFallback(t *testing.T) {
val, err := ParseScalarValue(ValueTimestamp, "2025-03-15")
if err != nil {
t.Fatal(err)
}
tv, ok := val.(time.Time)
if !ok {
t.Fatalf("expected time.Time, got %T", val)
}
if tv.Year() != 2025 || tv.Month() != 3 || tv.Day() != 15 {
t.Fatalf("got %v", tv)
}
}
func TestParseScalarValue_Timestamp_Invalid(t *testing.T) {
_, err := ParseScalarValue(ValueTimestamp, "yesterday")
if err == nil {
t.Fatal("expected error for invalid timestamp")
}
}
func TestParseScalarValue_Duration(t *testing.T) {
val, err := ParseScalarValue(ValueDuration, "2day")
if err != nil {
t.Fatal(err)
}
d, ok := val.(time.Duration)
if !ok {
t.Fatalf("expected time.Duration, got %T", val)
}
if d != 2*24*time.Hour {
t.Fatalf("got %v, want %v", d, 2*24*time.Hour)
}
_, err = ParseScalarValue(ValueDuration, "abc")
if err == nil {
t.Fatal("expected error for invalid duration")
}
}
func TestParseScalarValue_UnsupportedType(t *testing.T) {
_, err := ParseScalarValue(ValueEnum, "test")
if err == nil {
t.Fatal("expected error for unsupported type")
}
}

View file

@ -55,6 +55,7 @@ type Parser struct {
schema Schema
qualifiers qualifierPolicy // set before each validation pass
requireQualifiers bool // when true, bare FieldRef is a parse error (trigger where-guards)
inputType *ValueType // set per-call for input() type inference; nil when not available
}
// NewParser constructs a Parser with the given schema for validation.

View file

@ -22,14 +22,16 @@ func (e *UnvalidatedWrapperError) Error() string {
// ValidatedStatement is an immutable, semantically validated statement wrapper.
type ValidatedStatement struct {
seal *validationSeal
runtime ExecutorRuntimeMode
usesIDFunc bool
statement *Statement
seal *validationSeal
runtime ExecutorRuntimeMode
usesIDFunc bool
usesInputFunc bool
statement *Statement
}
func (v *ValidatedStatement) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
func (v *ValidatedStatement) UsesIDBuiltin() bool { return v.usesIDFunc }
func (v *ValidatedStatement) UsesInputBuiltin() bool { return v.usesInputFunc }
func (v *ValidatedStatement) RequiresCreateTemplate() bool {
return v != nil && v.statement != nil && v.statement.Create != nil
}
@ -199,6 +201,21 @@ func (p *Parser) ParseAndValidateStatement(input string, runtime ExecutorRuntime
return NewSemanticValidator(runtime).ValidateStatement(stmt)
}
// ParseAndValidateStatementWithInput parses a statement with an input() type
// declaration and applies runtime-aware semantic validation. The inputType is
// set on the parser for the duration of the parse so that inferExprType can
// resolve input() calls.
func (p *Parser) ParseAndValidateStatementWithInput(input string, runtime ExecutorRuntimeMode, inputType ValueType) (*ValidatedStatement, error) {
p.inputType = &inputType
defer func() { p.inputType = nil }()
stmt, err := p.ParseStatement(input)
if err != nil {
return nil, err
}
return NewSemanticValidator(runtime).ValidateStatement(stmt)
}
// ParseAndValidateTrigger parses an event trigger and applies runtime-aware semantic validation.
func (p *Parser) ParseAndValidateTrigger(input string, runtime ExecutorRuntimeMode) (*ValidatedTrigger, error) {
trig, err := p.ParseTrigger(input)
@ -259,14 +276,22 @@ func (v *SemanticValidator) ValidateStatement(stmt *Statement) (*ValidatedStatem
if usesID && v.runtime != ExecutorRuntimePlugin {
return nil, fmt.Errorf("id() is only available in plugin runtime")
}
inputCount, err := countInputUsage(stmt)
if err != nil {
return nil, err
}
if inputCount > 1 {
return nil, fmt.Errorf("input() may only be used once per action")
}
if err := validateStatementAssignmentsSemantics(stmt); err != nil {
return nil, err
}
return &ValidatedStatement{
seal: validatedSeal,
runtime: v.runtime,
usesIDFunc: usesID,
statement: cloneStatement(stmt),
seal: validatedSeal,
runtime: v.runtime,
usesIDFunc: usesID,
usesInputFunc: inputCount == 1,
statement: cloneStatement(stmt),
}, nil
}
@ -503,54 +528,199 @@ func scanConditionSemantics(cond Condition) (usesID bool, hasCall bool, err erro
}
}
type semanticFlags struct {
usesID bool
hasCall bool
inputCount int
}
func (f *semanticFlags) merge(other semanticFlags) {
f.usesID = f.usesID || other.usesID
f.hasCall = f.hasCall || other.hasCall
f.inputCount += other.inputCount
}
func scanExprSemantics(expr Expr) (usesID bool, hasCall bool, err error) {
flags, err := scanExprSemanticsEx(expr)
if err != nil {
return false, false, err
}
return flags.usesID, flags.hasCall, nil
}
func scanExprSemanticsEx(expr Expr) (semanticFlags, error) {
var f semanticFlags
if expr == nil {
return false, false, nil
return f, nil
}
switch e := expr.(type) {
case *FunctionCall:
if e.Name == "id" {
usesID = true
f.usesID = true
}
if e.Name == "call" {
hasCall = true
f.hasCall = true
}
if e.Name == "input" {
f.inputCount++
}
for _, arg := range e.Args {
u, c, err := scanExprSemantics(arg)
af, err := scanExprSemanticsEx(arg)
if err != nil {
return false, false, err
return f, err
}
usesID = usesID || u
hasCall = hasCall || c
f.merge(af)
}
return usesID, hasCall, nil
return f, nil
case *BinaryExpr:
u1, c1, err := scanExprSemantics(e.Left)
lf, err := scanExprSemanticsEx(e.Left)
if err != nil {
return false, false, err
return f, err
}
u2, c2, err := scanExprSemantics(e.Right)
rf, err := scanExprSemanticsEx(e.Right)
if err != nil {
return false, false, err
return f, err
}
return u1 || u2, c1 || c2, nil
f.merge(lf)
f.merge(rf)
return f, nil
case *ListLiteral:
for _, elem := range e.Elements {
u, c, err := scanExprSemantics(elem)
ef, err := scanExprSemanticsEx(elem)
if err != nil {
return false, false, err
return f, err
}
usesID = usesID || u
hasCall = hasCall || c
f.merge(ef)
}
return usesID, hasCall, nil
return f, nil
case *SubQuery:
return scanConditionSemantics(e.Where)
sf, err := scanConditionSemanticsEx(e.Where)
if err != nil {
return f, err
}
f.merge(sf)
return f, nil
default:
return false, false, nil
return f, nil
}
}
func scanConditionSemanticsEx(cond Condition) (semanticFlags, error) {
var f semanticFlags
if cond == nil {
return f, nil
}
switch c := cond.(type) {
case *BinaryCondition:
lf, err := scanConditionSemanticsEx(c.Left)
if err != nil {
return f, err
}
rf, err := scanConditionSemanticsEx(c.Right)
if err != nil {
return f, err
}
f.merge(lf)
f.merge(rf)
return f, nil
case *NotCondition:
return scanConditionSemanticsEx(c.Inner)
case *CompareExpr:
lf, err := scanExprSemanticsEx(c.Left)
if err != nil {
return f, err
}
rf, err := scanExprSemanticsEx(c.Right)
if err != nil {
return f, err
}
f.merge(lf)
f.merge(rf)
return f, nil
case *IsEmptyExpr:
return scanExprSemanticsEx(c.Expr)
case *InExpr:
vf, err := scanExprSemanticsEx(c.Value)
if err != nil {
return f, err
}
cf, err := scanExprSemanticsEx(c.Collection)
if err != nil {
return f, err
}
f.merge(vf)
f.merge(cf)
return f, nil
case *QuantifierExpr:
ef, err := scanExprSemanticsEx(c.Expr)
if err != nil {
return f, err
}
cf, err := scanConditionSemanticsEx(c.Condition)
if err != nil {
return f, err
}
f.merge(ef)
f.merge(cf)
return f, nil
default:
return f, fmt.Errorf("unknown condition type %T", c)
}
}
func countInputUsage(stmt *Statement) (int, error) {
var total semanticFlags
switch {
case stmt.Select != nil:
if stmt.Select.Where != nil {
f, err := scanConditionSemanticsEx(stmt.Select.Where)
if err != nil {
return 0, err
}
total.merge(f)
}
if stmt.Select.Pipe != nil && stmt.Select.Pipe.Run != nil {
f, err := scanExprSemanticsEx(stmt.Select.Pipe.Run.Command)
if err != nil {
return 0, err
}
total.merge(f)
}
case stmt.Create != nil:
for _, a := range stmt.Create.Assignments {
f, err := scanExprSemanticsEx(a.Value)
if err != nil {
return 0, err
}
total.merge(f)
}
case stmt.Update != nil:
if stmt.Update.Where != nil {
f, err := scanConditionSemanticsEx(stmt.Update.Where)
if err != nil {
return 0, err
}
total.merge(f)
}
for _, a := range stmt.Update.Set {
f, err := scanExprSemanticsEx(a.Value)
if err != nil {
return 0, err
}
total.merge(f)
}
case stmt.Delete != nil:
if stmt.Delete.Where != nil {
f, err := scanConditionSemanticsEx(stmt.Delete.Where)
if err != nil {
return 0, err
}
total.merge(f)
}
}
return total.inputCount, nil
}
func cloneStatement(stmt *Statement) *Statement {
if stmt == nil {
return nil

View file

@ -515,6 +515,16 @@ func (p *Parser) inferListElementType(e Expr) (ValueType, error) {
}
func (p *Parser) inferFuncCallType(fc *FunctionCall) (ValueType, error) {
if fc.Name == "input" {
if len(fc.Args) != 0 {
return 0, fmt.Errorf("input() takes no arguments, got %d", len(fc.Args))
}
if p.inputType == nil {
return 0, fmt.Errorf("input() requires 'input:' declaration on action")
}
return *p.inputType, nil
}
builtin, ok := builtinFuncs[fc.Name]
if !ok {
return 0, fmt.Errorf("unknown function %q", fc.Name)

View file

@ -121,10 +121,12 @@ func (s *TikiStore) loadTaskFile(path string, authorMap map[string]*git.AuthorIn
return nil, err
}
// validate type strictly — missing or unknown types are load errors
taskType, typeOK := taskpkg.ParseType(fm.Type)
if !typeOK {
return nil, fmt.Errorf("invalid or missing type %q", fm.Type)
if fm.Type != "" {
return nil, fmt.Errorf("unknown type %q", fm.Type)
}
taskType = taskpkg.DefaultType()
}
task := &taskpkg.Task{

View file

@ -16,6 +16,7 @@ import (
taskpkg "github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/view"
"github.com/boolean-maybe/tiki/view/header"
"github.com/boolean-maybe/tiki/view/palette"
"github.com/boolean-maybe/tiki/view/statusline"
"github.com/gdamore/tcell/v2"
@ -41,7 +42,11 @@ type TestApp struct {
taskController *controller.TaskController
statuslineConfig *model.StatuslineConfig
headerConfig *model.HeaderConfig
viewContext *model.ViewContext
layoutModel *model.LayoutModel
paletteConfig *model.ActionPaletteConfig
actionPalette *palette.ActionPalette
pages *tview.Pages
}
// NewTestApp bootstraps the full MVC stack for integration testing.
@ -113,11 +118,13 @@ func NewTestApp(t *testing.T) *TestApp {
viewFactory := view.NewViewFactory(taskStore)
// 7. Create header widget, statusline, and RootLayout
headerWidget := header.NewHeaderWidget(headerConfig)
viewContext := model.NewViewContext()
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
Header: headerWidget,
HeaderConfig: headerConfig,
ViewContext: viewContext,
LayoutModel: layoutModel,
ViewFactory: viewFactory,
TaskStore: taskStore,
@ -126,9 +133,7 @@ func NewTestApp(t *testing.T) *TestApp {
StatuslineWidget: statuslineWidget,
})
// Mirror main.go wiring: provide views a focus setter as they become active.
rootLayout.SetOnViewActivated(func(v controller.View) {
// generic focus settable check (covers TaskEditView and any other view with focus needs)
if focusSettable, ok := v.(controller.FocusSettable); ok {
focusSettable.SetFocusSetter(func(p tview.Primitive) {
app.SetFocus(p)
@ -136,8 +141,6 @@ func NewTestApp(t *testing.T) *TestApp {
}
})
// IMPORTANT: Retroactively wire focus setter for any view already active
// (RootLayout may have activated a view during construction before callback was set)
currentView := rootLayout.GetContentView()
if currentView != nil {
if focusSettable, ok := currentView.(controller.FocusSettable); ok {
@ -147,23 +150,59 @@ func NewTestApp(t *testing.T) *TestApp {
}
}
// 7.5 Action palette
paletteConfig := model.NewActionPaletteConfig()
inputRouter.SetHeaderConfig(headerConfig)
inputRouter.SetPaletteConfig(paletteConfig)
actionPalette := palette.NewActionPalette(viewContext, paletteConfig, inputRouter, navController)
actionPalette.SetChangedFunc()
// Build Pages root
pages := tview.NewPages()
pages.AddPage("base", rootLayout.GetPrimitive(), true, true)
paletteBox := tview.NewFlex()
paletteBox.AddItem(tview.NewBox(), 0, 1, false)
paletteBox.AddItem(actionPalette.GetPrimitive(), palette.PaletteMinWidth, 0, true)
pages.AddPage("palette", paletteBox, true, false)
var previousFocus tview.Primitive
paletteConfig.AddListener(func() {
if paletteConfig.IsVisible() {
previousFocus = app.GetFocus()
actionPalette.OnShow()
pages.ShowPage("palette")
app.SetFocus(actionPalette.GetFilterInput())
} else {
pages.HidePage("palette")
if previousFocus != nil {
app.SetFocus(previousFocus)
} else if cv := rootLayout.GetContentView(); cv != nil {
app.SetFocus(cv.GetPrimitive())
}
previousFocus = nil
}
})
// 8. Wire up callbacks
navController.SetOnViewChanged(func(viewID model.ViewID, params map[string]interface{}) {
layoutModel.SetContent(viewID, params)
})
navController.SetActiveViewGetter(rootLayout.GetContentView)
// 9. Set up global input capture
// 9. Set up global input capture (matches production)
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
handled := inputRouter.HandleInput(event, navController.CurrentView())
if handled {
return nil // consume event
if paletteConfig.IsVisible() {
return event
}
return event // pass through
statuslineConfig.DismissAutoHide()
if inputRouter.HandleInput(event, navController.CurrentView()) {
return nil
}
return event
})
// 10. Set root layout
app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false)
// 10. Set root (Pages)
app.SetRoot(pages, true).EnableMouse(false)
// Note: Do NOT call app.Run() - we use app.Draw() + screen.Show() for synchronous testing
@ -181,7 +220,11 @@ func NewTestApp(t *testing.T) *TestApp {
taskController: taskController,
statuslineConfig: statuslineConfig,
headerConfig: headerConfig,
viewContext: viewContext,
layoutModel: layoutModel,
paletteConfig: paletteConfig,
actionPalette: actionPalette,
pages: pages,
}
// 11. Auto-load plugins since all views are now plugins
@ -194,10 +237,9 @@ func NewTestApp(t *testing.T) *TestApp {
// Draw forces a synchronous draw without running the app event loop
func (ta *TestApp) Draw() {
// Get screen dimensions and set the root layout's rect
_, width, height := ta.Screen.GetContents()
ta.RootLayout.GetPrimitive().SetRect(0, 0, width, height)
ta.RootLayout.GetPrimitive().Draw(ta.Screen)
ta.pages.SetRect(0, 0, width, height)
ta.pages.Draw(ta.Screen)
ta.Screen.Show()
}
@ -313,9 +355,9 @@ func (ta *TestApp) DraftTask() *taskpkg.Task {
// Cleanup tears down the test app and releases resources
func (ta *TestApp) Cleanup() {
ta.actionPalette.Cleanup()
ta.RootLayout.Cleanup()
ta.Screen.Fini()
// TaskDir cleanup handled automatically by t.TempDir()
}
// LoadPlugins loads plugins from workflow.yaml files and wires them into the test app.
@ -383,44 +425,19 @@ func (ta *TestApp) LoadPlugins() error {
ta.statuslineConfig,
ta.Schema,
)
ta.InputRouter.SetHeaderConfig(ta.headerConfig)
ta.InputRouter.SetPaletteConfig(ta.paletteConfig)
// Update global input capture to handle plugin switching keys
// Update global input capture (matches production pipeline)
ta.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
// Check if search box has focus - if so, let it handle ALL input
if activeView := ta.NavController.GetActiveView(); activeView != nil {
if searchableView, ok := activeView.(controller.SearchableView); ok {
if searchableView.IsSearchBoxFocused() {
return event
}
}
if ta.paletteConfig.IsVisible() {
return event
}
currentView := ta.NavController.CurrentView()
if currentView != nil {
// Handle plugin switching between plugins
if model.IsPluginViewID(currentView.ViewID) {
if action := controller.GetPluginActions().Match(event); action != nil {
pluginName := controller.GetPluginNameFromAction(action.ID)
if pluginName != "" {
targetPluginID := model.MakePluginViewID(pluginName)
// Don't switch to the same plugin we're already viewing
if currentView.ViewID == targetPluginID {
return nil // no-op
}
// Replace current plugin with target plugin
ta.NavController.ReplaceView(targetPluginID, nil)
return nil
}
}
}
ta.statuslineConfig.DismissAutoHide()
if ta.InputRouter.HandleInput(event, ta.NavController.CurrentView()) {
return nil
}
// Let InputRouter handle the rest
handled := ta.InputRouter.HandleInput(event, ta.NavController.CurrentView())
if handled {
return nil // consume event
}
return event // pass through
return event
})
// Update ViewFactory with plugins
@ -441,13 +458,14 @@ func (ta *TestApp) LoadPlugins() error {
})
// Recreate RootLayout with new view factory
headerWidget := header.NewHeaderWidget(ta.headerConfig)
headerWidget := header.NewHeaderWidget(ta.headerConfig, ta.viewContext)
ta.RootLayout.Cleanup()
slConfig := model.NewStatuslineConfig()
slWidget := statusline.NewStatuslineWidget(slConfig)
ta.RootLayout = view.NewRootLayout(view.RootLayoutOpts{
Header: headerWidget,
HeaderConfig: ta.headerConfig,
ViewContext: ta.viewContext,
LayoutModel: ta.layoutModel,
ViewFactory: viewFactory,
TaskStore: ta.TaskStore,
@ -477,12 +495,35 @@ func (ta *TestApp) LoadPlugins() error {
}
}
// Set new root
ta.App.SetRoot(ta.RootLayout.GetPrimitive(), true)
// Update palette with new view context
ta.actionPalette.Cleanup()
ta.actionPalette = palette.NewActionPalette(ta.viewContext, ta.paletteConfig, ta.InputRouter, ta.NavController)
ta.actionPalette.SetChangedFunc()
// Rebuild Pages
ta.pages.RemovePage("palette")
ta.pages.RemovePage("base")
ta.pages.AddPage("base", ta.RootLayout.GetPrimitive(), true, true)
paletteBox := tview.NewFlex()
paletteBox.AddItem(tview.NewBox(), 0, 1, false)
paletteBox.AddItem(ta.actionPalette.GetPrimitive(), palette.PaletteMinWidth, 0, true)
ta.pages.AddPage("palette", paletteBox, true, false)
ta.App.SetRoot(ta.pages, true)
return nil
}
// GetHeaderConfig returns the header config for testing visibility assertions.
func (ta *TestApp) GetHeaderConfig() *model.HeaderConfig {
return ta.headerConfig
}
// GetPaletteConfig returns the palette config for testing visibility assertions.
func (ta *TestApp) GetPaletteConfig() *model.ActionPaletteConfig {
return ta.paletteConfig
}
// GetPluginConfig retrieves the PluginConfig for a given plugin name.
// Returns nil if the plugin is not loaded.
func (ta *TestApp) GetPluginConfig(pluginName string) *model.PluginConfig {

View file

@ -1,56 +0,0 @@
package view
import (
"github.com/boolean-maybe/tiki/config"
"github.com/gdamore/tcell/v2"
)
// Single-line box drawing characters
const (
BorderHorizontal = '─'
BorderVertical = '│'
BorderTopLeft = '┌'
BorderTopRight = '┐'
BorderBottomLeft = '└'
BorderBottomRight = '┘'
)
// DrawSingleLineBorder draws a single-line border around the given rectangle
// using the TaskBoxUnselectedBorder color from config.
// This is useful for primitives that should not use tview's double-line focus borders.
func DrawSingleLineBorder(screen tcell.Screen, x, y, width, height int) {
if width <= 0 || height <= 0 {
return
}
colors := config.GetColors()
style := tcell.StyleDefault.Foreground(colors.TaskBoxUnselectedBorder.TCell()).Background(colors.ContentBackgroundColor.TCell())
DrawSingleLineBorderWithStyle(screen, x, y, width, height, style)
}
// DrawSingleLineBorderWithStyle draws a single-line border with a custom style
func DrawSingleLineBorderWithStyle(screen tcell.Screen, x, y, width, height int, style tcell.Style) {
if width <= 0 || height <= 0 {
return
}
// Draw horizontal lines
for i := x + 1; i < x+width-1; i++ {
screen.SetContent(i, y, BorderHorizontal, nil, style)
screen.SetContent(i, y+height-1, BorderHorizontal, nil, style)
}
// Draw vertical lines
for i := y + 1; i < y+height-1; i++ {
screen.SetContent(x, i, BorderVertical, nil, style)
screen.SetContent(x+width-1, i, BorderVertical, nil, style)
}
// Draw corners
screen.SetContent(x, y, BorderTopLeft, nil, style)
screen.SetContent(x+width-1, y, BorderTopRight, nil, style)
screen.SetContent(x, y+height-1, BorderBottomLeft, nil, style)
screen.SetContent(x+width-1, y+height-1, BorderBottomRight, nil, style)
}

View file

@ -1,7 +1,6 @@
package view
import (
_ "embed"
"fmt"
"log/slog"
@ -18,24 +17,16 @@ import (
"github.com/rivo/tview"
)
//go:embed help/help.md
var helpMd string
//go:embed help/tiki.md
var tikiMd string
//go:embed help/custom.md
var customMd string
// DokiView renders a documentation plugin (navigable markdown)
type DokiView struct {
root *tview.Flex
titleBar tview.Primitive
md *markdown.NavigableMarkdown
pluginDef *plugin.DokiPlugin
registry *controller.ActionRegistry
imageManager *navtview.ImageManager
mermaidOpts *nav.MermaidOptions
root *tview.Flex
titleBar tview.Primitive
md *markdown.NavigableMarkdown
pluginDef *plugin.DokiPlugin
registry *controller.ActionRegistry
imageManager *navtview.ImageManager
mermaidOpts *nav.MermaidOptions
actionChangeHandler func()
}
// NewDokiView creates a doki view
@ -93,13 +84,15 @@ func (dv *DokiView) build() {
MermaidOptions: dv.mermaidOpts,
})
// fetcher: internal is intentionally preserved as a supported pattern for
// code-only internal docs, even though no default workflow currently uses it.
//
// The default Help plugin previously used this with embedded markdown:
// cnt := map[string]string{"Help": helpMd, "tiki.md": tikiMd, "view.md": customMd}
// provider := &internalDokiProvider{content: cnt}
// That usage was replaced by the action palette (press Ctrl+A to open).
case "internal":
cnt := map[string]string{
"Help": helpMd,
"tiki.md": tikiMd,
"view.md": customMd,
}
provider := &internalDokiProvider{content: cnt}
provider := &internalDokiProvider{content: map[string]string{}}
content, err = provider.FetchContent(nav.NavElement{Text: dv.pluginDef.Text})
dv.md = markdown.NewNavigableMarkdown(markdown.NavigableMarkdownConfig{
@ -172,6 +165,10 @@ func (dv *DokiView) OnBlur() {
}
}
func (dv *DokiView) SetActionChangeHandler(handler func()) {
dv.actionChangeHandler = handler
}
// UpdateNavigationActions updates the registry to reflect current navigation state
func (dv *DokiView) UpdateNavigationActions() {
// Clear and rebuild the registry
@ -212,6 +209,37 @@ func (dv *DokiView) UpdateNavigationActions() {
ShowInHeader: true,
})
}
if dv.actionChangeHandler != nil {
dv.actionChangeHandler()
}
}
// HandlePaletteAction maps palette-dispatched actions to the markdown viewer's
// existing key-driven behavior by replaying synthetic key events.
func (dv *DokiView) HandlePaletteAction(id controller.ActionID) bool {
if dv.md == nil {
return false
}
var event *tcell.EventKey
switch id {
case "navigate_next_link":
event = tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)
case "navigate_prev_link":
event = tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone)
case controller.ActionNavigateBack:
event = tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)
case controller.ActionNavigateForward:
event = tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)
default:
return false
}
handler := dv.md.Viewer().InputHandler()
if handler != nil {
handler(event, nil)
return true
}
return false
}
// internalDokiProvider implements navidown.ContentProvider for embedded/internal docs.

View file

@ -55,20 +55,23 @@ type HeaderWidget struct {
rightSpacer *tview.Box
logo *tview.TextView
// Model reference
headerConfig *model.HeaderConfig
listenerID int
// Model references
headerConfig *model.HeaderConfig
viewContext *model.ViewContext
headerListenerID int
viewContextListenerID int
// Layout state
lastWidth int
chartVisible bool
}
// NewHeaderWidget creates a header widget that observes HeaderConfig for all state
func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget {
// NewHeaderWidget creates a header widget that observes HeaderConfig (burndown/visibility)
// and ViewContext (view info + actions) for state.
func NewHeaderWidget(headerConfig *model.HeaderConfig, viewContext *model.ViewContext) *HeaderWidget {
info := NewInfoWidget()
contextHelp := NewContextHelpWidget()
chart := NewChartWidgetSimple() // No store dependency, data comes from HeaderConfig
chart := NewChartWidgetSimple()
logo := tview.NewTextView()
logo.SetDynamicColors(true)
@ -87,29 +90,27 @@ func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget {
chart: chart,
rightSpacer: tview.NewBox(),
headerConfig: headerConfig,
viewContext: viewContext,
}
// Subscribe to header config changes
hw.listenerID = headerConfig.AddListener(hw.rebuild)
hw.headerListenerID = headerConfig.AddListener(hw.rebuild)
if viewContext != nil {
hw.viewContextListenerID = viewContext.AddListener(hw.rebuild)
}
hw.rebuild()
hw.rebuildLayout(0)
return hw
}
// rebuild reads all data from HeaderConfig and updates display
// rebuild reads data from ViewContext (view info + actions) and HeaderConfig (burndown)
func (h *HeaderWidget) rebuild() {
// Update view info from HeaderConfig
h.info.SetViewInfo(h.headerConfig.GetViewName(), h.headerConfig.GetViewDescription())
if h.viewContext != nil {
h.info.SetViewInfo(h.viewContext.GetViewName(), h.viewContext.GetViewDescription())
h.contextHelp.SetActionsFromModel(h.viewContext.GetViewActions(), h.viewContext.GetPluginActions())
}
// Update burndown chart from HeaderConfig
burndown := h.headerConfig.GetBurndown()
h.chart.UpdateBurndown(burndown)
// Update context help from HeaderConfig
viewActions := h.headerConfig.GetViewActions()
pluginActions := h.headerConfig.GetPluginActions()
h.contextHelp.SetActionsFromModel(viewActions, pluginActions)
h.chart.UpdateBurndown(h.headerConfig.GetBurndown())
if h.lastWidth > 0 {
h.rebuildLayout(h.lastWidth)
@ -125,9 +126,12 @@ func (h *HeaderWidget) Draw(screen tcell.Screen) {
h.Flex.Draw(screen)
}
// Cleanup removes the listener from HeaderConfig
// Cleanup removes all listeners
func (h *HeaderWidget) Cleanup() {
h.headerConfig.RemoveListener(h.listenerID)
h.headerConfig.RemoveListener(h.headerListenerID)
if h.viewContext != nil {
h.viewContext.RemoveListener(h.viewContextListenerID)
}
}
// rebuildLayout recalculates and rebuilds the flex layout based on terminal width.

View file

@ -138,7 +138,7 @@ func TestCalculateHeaderLayout_chartHiddenContextFillsAvailable(t *testing.T) {
func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) {
headerConfig := model.NewHeaderConfig()
h := NewHeaderWidget(headerConfig)
h := NewHeaderWidget(headerConfig, model.NewViewContext())
defer h.Cleanup()
h.contextHelp.width = 10
@ -154,7 +154,7 @@ func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) {
func TestHeaderWidget_chartVisibilityThreshold_growsWithContextHelp(t *testing.T) {
headerConfig := model.NewHeaderConfig()
h := NewHeaderWidget(headerConfig)
h := NewHeaderWidget(headerConfig, model.NewViewContext())
defer h.Cleanup()
h.contextHelp.width = 60

View file

@ -1,249 +0,0 @@
# Customization
First of all, you just navigated to a linked file. To go back press `Left` arrow or `Alt-Left`
To go forward press `Right` arrow or `Alt-Right`
tiki is highly customizable. `workflow.yaml` lets you define your workflow statuses and configure views (plugins) for 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.
## 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:
```yaml
statuses:
- key: backlog
label: Backlog
emoji: "📥"
default: true
- key: ready
label: Ready
emoji: "📋"
active: true
- key: in_progress
label: "In Progress"
emoji: "⚙️"
active: true
- key: review
label: Review
emoji: "👀"
active: true
- key: done
label: Done
emoji: "✅"
done: true
```
Each status has:
- `key` — canonical identifier (lowercase, underscores). Used in filters, actions, and frontmatter.
- `label` — display name shown in the UI
- `emoji` — emoji shown alongside the label
- `active` — marks the status as "active work" (used for burndown charts and 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)
You can customize these to match your team's workflow. All filters and actions in view definitions must reference valid status keys.
## Plugins
tiki TUI app is much like a lego - everything is a customizable view. Here is, for example,
how Backlog is defined:
```yaml
views:
plugins:
- name: Backlog
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
```
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
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, hotkey, and `ruki` expressions for filtering and actions. Save this into a `workflow.yaml` file in the config directory
Likewise the documentation is just a plugin:
```yaml
views:
plugins:
- name: Docs
type: doki
fetcher: file
url: "index.md"
key: "F2"
```
that translates to - show `index.md` file located under `.doc/doki`
installed in the same way
### Multi-lane plugin
Backlog is a pretty simple plugin in that it displays all tikis in a single lane. Multi-lane tiki plugins offer functionality
similar to that of the board. You can define multiple lanes per view and move tikis around with Shift-Left/Shift-Right
much like in the board. You can create a multi-lane plugin by defining multiple lanes in its definition and assigning
actions to each lane. An action defines what happens when you move a tiki into the lane. Here is a multi-lane plugin
definition that roughly mimics the board:
```yaml
name: Custom
key: "F4"
lanes:
- name: Ready
columns: 1
width: 20
filter: select where status = "ready" order by priority, title
action: update where id = id() set status="ready"
- name: In Progress
columns: 1
width: 30
filter: select where status = "inProgress" order by priority, title
action: update where id = id() set status="inProgress"
- name: Review
columns: 1
width: 30
filter: select where status = "review" order by priority, title
action: update where id = id() set status="review"
- name: Done
columns: 1
width: 20
filter: select where status = "done" order by priority, title
action: update where id = id() set status="done"
```
### Lane width
Each lane can optionally specify a `width` as a percentage (1-100) to control how much horizontal space it occupies. Widths are relative proportions — they don't need to sum to 100. If width is omitted, the lane gets an equal share of the remaining space.
```yaml
lanes:
- name: Sidebar
width: 25
- name: Main
width: 50
- name: Details
width: 25
```
If no lanes specify width, all lanes are equally sized (the default behavior).
### Global plugin actions
You can define actions under `views.actions` that are available in **all** tiki plugin views:
```yaml
views:
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
plugins:
- name: Kanban
...
```
Global actions appear in the header alongside per-plugin actions. If a per-plugin action uses the same key, the per-plugin action takes precedence for that view. When multiple workflow files define `views.actions`, they merge by key — later files override same-keyed globals from earlier files.
### Per-plugin actions
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
```yaml
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
```
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki.
### ruki expressions
Plugin filters, lane actions, and plugin actions all use the `ruki` language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
#### Filter (select)
The `filter` field uses a `ruki` `select` statement to determine which tikis appear in a lane. Sorting is part of the select — use `order by` to control display order.
```sql
-- basic filter with sort
select where status = "backlog" and type != "epic" order by priority, id
-- recent items, most recent first
select where now() - updatedAt < 24hour order by updatedAt desc
-- multiple conditions
select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
-- assigned to me
select where assignee = user() order by priority
```
#### Action (update)
The `action` field uses a `ruki` `update` statement. In plugin context, `id()` refers to the currently selected tiki.
```sql
-- set status on move
update where id = id() set status="ready"
-- set multiple fields
update where id = id() set status="backlog" priority=2
-- assign to current user
update where id = id() set assignee=user()
```
#### Supported fields
- `id` - task identifier (e.g., "TIKI-M7N2XK")
- `title` - task title text
- `type` - task type (must match a key defined in `workflow.yaml` types)
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
- `assignee` - assigned user
- `priority` - numeric priority value (1-5)
- `points` - story points estimate
- `tags` - list of tags
- `dependsOn` - list of dependency tiki IDs
- `due` - due date (YYYY-MM-DD format)
- `recurrence` - recurrence pattern (cron format)
- `createdAt` - creation timestamp
- `updatedAt` - last update timestamp
#### Conditions
- **Comparison**: `=`, `!=`, `>`, `>=`, `<`, `<=`
- **Logical**: `and`, `or`, `not` (precedence: not > and > or)
- **Membership**: `"value" in field`, `status not in ["done", "cancelled"]`
- **Emptiness**: `assignee is empty`, `tags is not empty`
- **Quantifiers**: `dependsOn any status != "done"`, `dependsOn all status = "done"`
- **Grouping**: parentheses `()` to control evaluation order
#### Literals and built-ins
- Strings: double-quoted (`"ready"`, `"alex"`)
- Integers: `1`, `5`
- Dates: `2026-03-25`
- Durations: `2hour`, `14day`, `3week`, `1month`
- Lists: `["bug", "frontend"]`
- `user()` — current git user
- `now()` — current timestamp
- `id()` — currently selected tiki (in plugin context)
- `count(select where ...)` — count matching tikis

View file

@ -1,54 +0,0 @@
# About
tiki is a lightweight issue-tracking, project management and knowledge base tool that uses git repo
to store issues, stories and documentation.
- tiki uses Markdown files stored in `tiki` format under `.doc/tiki` subdirectory of a git repo
to track issues, stories or epics. Press `Tab` then `Enter` to select this link: [tiki](tiki.md) and read about `tiki` format
- Project-related documentation is stored under `.doc/doki` also in Markdown format. They can be linked/back-linked
for easier navigation.
>Since they are stored in git they are automatically versioned and can be perfectly synced to the current
state of the repo or its git branch. Also, all past versions and deleted items remain in git history of the repo
## Board
Board is a simple Kanban-style board where tikis can be moved around with `Shift-Right` and `Shift-Left`
As tikis are moved their status changes correspondingly. Statuses are configurable via `workflow.yaml`.
Tikis can be opened for viewing or editing or searched by ID, title, description and tags.
To quickly capture an idea - hit `n` in the board or any tiki view, type in the title and press Enter
You can also edit its status, type and other fields, or open the source file directly for editing in your favorite editor
## Documentation
Documentation is a Wiki-style knowledge base stored alongside the project files
Documentation and various other files such as prompts can also be stored under git version control
The documentation can be organized using Markdown links and navigated in the `tiki` cli using Tab/Shift-Tab and Enter
## AI
Since Markdown is an AI-native format issues and documentation can easily be created and maintained using AI tools.
tiki can optionally install skills to enable AI tools such as `claude`, `gemini`, `codex` or `opencode` to understand its
format. Try:
>create a tiki from @my-markdown.md with title "Fix UI bug"
or:
>mark tiki ABC123 as complete
## Customization
Press `Tab` then `Enter` to read [customize](view.md) to understand how to customize or extend tiki with your own plugins
## Configuration
tiki can be configured via `config.yaml` file stored in the same directory where executable is installed
## Header
- Context help showing keyboard shortcuts for the current view
- Various statistics - tiki count, git branch and current user name
- Burndown chart - number of incomplete tikis remaining

View file

@ -1,230 +0,0 @@
# tiki format
First of all, you just navigated to a linked file. To go back press `Left` arrow or `Alt-Left`
To go forward press `Right` arrow or `Alt-Right`
Keep your tickets in your pockets!
`tiki` refers to a task or a ticket (hence tiki) stored in your **git** repo
- like a ticket it can have a status, priority, assignee, points, type and multiple tags attached to it
- they are essentially just Markdown files and you can use full Markdown syntax to describe a story or a bug
- they are stored in `.doc/tiki` subdirectory and are **git**-controlled - they are added to **git** when they are created,
removed when they are done and the entire history is preserved in **git** repo
- because they are in **git** they can be perfectly synced up to the state of your repo or a branch
- you can use either the `tiki` CLI tool or any of the AI coding assistant to work with your tikis
## tiki format
Tiki stores tickets (aka tikis) and documents (aka dokis) in the git repo along with code
They are stored under `.doc` directory and are supposed to be checked-in/versioned along with all other files
The `.doc/` directory contains two main subdirectories:
- **doki/**: Documentation files (wiki-style markdown pages)
- **tiki/**: Task files (kanban style tasks with YAML frontmatter)
## Directory Structure
```
.doc/
├── doki/
│ ├── index.md
│ ├── page2.md
│ ├── page3.md
│ └── sub/
│ └── page4.md
└── tiki/
├── tiki-k3x9m2.md
├── tiki-7wq4na.md
├── tiki-p8j1fz.md
└── ...
```
## Tiki files
Tiki files are saved in `.doc/tiki` directory and can be managed via:
- `tiki` cli
- AI tools such as `claude`, `gemini`, `codex` or `opencode`
- manually
A tiki is made of its frontmatter that includes all fields related to a tiki status and types and its description
in Markdown format
```text
---
title: Sample title
type: story
status: backlog
assignee: booleanmaybe
priority: 3
points: 10
tags:
- UX
- test
dependsOn:
- TIKI-ABC123
- TIKI-DEF456
due: 2026-04-01
recurrence: 0 0 * * MON
---
This is the description of a tiki in Markdown:
# Tests
Make sure all tests pass
## Integration tests
Integration test cases
```
### Fields
#### title
**Required.** String. The name of the tiki. Must be non-empty, max 200 characters.
```yaml
title: Implement user authentication
```
#### type
Optional string. Defaults to the first type defined in `workflow.yaml`.
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
```
#### status
Optional string. Must match a status defined in your project's `workflow.yaml`.
Default: the status marked `default: true` in the workflow (typically `backlog`).
If the value doesn't match any status in the workflow, it falls back to the default.
```yaml
status: in_progress
```
#### priority
Optional. Stored in the file as an integer 15 (1 = highest, 5 = lowest). Default: `3`.
In the TUI, priority is displayed as text with a color indicator:
| Value | TUI label | Emoji |
|-------|-----------|-------|
| 1 | High | 🔴 |
| 2 | Medium High | 🟠 |
| 3 | Medium | 🟡 |
| 4 | Medium Low | 🔵 |
| 5 | Low | 🟢 |
Text aliases are accepted when creating tikis (case-insensitive, hyphens/underscores/spaces as separators):
`high` = 1, `medium-high` = 2, `medium` = 3, `medium-low` = 4, `low` = 5.
```yaml
priority: 2
```
#### points
Optional integer. Range: `0` to `maxPoints` (configurable via `tiki.maxPoints`, default max is 10).
`0` means unestimated. Values outside the valid range default to `maxPoints / 2` (typically 5).
```yaml
points: 3
```
#### assignee
Optional string. Free-form text, typically a username. Default: empty.
```yaml
assignee: booleanmaybe
```
#### tags
Optional string list. Arbitrary labels attached to a tiki. Empty and whitespace-only strings are filtered out.
Default: empty. Both YAML list formats are accepted:
```yaml
# block list
tags:
- frontend
- urgent
# inline list
tags: [frontend, urgent]
```
#### dependsOn
Optional string list. Each entry must be a valid tiki ID in `TIKI-XXXXXX` format (6-character alphanumeric suffix)
referencing an existing tiki. IDs are automatically uppercased. A dependency means this tiki is blocked by the listed tikis.
Default: empty. Both YAML list formats are accepted:
```yaml
# block list
dependsOn:
- TIKI-ABC123
- TIKI-DEF456
# inline list
dependsOn: [TIKI-ABC123, TIKI-DEF456]
```
#### due
Optional date in `YYYY-MM-DD` format (date-only, no time component).
Represents when the task should be completed by. Empty string or omitted field means no due date.
```yaml
due: 2026-04-01
```
#### recurrence
Optional cron string specifying a recurrence pattern. Displayed as English in the TUI.
This is metadata-only — it does not auto-create tasks on completion.
Supported patterns:
| Cron | Display |
|------|---------|
| (empty) | None |
| `0 0 * * *` | Daily |
| `0 0 * * MON` | Weekly on Monday |
| `0 0 * * TUE` | Weekly on Tuesday |
| `0 0 * * WED` | Weekly on Wednesday |
| `0 0 * * THU` | Weekly on Thursday |
| `0 0 * * FRI` | Weekly on Friday |
| `0 0 * * SAT` | Weekly on Saturday |
| `0 0 * * SUN` | Weekly on Sunday |
| `0 0 1 * *` | Monthly |
```yaml
recurrence: 0 0 * * MON
```
### Derived fields
Fields such as:
- `created by`
- `created at`
- `updated at`
are not stored and are calculated from git - the time and git user who created a tiki or the time it was last modified
## Doki files
Documents are any file in a Markdown format saved under `.doc/doki` directory. They can be organized in subdirectory
tree and include links between them or to external Markdown files

101
view/input_box.go Normal file
View file

@ -0,0 +1,101 @@
package view
import (
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/controller"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// InputBox is a single-line input field with a configurable prompt
type InputBox struct {
*tview.InputField
onSubmit func(text string) controller.InputSubmitResult
onCancel func()
}
// NewInputBox creates a new input box widget with the default "> " prompt
func NewInputBox() *InputBox {
colors := config.GetColors()
inputField := tview.NewInputField()
inputField.SetLabel("> ")
inputField.SetLabelColor(colors.InputBoxLabelColor.TCell())
inputField.SetFieldBackgroundColor(colors.InputBoxBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.InputBoxTextColor.TCell())
inputField.SetBorder(true)
inputField.SetBorderColor(colors.TaskBoxUnselectedBorder.TCell())
sb := &InputBox{
InputField: inputField,
}
return sb
}
// SetPrompt changes the prompt label displayed before the input text.
func (sb *InputBox) SetPrompt(label string) *InputBox {
sb.SetLabel(label)
return sb
}
// SetSubmitHandler sets the callback for when Enter is pressed.
// The callback returns an InputSubmitResult controlling box disposition.
func (sb *InputBox) SetSubmitHandler(handler func(text string) controller.InputSubmitResult) *InputBox {
sb.onSubmit = handler
return sb
}
// SetCancelHandler sets the callback for when Escape is pressed
func (sb *InputBox) SetCancelHandler(handler func()) *InputBox {
sb.onCancel = handler
return sb
}
// Clear clears the input text
func (sb *InputBox) Clear() *InputBox {
sb.SetText("")
return sb
}
// InputHandler handles key input for the input box
func (sb *InputBox) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return sb.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
key := event.Key()
switch key {
case tcell.KeyEnter:
if sb.onSubmit != nil {
sb.onSubmit(sb.GetText())
}
return
case tcell.KeyEscape:
if sb.onCancel != nil {
sb.onCancel()
}
return
}
if sb.isAllowedKey(event) {
handler := sb.InputField.InputHandler()
if handler != nil {
handler(event, setFocus)
}
}
})
}
// isAllowedKey returns true if the key should be processed by the InputField
func (sb *InputBox) isAllowedKey(event *tcell.EventKey) bool {
key := event.Key()
switch key {
case tcell.KeyBackspace, tcell.KeyBackspace2, tcell.KeyDelete:
return true
case tcell.KeyRune:
return true
}
return false
}

187
view/input_helper.go Normal file
View file

@ -0,0 +1,187 @@
package view
import (
"strings"
"github.com/boolean-maybe/tiki/controller"
"github.com/rivo/tview"
)
type inputMode int
const (
inputModeClosed inputMode = iota
inputModeSearchEditing // search box focused, user typing
inputModeSearchPassive // search applied, box visible but unfocused
inputModeActionInput // action-input box focused, user typing
)
// InputHelper provides reusable input box integration to eliminate duplication across views.
// It tracks an explicit mode state machine:
//
// closed → searchEditing (on search open)
// searchEditing → searchPassive (on non-empty Enter)
// searchEditing → closed (on Esc — clears search)
// searchPassive → closed (on Esc — clears search)
// searchPassive → actionInput (on action-input open — temporarily replaces)
// actionInput → searchPassive (on Enter/Esc if search was passive before)
// actionInput → closed (on Enter/Esc if no prior search)
type InputHelper struct {
inputBox *InputBox
mode inputMode
savedSearchQuery string // saved when action-input temporarily replaces passive search
onSubmit func(text string) controller.InputSubmitResult
onCancel func()
onClose func() // called when the helper needs the view to rebuild layout (remove widget)
onRestorePassive func(query string) // called when action-input ends and passive search should be restored
focusSetter func(p tview.Primitive)
contentPrimitive tview.Primitive
}
// NewInputHelper creates a new input helper with an initialized input box
func NewInputHelper(contentPrimitive tview.Primitive) *InputHelper {
helper := &InputHelper{
inputBox: NewInputBox(),
contentPrimitive: contentPrimitive,
}
helper.inputBox.SetSubmitHandler(func(text string) controller.InputSubmitResult {
if helper.onSubmit == nil {
return controller.InputClose
}
result := helper.onSubmit(text)
switch result {
case controller.InputShowPassive:
helper.mode = inputModeSearchPassive
helper.inputBox.SetText(strings.TrimSpace(text))
if helper.focusSetter != nil {
helper.focusSetter(contentPrimitive)
}
case controller.InputClose:
helper.finishInput()
}
return result
})
helper.inputBox.SetCancelHandler(func() {
if helper.onCancel != nil {
helper.onCancel()
}
})
return helper
}
// finishInput handles InputClose: restores passive search or fully closes.
func (ih *InputHelper) finishInput() {
if ih.mode == inputModeActionInput && ih.savedSearchQuery != "" {
query := ih.savedSearchQuery
ih.savedSearchQuery = ""
ih.mode = inputModeSearchPassive
ih.inputBox.SetPrompt("> ")
ih.inputBox.SetText(query)
if ih.focusSetter != nil {
ih.focusSetter(ih.contentPrimitive)
}
if ih.onRestorePassive != nil {
ih.onRestorePassive(query)
}
return
}
ih.savedSearchQuery = ""
ih.mode = inputModeClosed
ih.inputBox.Clear()
if ih.focusSetter != nil {
ih.focusSetter(ih.contentPrimitive)
}
if ih.onClose != nil {
ih.onClose()
}
}
// SetSubmitHandler sets the handler called when user submits input (Enter key).
func (ih *InputHelper) SetSubmitHandler(handler func(text string) controller.InputSubmitResult) {
ih.onSubmit = handler
}
// SetCancelHandler sets the handler called when user cancels input (Escape key)
func (ih *InputHelper) SetCancelHandler(handler func()) {
ih.onCancel = handler
}
// SetCloseHandler sets the callback for when the input box should be removed from layout.
func (ih *InputHelper) SetCloseHandler(handler func()) {
ih.onClose = handler
}
// SetRestorePassiveHandler sets the callback for when passive search should be restored
// after action-input ends (layout may need prompt text update).
func (ih *InputHelper) SetRestorePassiveHandler(handler func(query string)) {
ih.onRestorePassive = handler
}
// SetFocusSetter sets the function used to change focus between primitives.
func (ih *InputHelper) SetFocusSetter(setter func(p tview.Primitive)) {
ih.focusSetter = setter
}
// Show makes the input box visible with the given prompt and initial text.
// If the box is in search-passive mode, it saves the search query and
// transitions to action-input mode (temporarily replacing the passive indicator).
func (ih *InputHelper) Show(prompt, initialText string, mode inputMode) tview.Primitive {
if ih.mode == inputModeSearchPassive && mode == inputModeActionInput {
ih.savedSearchQuery = ih.inputBox.GetText()
}
ih.mode = mode
ih.inputBox.SetPrompt(prompt)
ih.inputBox.SetText(initialText)
return ih.inputBox
}
// ShowSearch makes the input box visible in search-editing mode.
func (ih *InputHelper) ShowSearch(currentQuery string) tview.Primitive {
return ih.Show("> ", currentQuery, inputModeSearchEditing)
}
// Hide hides the input box and clears its text. Generic teardown only.
func (ih *InputHelper) Hide() {
ih.mode = inputModeClosed
ih.savedSearchQuery = ""
ih.inputBox.Clear()
}
// IsVisible returns true if the input box is currently visible (any mode except closed)
func (ih *InputHelper) IsVisible() bool {
return ih.mode != inputModeClosed
}
// IsEditing returns true if the input box is in an active editing mode (focused)
func (ih *InputHelper) IsEditing() bool {
return ih.mode == inputModeSearchEditing || ih.mode == inputModeActionInput
}
// IsSearchPassive returns true if the input box is in search-passive mode
func (ih *InputHelper) IsSearchPassive() bool {
return ih.mode == inputModeSearchPassive
}
// Mode returns the current input mode
func (ih *InputHelper) Mode() inputMode {
return ih.mode
}
// HasFocus returns true if the input box currently has focus
func (ih *InputHelper) HasFocus() bool {
return ih.IsVisible() && ih.inputBox.HasFocus()
}
// GetFocusSetter returns the focus setter function
func (ih *InputHelper) GetFocusSetter() func(p tview.Primitive) {
return ih.focusSetter
}
// GetInputBox returns the underlying input box primitive for layout building
func (ih *InputHelper) GetInputBox() *InputBox {
return ih.inputBox
}

View file

@ -0,0 +1,535 @@
package palette
import (
"fmt"
"sort"
"strings"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/controller"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/util"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const PaletteMinWidth = 30
// sectionType identifies which section a palette row belongs to.
type sectionType int
const (
sectionGlobal sectionType = iota
sectionViews
sectionView
)
// paletteRow is a single entry in the rendered palette list.
type paletteRow struct {
action controller.Action
section sectionType
enabled bool
separator bool // true for section header/separator rows
label string
}
// ActionPalette is a modal overlay listing all available actions, filterable by fuzzy typing.
type ActionPalette struct {
root *tview.Flex
filterInput *tview.InputField
listView *tview.TextView
hintView *tview.TextView
viewContext *model.ViewContext
paletteConfig *model.ActionPaletteConfig
inputRouter *controller.InputRouter
navController *controller.NavigationController
rows []paletteRow
visibleRows []int // indices into rows for current filter
selectedIndex int // index into visibleRows
lastWidth int // width used for last render, to detect resize
viewContextListenerID int
}
// NewActionPalette creates the palette widget.
func NewActionPalette(
viewContext *model.ViewContext,
paletteConfig *model.ActionPaletteConfig,
inputRouter *controller.InputRouter,
navController *controller.NavigationController,
) *ActionPalette {
colors := config.GetColors()
ap := &ActionPalette{
viewContext: viewContext,
paletteConfig: paletteConfig,
inputRouter: inputRouter,
navController: navController,
}
// filter input
ap.filterInput = tview.NewInputField()
ap.filterInput.SetLabel(" ")
ap.filterInput.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
ap.filterInput.SetFieldTextColor(colors.InputFieldTextColor.TCell())
ap.filterInput.SetLabelColor(colors.InputBoxLabelColor.TCell())
ap.filterInput.SetPlaceholder("Type to search")
ap.filterInput.SetPlaceholderStyle(tcell.StyleDefault.
Foreground(colors.TaskDetailPlaceholderColor.TCell()).
Background(colors.ContentBackgroundColor.TCell()))
ap.filterInput.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
// list area
ap.listView = tview.NewTextView().SetDynamicColors(true)
ap.listView.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
ap.listView.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) {
if width != ap.lastWidth && width > 0 {
ap.renderList()
}
return x, y, width, height
})
// bottom hint
ap.hintView = tview.NewTextView().SetDynamicColors(true)
ap.hintView.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
mutedHex := colors.TaskDetailPlaceholderColor.Hex()
ap.hintView.SetText(fmt.Sprintf(" [%s]↑↓ Select ⏎ Run Esc Close", mutedHex))
ap.root = tview.NewFlex().SetDirection(tview.FlexRow)
ap.root.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
ap.root.SetBorder(true)
ap.root.SetBorderColor(colors.TaskBoxUnselectedBorder.TCell())
ap.root.AddItem(ap.filterInput, 1, 0, true)
ap.root.AddItem(ap.listView, 0, 1, false)
ap.root.AddItem(ap.hintView, 1, 0, false)
// wire filter input to intercept all palette keys
ap.filterInput.SetInputCapture(ap.handleFilterInput)
// subscribe to view context changes
ap.viewContextListenerID = viewContext.AddListener(func() {
ap.rebuildRows()
ap.renderList()
})
return ap
}
// GetPrimitive returns the root tview primitive for embedding in a Pages overlay.
func (ap *ActionPalette) GetPrimitive() tview.Primitive {
return ap.root
}
// GetFilterInput returns the input field that should receive focus when the palette opens.
func (ap *ActionPalette) GetFilterInput() *tview.InputField {
return ap.filterInput
}
// OnShow resets state and rebuilds rows when the palette becomes visible.
func (ap *ActionPalette) OnShow() {
ap.filterInput.SetText("")
ap.selectedIndex = 0
ap.rebuildRows()
ap.renderList()
}
// Cleanup removes all listeners.
func (ap *ActionPalette) Cleanup() {
ap.viewContext.RemoveListener(ap.viewContextListenerID)
}
func (ap *ActionPalette) rebuildRows() {
ap.rows = nil
currentView := ap.navController.CurrentView()
activeView := ap.navController.GetActiveView()
globalActions := controller.DefaultGlobalActions().GetPaletteActions()
globalIDs := make(map[controller.ActionID]bool, len(globalActions))
for _, a := range globalActions {
globalIDs[a.ID] = true
}
// global section
if len(globalActions) > 0 {
ap.rows = append(ap.rows, paletteRow{separator: true, label: "Global", section: sectionGlobal})
for _, a := range globalActions {
ap.rows = append(ap.rows, paletteRow{
action: a,
section: sectionGlobal,
enabled: actionEnabled(a, currentView, activeView),
})
}
}
// views section (plugin activation keys) — only if active view shows navigation
pluginIDs := make(map[controller.ActionID]bool)
if activeView != nil {
if np, ok := activeView.(controller.NavigationProvider); ok && np.ShowNavigation() {
pluginActions := controller.GetPluginActions().GetPaletteActions()
if len(pluginActions) > 0 {
ap.rows = append(ap.rows, paletteRow{separator: true, label: "Views", section: sectionViews})
for _, a := range pluginActions {
pluginIDs[a.ID] = true
ap.rows = append(ap.rows, paletteRow{
action: a,
section: sectionViews,
enabled: actionEnabled(a, currentView, activeView),
})
}
}
}
}
// view section — current view's own actions, deduped against global + plugin
if activeView != nil {
viewActions := activeView.GetActionRegistry().GetPaletteActions()
var filtered []controller.Action
for _, a := range viewActions {
if globalIDs[a.ID] || pluginIDs[a.ID] {
continue
}
filtered = append(filtered, a)
}
if len(filtered) > 0 {
ap.rows = append(ap.rows, paletteRow{separator: true, label: "View", section: sectionView})
for _, a := range filtered {
ap.rows = append(ap.rows, paletteRow{
action: a,
section: sectionView,
enabled: actionEnabled(a, currentView, activeView),
})
}
}
}
ap.filterRows()
}
func actionEnabled(a controller.Action, currentView *controller.ViewEntry, activeView controller.View) bool {
if a.IsEnabled == nil {
return true
}
return a.IsEnabled(currentView, activeView)
}
func (ap *ActionPalette) filterRows() {
query := ap.filterInput.GetText()
ap.visibleRows = nil
if query == "" {
for i := range ap.rows {
ap.visibleRows = append(ap.visibleRows, i)
}
ap.stripEmptySections()
ap.clampSelection()
return
}
type scored struct {
idx int
score int
}
// group by section, score each, sort within section
sectionScored := make(map[sectionType][]scored)
for i, row := range ap.rows {
if row.separator {
continue
}
matched, score := fuzzyMatch(query, row.action.Label)
if matched {
sectionScored[row.section] = append(sectionScored[row.section], scored{i, score})
}
}
for _, section := range []sectionType{sectionGlobal, sectionViews, sectionView} {
items := sectionScored[section]
if len(items) == 0 {
continue
}
sort.Slice(items, func(a, b int) bool {
if items[a].score != items[b].score {
return items[a].score < items[b].score
}
la := strings.ToLower(ap.rows[items[a].idx].action.Label)
lb := strings.ToLower(ap.rows[items[b].idx].action.Label)
if la != lb {
return la < lb
}
return ap.rows[items[a].idx].action.ID < ap.rows[items[b].idx].action.ID
})
// find section separator
for i, row := range ap.rows {
if row.separator && row.section == section {
ap.visibleRows = append(ap.visibleRows, i)
break
}
}
for _, item := range items {
ap.visibleRows = append(ap.visibleRows, item.idx)
}
}
ap.stripEmptySections()
ap.clampSelection()
}
// stripEmptySections removes section separators that have no visible action rows after them.
func (ap *ActionPalette) stripEmptySections() {
var result []int
for i, vi := range ap.visibleRows {
row := ap.rows[vi]
if row.separator {
// check if next visible row is a non-separator in same section
hasContent := false
for j := i + 1; j < len(ap.visibleRows); j++ {
next := ap.rows[ap.visibleRows[j]]
if next.separator {
break
}
hasContent = true
break
}
if !hasContent {
continue
}
}
result = append(result, vi)
}
ap.visibleRows = result
}
func (ap *ActionPalette) clampSelection() {
if ap.selectedIndex >= len(ap.visibleRows) {
ap.selectedIndex = 0
}
// skip to first selectable (non-separator, enabled) row
ap.selectedIndex = ap.nextSelectableFrom(ap.selectedIndex, 1)
}
func (ap *ActionPalette) nextSelectableFrom(start, direction int) int {
n := len(ap.visibleRows)
if n == 0 {
return 0
}
for i := 0; i < n; i++ {
idx := (start + i*direction + n) % n
row := ap.rows[ap.visibleRows[idx]]
if !row.separator && row.enabled {
return idx
}
}
return start
}
func (ap *ActionPalette) renderList() {
colors := config.GetColors()
_, _, width, _ := ap.listView.GetInnerRect()
if width <= 0 {
width = PaletteMinWidth
}
ap.lastWidth = width
globalScheme := sectionColors(sectionGlobal)
viewsScheme := sectionColors(sectionViews)
viewScheme := sectionColors(sectionView)
mutedHex := colors.TaskDetailPlaceholderColor.Hex()
selBgHex := colors.TaskListSelectionBg.Hex()
var buf strings.Builder
if len(ap.visibleRows) == 0 {
buf.WriteString(fmt.Sprintf("[%s] no matches", mutedHex))
ap.listView.SetText(buf.String())
return
}
keyColWidth := 12
for vi, rowIdx := range ap.visibleRows {
row := ap.rows[rowIdx]
if row.separator {
if vi > 0 {
buf.WriteString("\n")
line := strings.Repeat("─", width)
buf.WriteString(fmt.Sprintf("[%s]%s[-]", mutedHex, line))
}
continue
}
keyStr := util.FormatKeyBinding(row.action.Key, row.action.Rune, row.action.Modifier)
label := row.action.Label
// truncate label if needed
maxLabel := width - keyColWidth - 4
if maxLabel < 5 {
maxLabel = 5
}
if len([]rune(label)) > maxLabel {
label = string([]rune(label)[:maxLabel-1]) + "…"
}
var scheme sectionColorPair
switch row.section {
case sectionGlobal:
scheme = globalScheme
case sectionViews:
scheme = viewsScheme
case sectionView:
scheme = viewScheme
}
selected := vi == ap.selectedIndex
if vi > 0 {
buf.WriteString("\n")
}
// build visible text: key column + label
visibleLen := 1 + keyColWidth + 1 + len([]rune(label)) // leading space + key + space + label
pad := ""
if visibleLen < width {
pad = strings.Repeat(" ", width-visibleLen)
}
if !row.enabled {
buf.WriteString(fmt.Sprintf(" [%s]%-*s %s%s[-]", mutedHex, keyColWidth, keyStr, label, pad))
} else if selected {
buf.WriteString(fmt.Sprintf("[%s:%s:b] %-*s[-:-:-][:%s:] %s%s[-:-:-]",
scheme.keyHex, selBgHex, keyColWidth, keyStr,
selBgHex, label, pad))
} else {
buf.WriteString(fmt.Sprintf(" [%s]%-*s[-] %s%s", scheme.keyHex, keyColWidth, keyStr, label, pad))
}
}
ap.listView.SetText(buf.String())
}
type sectionColorPair struct {
keyHex string
labelHex string
}
func sectionColors(s sectionType) sectionColorPair {
colors := config.GetColors()
switch s {
case sectionGlobal:
return sectionColorPair{
keyHex: colors.HeaderActionGlobalKeyColor.Hex(),
labelHex: colors.HeaderActionGlobalLabelColor.Hex(),
}
case sectionViews:
return sectionColorPair{
keyHex: colors.HeaderActionPluginKeyColor.Hex(),
labelHex: colors.HeaderActionPluginLabelColor.Hex(),
}
case sectionView:
return sectionColorPair{
keyHex: colors.HeaderActionViewKeyColor.Hex(),
labelHex: colors.HeaderActionViewLabelColor.Hex(),
}
default:
return sectionColorPair{
keyHex: colors.HeaderActionGlobalKeyColor.Hex(),
labelHex: colors.HeaderActionGlobalLabelColor.Hex(),
}
}
}
// handleFilterInput owns all palette keyboard behavior.
func (ap *ActionPalette) handleFilterInput(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEscape:
ap.paletteConfig.SetVisible(false)
return nil
case tcell.KeyEnter:
ap.dispatchSelected()
return nil
case tcell.KeyUp:
ap.moveSelection(-1)
ap.renderList()
return nil
case tcell.KeyDown:
ap.moveSelection(1)
ap.renderList()
return nil
case tcell.KeyCtrlU:
ap.filterInput.SetText("")
ap.filterRows()
ap.renderList()
return nil
case tcell.KeyRune:
// let the input field handle the rune, then re-filter
return event
case tcell.KeyBackspace, tcell.KeyBackspace2:
return event
default:
// swallow everything else
return nil
}
}
// SetChangedFunc wires a callback that re-filters when the input text changes.
func (ap *ActionPalette) SetChangedFunc() {
ap.filterInput.SetChangedFunc(func(text string) {
ap.filterRows()
ap.renderList()
})
}
func (ap *ActionPalette) moveSelection(direction int) {
n := len(ap.visibleRows)
if n == 0 {
return
}
start := ap.selectedIndex + direction
if start < 0 {
start = n - 1
} else if start >= n {
start = 0
}
ap.selectedIndex = ap.nextSelectableFrom(start, direction)
}
func (ap *ActionPalette) dispatchSelected() {
if ap.selectedIndex >= len(ap.visibleRows) {
ap.paletteConfig.SetVisible(false)
return
}
row := ap.rows[ap.visibleRows[ap.selectedIndex]]
if row.separator || !row.enabled {
return
}
actionID := row.action.ID
// close palette BEFORE dispatch (clean focus transition)
ap.paletteConfig.SetVisible(false)
// try view-local handler first
if activeView := ap.navController.GetActiveView(); activeView != nil {
if handler, ok := activeView.(controller.PaletteActionHandler); ok {
if handler.HandlePaletteAction(actionID) {
return
}
}
}
// fall back to controller-side dispatch
ap.inputRouter.HandleAction(actionID, ap.navController.CurrentView())
}

34
view/palette/fuzzy.go Normal file
View file

@ -0,0 +1,34 @@
package palette
import "unicode"
// fuzzyMatch performs a case-insensitive subsequence match of query against text.
// Returns (matched, score) where lower score is better.
// Score = firstMatchPos + spanLength, where spanLength = lastMatchIdx - firstMatchIdx.
func fuzzyMatch(query, text string) (bool, int) {
if query == "" {
return true, 0
}
queryRunes := []rune(query)
textRunes := []rune(text)
qi := 0
firstMatch := -1
lastMatch := -1
for ti := 0; ti < len(textRunes) && qi < len(queryRunes); ti++ {
if unicode.ToLower(textRunes[ti]) == unicode.ToLower(queryRunes[qi]) {
if firstMatch == -1 {
firstMatch = ti
}
lastMatch = ti
qi++
}
}
if qi < len(queryRunes) {
return false, 0
}
return true, firstMatch + (lastMatch - firstMatch)
}

View file

@ -0,0 +1,81 @@
package palette
import (
"testing"
)
func TestFuzzyMatch_EmptyQuery(t *testing.T) {
matched, score := fuzzyMatch("", "anything")
if !matched {
t.Error("empty query should match everything")
}
if score != 0 {
t.Errorf("empty query score should be 0, got %d", score)
}
}
func TestFuzzyMatch_ExactMatch(t *testing.T) {
matched, score := fuzzyMatch("Save", "Save")
if !matched {
t.Error("exact match should succeed")
}
if score != 3 {
t.Errorf("expected score 3 (pos=0, span=3), got %d", score)
}
}
func TestFuzzyMatch_PrefixMatch(t *testing.T) {
matched, score := fuzzyMatch("Sav", "Save Task")
if !matched {
t.Error("prefix match should succeed")
}
if score != 2 {
t.Errorf("expected score 2 (pos=0, span=2), got %d", score)
}
}
func TestFuzzyMatch_SubsequenceMatch(t *testing.T) {
matched, score := fuzzyMatch("TH", "Toggle Header")
if !matched {
t.Error("subsequence match should succeed")
}
// T at 0, H at 7 → score = 0 + 7 = 7
if score != 7 {
t.Errorf("expected score 7, got %d", score)
}
}
func TestFuzzyMatch_CaseInsensitive(t *testing.T) {
matched, _ := fuzzyMatch("save", "Save Task")
if !matched {
t.Error("case-insensitive match should succeed")
}
}
func TestFuzzyMatch_NoMatch(t *testing.T) {
matched, _ := fuzzyMatch("xyz", "Save Task")
if matched {
t.Error("non-matching query should not match")
}
}
func TestFuzzyMatch_ScoreOrdering(t *testing.T) {
// "se" matches "Search" (S=0, e=1 → score=1) better than "Save Edit" (S=0, e=5 → score=5)
_, scoreSearch := fuzzyMatch("se", "Search")
_, scoreSaveEdit := fuzzyMatch("se", "Save Edit")
if scoreSearch >= scoreSaveEdit {
t.Errorf("'Search' should score better than 'Save Edit' for 'se': %d vs %d", scoreSearch, scoreSaveEdit)
}
}
func TestFuzzyMatch_SingleChar(t *testing.T) {
matched, score := fuzzyMatch("q", "Quit")
if !matched {
t.Error("single char should match")
}
// q at position 0 → score = 0 + 0 = 0
if score != 0 {
t.Errorf("single char at start should score 0, got %d", score)
}
}

View file

@ -36,6 +36,8 @@ type RootLayout struct {
contentView controller.View
lastParamsKey string
viewContext *model.ViewContext
headerListenerID int
layoutListenerID int
storeListenerID int
@ -50,6 +52,7 @@ type RootLayout struct {
type RootLayoutOpts struct {
Header *header.HeaderWidget
HeaderConfig *model.HeaderConfig
ViewContext *model.ViewContext
LayoutModel *model.LayoutModel
ViewFactory controller.ViewFactory
TaskStore store.Store
@ -65,6 +68,7 @@ func NewRootLayout(opts RootLayoutOpts) *RootLayout {
header: opts.Header,
contentArea: tview.NewFlex().SetDirection(tview.FlexRow),
headerConfig: opts.HeaderConfig,
viewContext: opts.ViewContext,
layoutModel: opts.LayoutModel,
viewFactory: opts.ViewFactory,
taskStore: opts.TaskStore,
@ -139,20 +143,8 @@ func (rl *RootLayout) onLayoutChange() {
rl.contentArea.AddItem(newView.GetPrimitive(), 0, 1, true)
rl.contentView = newView
// Update header with new view's actions
rl.headerConfig.SetViewActions(newView.GetActionRegistry().ToHeaderActions())
// Show or hide plugin navigation keys based on the view's declaration
if np, ok := newView.(controller.NavigationProvider); ok && np.ShowNavigation() {
rl.headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions())
} else {
rl.headerConfig.SetPluginActions(nil)
}
// Update header info section with view name and description
if vip, ok := newView.(controller.ViewInfoProvider); ok {
rl.headerConfig.SetViewInfo(vip.GetViewName(), vip.GetViewDescription())
}
// Sync view context (writes to both ViewContext and HeaderConfig for header actions)
rl.syncViewContextFromView(newView)
// Update statusline stats from the view
rl.updateStatuslineViewStats(newView)
@ -169,6 +161,13 @@ func (rl *RootLayout) onLayoutChange() {
})
}
// Wire up action change notifications (registry or enablement changes on the same view)
if notifier, ok := newView.(controller.ActionChangeNotifier); ok {
notifier.SetActionChangeHandler(func() {
rl.syncViewContextFromView(newView)
})
}
// Focus the view
newView.OnFocus()
if newView.GetViewID() == model.TaskEditViewID {
@ -199,6 +198,32 @@ func (rl *RootLayout) onLayoutChange() {
rl.app.SetFocus(newView.GetPrimitive())
}
// syncViewContextFromView computes view name/description, view actions, and plugin actions
// from the active view, then writes to both ViewContext (single atomic update) and HeaderConfig
// (for backward-compatible header display during the transition period).
func (rl *RootLayout) syncViewContextFromView(v controller.View) {
if v == nil {
return
}
viewActions := v.GetActionRegistry().ToHeaderActions()
var pluginActions []model.HeaderAction
if np, ok := v.(controller.NavigationProvider); ok && np.ShowNavigation() {
pluginActions = controller.GetPluginActions().ToHeaderActions()
}
var viewName, viewDesc string
if vip, ok := v.(controller.ViewInfoProvider); ok {
viewName = vip.GetViewName()
viewDesc = vip.GetViewDescription()
}
if rl.viewContext != nil {
rl.viewContext.SetFromView(v.GetViewID(), viewName, viewDesc, viewActions, pluginActions)
}
}
// recomputeHeaderVisibility computes header visibility based on view requirements and user preference
func (rl *RootLayout) recomputeHeaderVisibility(v controller.View) {
// Start from user preference

View file

@ -1,129 +0,0 @@
package view
import (
"github.com/boolean-maybe/tiki/config"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// SearchBox is a single-line input field with a "> " prompt
type SearchBox struct {
*tview.InputField
onSubmit func(text string)
onCancel func()
}
// NewSearchBox creates a new search box widget
func NewSearchBox() *SearchBox {
colors := config.GetColors()
inputField := tview.NewInputField()
// Configure the input field (border drawn manually in Draw)
inputField.SetLabel("> ")
inputField.SetLabelColor(colors.SearchBoxLabelColor.TCell())
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
inputField.SetBorder(false)
sb := &SearchBox{
InputField: inputField,
}
return sb
}
// SetSubmitHandler sets the callback for when Enter is pressed
func (sb *SearchBox) SetSubmitHandler(handler func(text string)) *SearchBox {
sb.onSubmit = handler
return sb
}
// SetCancelHandler sets the callback for when Escape is pressed
func (sb *SearchBox) SetCancelHandler(handler func()) *SearchBox {
sb.onCancel = handler
return sb
}
// Clear clears the search text
func (sb *SearchBox) Clear() *SearchBox {
sb.SetText("")
return sb
}
// Draw renders the search box with single-line borders
// (overrides InputField.Draw to avoid double-line focus borders)
func (sb *SearchBox) Draw(screen tcell.Screen) {
x, y, width, height := sb.GetRect()
if width <= 0 || height <= 0 {
return
}
// Fill interior with theme-aware background color
bgColor := config.GetColors().ContentBackgroundColor.TCell()
bgStyle := tcell.StyleDefault.Background(bgColor)
for row := y; row < y+height; row++ {
for col := x; col < x+width; col++ {
screen.SetContent(col, row, ' ', nil, bgStyle)
}
}
// Draw single-line border using shared utility
DrawSingleLineBorder(screen, x, y, width, height)
// Draw InputField inside border (offset by 1 for border)
sb.SetRect(x+1, y+1, width-2, height-2)
sb.InputField.Draw(screen)
}
// InputHandler handles key input for the search box
func (sb *SearchBox) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return sb.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
key := event.Key()
// Handle submit and cancel
switch key {
case tcell.KeyEnter:
if sb.onSubmit != nil {
sb.onSubmit(sb.GetText())
}
return
case tcell.KeyEscape:
if sb.onCancel != nil {
sb.onCancel()
}
return
}
// Only allow typing and basic editing - block everything else
if sb.isAllowedKey(event) {
handler := sb.InputField.InputHandler()
if handler != nil {
handler(event, setFocus)
}
}
// All other keys silently ignored (consumed)
})
}
// isAllowedKey returns true if the key should be processed by the InputField
func (sb *SearchBox) isAllowedKey(event *tcell.EventKey) bool {
key := event.Key()
// Allow basic editing keys
switch key {
case tcell.KeyBackspace, tcell.KeyBackspace2, tcell.KeyDelete:
return true
// Allow printable characters (letters, digits, symbols)
case tcell.KeyRune:
return true
}
// Block everything else:
// - All arrows (Left, Right, Up, Down)
// - Tab, Home, End, PageUp, PageDown
// - All function keys (F1-F12)
// - All control sequences
return false
}

View file

@ -1,85 +0,0 @@
package view
import (
"github.com/rivo/tview"
)
// SearchHelper provides reusable search box integration to eliminate duplication across views
type SearchHelper struct {
searchBox *SearchBox
searchVisible bool
onSearchSubmit func(text string)
focusSetter func(p tview.Primitive)
}
// NewSearchHelper creates a new search helper with an initialized search box
func NewSearchHelper(contentPrimitive tview.Primitive) *SearchHelper {
helper := &SearchHelper{
searchBox: NewSearchBox(),
}
// Wire up internal handlers - the search box will call these
helper.searchBox.SetSubmitHandler(func(text string) {
if helper.onSearchSubmit != nil {
helper.onSearchSubmit(text)
}
// Transfer focus back to content after search
if helper.focusSetter != nil {
helper.focusSetter(contentPrimitive)
}
})
return helper
}
// SetSubmitHandler sets the handler called when user submits a search query
// This is typically wired to the controller's HandleSearch method
func (sh *SearchHelper) SetSubmitHandler(handler func(text string)) {
sh.onSearchSubmit = handler
}
// SetCancelHandler sets the handler called when user cancels search (Escape key)
// This is typically wired to the view's HideSearch method
func (sh *SearchHelper) SetCancelHandler(handler func()) {
sh.searchBox.SetCancelHandler(handler)
}
// SetFocusSetter sets the function used to change focus between primitives
// This is typically app.SetFocus and is provided by the InputRouter
func (sh *SearchHelper) SetFocusSetter(setter func(p tview.Primitive)) {
sh.focusSetter = setter
}
// ShowSearch makes the search box visible and returns it for focus management
// currentQuery: the query text to restore (e.g., when returning from task detail)
func (sh *SearchHelper) ShowSearch(currentQuery string) tview.Primitive {
sh.searchVisible = true
sh.searchBox.SetText(currentQuery)
return sh.searchBox
}
// HideSearch clears and hides the search box
func (sh *SearchHelper) HideSearch() {
sh.searchVisible = false
sh.searchBox.Clear()
}
// IsVisible returns true if the search box is currently visible
func (sh *SearchHelper) IsVisible() bool {
return sh.searchVisible
}
// HasFocus returns true if the search box currently has focus
func (sh *SearchHelper) HasFocus() bool {
return sh.searchVisible && sh.searchBox.HasFocus()
}
// GetFocusSetter returns the focus setter function for transferring focus after layout changes
func (sh *SearchHelper) GetFocusSetter() func(p tview.Primitive) {
return sh.focusSetter
}
// GetSearchBox returns the underlying search box primitive for layout building
func (sh *SearchHelper) GetSearchBox() *SearchBox {
return sh.searchBox
}

View file

@ -78,6 +78,16 @@ func (tv *TaskDetailView) OnFocus() {
tv.refresh()
}
// RestoreFocus sets focus to the current description viewer (which may have been
// rebuilt by a store-driven refresh while the palette was open).
func (tv *TaskDetailView) RestoreFocus() bool {
if tv.descView != nil && tv.focusSetter != nil {
tv.focusSetter(tv.descView)
return true
}
return false
}
// OnBlur is called when the view becomes inactive
func (tv *TaskDetailView) OnBlur() {
if tv.storeListenerID != 0 {

View file

@ -256,11 +256,13 @@ func (ev *TaskEditView) IsRecurrenceValueFocused() bool {
func (ev *TaskEditView) UpdateHeaderForField(field model.EditField) {
if ev.descOnly {
ev.registry = controller.DescOnlyEditActions()
return
}
if ev.tagsOnly {
} else if ev.tagsOnly {
ev.registry = controller.TagsOnlyEditActions()
return
} else {
ev.registry = controller.GetActionsForField(field)
}
if ev.actionChangeHandler != nil {
ev.actionChangeHandler()
}
ev.registry = controller.GetActionsForField(field)
}

View file

@ -45,28 +45,34 @@ type TaskEditView struct {
tagsEditing bool
// All callbacks
onTitleSave func(string)
onTitleChange func(string)
onTitleCancel func()
onDescSave func(string)
onDescCancel func()
onStatusSave func(string)
onTypeSave func(string)
onPrioritySave func(int)
onAssigneeSave func(string)
onPointsSave func(int)
onDueSave func(string)
onRecurrenceSave func(string)
onTagsSave func(string)
onTagsCancel func()
onTitleSave func(string)
onTitleChange func(string)
onTitleCancel func()
onDescSave func(string)
onDescCancel func()
onStatusSave func(string)
onTypeSave func(string)
onPrioritySave func(int)
onAssigneeSave func(string)
onPointsSave func(int)
onDueSave func(string)
onRecurrenceSave func(string)
onTagsSave func(string)
onTagsCancel func()
actionChangeHandler func()
}
// Compile-time interface checks
var (
_ controller.View = (*TaskEditView)(nil)
_ controller.FocusSettable = (*TaskEditView)(nil)
_ controller.View = (*TaskEditView)(nil)
_ controller.FocusSettable = (*TaskEditView)(nil)
_ controller.ActionChangeNotifier = (*TaskEditView)(nil)
)
func (ev *TaskEditView) SetActionChangeHandler(handler func()) {
ev.actionChangeHandler = handler
}
// NewTaskEditView creates a task edit view
func NewTaskEditView(taskStore store.Store, taskID string, imageManager *navtview.ImageManager) *TaskEditView {
ev := &TaskEditView{
@ -75,7 +81,7 @@ func NewTaskEditView(taskStore store.Store, taskID string, imageManager *navtvie
taskID: taskID,
imageManager: imageManager,
},
registry: controller.TaskEditViewActions(),
registry: controller.GetActionsForField(model.EditFieldTitle),
viewID: model.TaskEditViewID,
focusedField: model.EditFieldTitle,
titleEditing: true,
@ -142,7 +148,11 @@ func (ev *TaskEditView) SetDescOnly(descOnly bool) {
ev.descOnly = descOnly
if descOnly {
ev.focusedField = model.EditFieldDescription
ev.registry = controller.DescOnlyEditActions()
ev.refresh()
if ev.actionChangeHandler != nil {
ev.actionChangeHandler()
}
}
}
@ -156,7 +166,11 @@ func (ev *TaskEditView) IsDescOnly() bool {
func (ev *TaskEditView) SetTagsOnly(tagsOnly bool) {
ev.tagsOnly = tagsOnly
if tagsOnly {
ev.registry = controller.TagsOnlyEditActions()
ev.refresh()
if ev.actionChangeHandler != nil {
ev.actionChangeHandler()
}
}
}

View file

@ -17,7 +17,7 @@ import (
type PluginView struct {
root *tview.Flex
titleBar tview.Primitive
searchHelper *SearchHelper
inputHelper *InputHelper
lanes *tview.Flex
laneBoxes []*ScrollableList
taskStore store.Store
@ -29,6 +29,7 @@ type PluginView struct {
selectionListenerID int
getLaneTasks func(lane int) []*task.Task // injected from controller
ensureSelection func() bool // injected from controller
actionChangeHandler func()
}
// NewPluginView creates a plugin view
@ -81,10 +82,16 @@ func (pv *PluginView) build() {
pv.lanes = tview.NewFlex().SetDirection(tview.FlexColumn)
pv.laneBoxes = make([]*ScrollableList, 0, len(pv.pluginDef.Lanes))
// search helper - focus returns to lanes container
pv.searchHelper = NewSearchHelper(pv.lanes)
pv.searchHelper.SetCancelHandler(func() {
pv.HideSearch()
// input helper - focus returns to lanes container
pv.inputHelper = NewInputHelper(pv.lanes)
pv.inputHelper.SetCancelHandler(func() {
pv.cancelCurrentInput()
})
pv.inputHelper.SetCloseHandler(func() {
pv.removeInputBoxFromLayout()
})
pv.inputHelper.SetRestorePassiveHandler(func(_ string) {
// layout already has the input box; no rebuild needed
})
// root layout
@ -94,16 +101,18 @@ func (pv *PluginView) build() {
pv.refresh()
}
// rebuildLayout rebuilds the root layout based on current state (search visibility)
// rebuildLayout rebuilds the root layout based on current state
func (pv *PluginView) rebuildLayout() {
pv.root.Clear()
pv.root.AddItem(pv.titleBar, 1, 0, false)
// Restore search box if search is active (e.g., returning from task details)
if pv.pluginConfig.IsSearchActive() {
if pv.inputHelper.IsVisible() {
pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, false)
pv.root.AddItem(pv.lanes, 0, 1, false)
} else if pv.pluginConfig.IsSearchActive() {
query := pv.pluginConfig.GetSearchQuery()
pv.searchHelper.ShowSearch(query)
pv.root.AddItem(pv.searchHelper.GetSearchBox(), config.SearchBoxHeight, 0, false)
pv.inputHelper.Show("> ", query, inputModeSearchPassive)
pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, false)
pv.root.AddItem(pv.lanes, 0, 1, false)
} else {
pv.root.AddItem(pv.lanes, 0, 1, true)
@ -186,6 +195,36 @@ func (pv *PluginView) refresh() {
// Sync scroll offset from view to model for later lane navigation
pv.pluginConfig.SetScrollOffsetForLane(laneIdx, laneContainer.GetScrollOffset())
}
if pv.actionChangeHandler != nil {
pv.actionChangeHandler()
}
}
func (pv *PluginView) GetSelectedID() string {
lane := pv.pluginConfig.GetSelectedLane()
tasks := pv.getLaneTasks(lane)
idx := pv.pluginConfig.GetSelectedIndexForLane(lane)
if idx < 0 || idx >= len(tasks) {
return ""
}
return tasks[idx].ID
}
func (pv *PluginView) SetSelectedID(id string) {
for lane := range pv.pluginDef.Lanes {
for i, t := range pv.getLaneTasks(lane) {
if t.ID == id {
pv.pluginConfig.SetSelectedLane(lane)
pv.pluginConfig.SetSelectedIndexForLane(lane, i)
return
}
}
}
}
func (pv *PluginView) SetActionChangeHandler(handler func()) {
pv.actionChangeHandler = handler
}
// GetPrimitive returns the root tview primitive
@ -225,64 +264,104 @@ func (pv *PluginView) OnBlur() {
pv.pluginConfig.RemoveSelectionListener(pv.selectionListenerID)
}
// ShowSearch displays the search box and returns the primitive to focus
func (pv *PluginView) ShowSearch() tview.Primitive {
if pv.searchHelper.IsVisible() {
return pv.searchHelper.GetSearchBox()
// ShowInputBox displays the input box with the given prompt and initial text.
// If search is currently passive, action-input temporarily replaces it.
func (pv *PluginView) ShowInputBox(prompt, initial string) tview.Primitive {
wasVisible := pv.inputHelper.IsVisible()
inputBox := pv.inputHelper.Show(prompt, initial, inputModeActionInput)
if !wasVisible {
pv.root.Clear()
pv.root.AddItem(pv.titleBar, 1, 0, false)
pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, true)
pv.root.AddItem(pv.lanes, 0, 1, false)
}
query := pv.pluginConfig.GetSearchQuery()
searchBox := pv.searchHelper.ShowSearch(query)
// Rebuild layout with search box
pv.root.Clear()
pv.root.AddItem(pv.titleBar, 1, 0, false)
pv.root.AddItem(pv.searchHelper.GetSearchBox(), config.SearchBoxHeight, 0, true)
pv.root.AddItem(pv.lanes, 0, 1, false)
return searchBox
return inputBox
}
// HideSearch hides the search box and clears search results
func (pv *PluginView) HideSearch() {
if !pv.searchHelper.IsVisible() {
// ShowSearchBox opens the input box in search-editing mode.
func (pv *PluginView) ShowSearchBox() tview.Primitive {
inputBox := pv.inputHelper.ShowSearch("")
pv.root.Clear()
pv.root.AddItem(pv.titleBar, 1, 0, false)
pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, true)
pv.root.AddItem(pv.lanes, 0, 1, false)
return inputBox
}
// HideInputBox hides the input box without touching search state.
func (pv *PluginView) HideInputBox() {
if !pv.inputHelper.IsVisible() {
return
}
pv.inputHelper.Hide()
pv.removeInputBoxFromLayout()
}
pv.searchHelper.HideSearch()
// Clear search results (restores pre-search selection)
pv.pluginConfig.ClearSearchResults()
// Rebuild layout without search box
// removeInputBoxFromLayout rebuilds the layout without the input box and restores focus.
func (pv *PluginView) removeInputBoxFromLayout() {
pv.root.Clear()
pv.root.AddItem(pv.titleBar, 1, 0, false)
pv.root.AddItem(pv.lanes, 0, 1, true)
// explicitly transfer focus to lanes (clears cursor from removed search box)
if pv.searchHelper.GetFocusSetter() != nil {
pv.searchHelper.GetFocusSetter()(pv.lanes)
if pv.inputHelper.GetFocusSetter() != nil {
pv.inputHelper.GetFocusSetter()(pv.lanes)
}
}
// IsSearchVisible returns whether the search box is currently visible
func (pv *PluginView) IsSearchVisible() bool {
return pv.searchHelper.IsVisible()
// cancelCurrentInput handles Esc based on the current input mode.
func (pv *PluginView) cancelCurrentInput() {
switch pv.inputHelper.Mode() {
case inputModeSearchEditing, inputModeSearchPassive:
pv.inputHelper.Hide()
pv.pluginConfig.ClearSearchResults()
pv.removeInputBoxFromLayout()
case inputModeActionInput:
// finishInput handles restore-passive-search vs full-close
pv.inputHelper.finishInput()
default:
pv.inputHelper.Hide()
pv.removeInputBoxFromLayout()
}
}
// IsSearchBoxFocused returns whether the search box currently has focus
func (pv *PluginView) IsSearchBoxFocused() bool {
return pv.searchHelper.HasFocus()
// CancelInputBox triggers mode-aware cancel from the router
func (pv *PluginView) CancelInputBox() {
pv.cancelCurrentInput()
}
// SetSearchSubmitHandler sets the callback for when search is submitted
func (pv *PluginView) SetSearchSubmitHandler(handler func(text string)) {
pv.searchHelper.SetSubmitHandler(handler)
// IsInputBoxVisible returns whether the input box is currently visible
func (pv *PluginView) IsInputBoxVisible() bool {
return pv.inputHelper.IsVisible()
}
// IsInputBoxFocused returns whether the input box currently has focus
func (pv *PluginView) IsInputBoxFocused() bool {
return pv.inputHelper.HasFocus()
}
// IsSearchPassive returns true if search is applied and the input box is passive
func (pv *PluginView) IsSearchPassive() bool {
return pv.inputHelper.IsSearchPassive()
}
// SetInputSubmitHandler sets the callback for when input is submitted
func (pv *PluginView) SetInputSubmitHandler(handler func(text string) controller.InputSubmitResult) {
pv.inputHelper.SetSubmitHandler(handler)
}
// SetInputCancelHandler sets the callback for when input is cancelled
func (pv *PluginView) SetInputCancelHandler(handler func()) {
pv.inputHelper.SetCancelHandler(handler)
}
// SetFocusSetter sets the callback for requesting focus changes
func (pv *PluginView) SetFocusSetter(setter func(p tview.Primitive)) {
pv.searchHelper.SetFocusSetter(setter)
pv.inputHelper.SetFocusSetter(setter)
}
// GetStats returns stats for the header and statusline (Total count of filtered tasks)

View file

@ -0,0 +1,21 @@
---
title:
type: bug
priority: 3
severity: medium
environment:
reportedBy:
foundIn:
dueBy:
regression: false
escalations: 0
---
## Steps to reproduce
1.
## Expected
## Actual

View file

@ -0,0 +1,249 @@
version: 0.5.0
description: |
Bug tracker for triaging and resolving customer-reported issues.
Six statuses (Open → Triaged → In Progress → In Review → Verified, plus Won't Fix),
three task types (Bug, Regression, Incident), and custom fields for severity,
environment, reporter, foundIn version, due date, regression flag, and escalation count.
fields:
- name: severity
type: enum
values: [critical, high, medium, low]
- name: environment
type: enum
values: [prod, staging, dev]
- name: reportedBy
type: text
- name: foundIn
type: text
- name: dueBy
type: datetime
- name: regression
type: boolean
- name: escalations
type: integer
statuses:
- key: open
label: Open
emoji: "\U0001F195"
default: true
- key: triaged
label: Triaged
emoji: "\U0001F4CB"
active: true
- key: inProgress
label: "In Progress"
emoji: "\u2699\uFE0F"
active: true
- key: inReview
label: "In Review"
emoji: "\U0001F440"
active: true
- key: verified
label: Verified
emoji: "\u2705"
done: true
- key: wontFix
label: "Won't Fix"
emoji: "\U0001F6AB"
types:
- key: bug
label: Bug
emoji: "\U0001F41B"
- key: regression
label: Regression
emoji: "\U0001F501"
- key: incident
label: Incident
emoji: "\U0001F525"
views:
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: "R"
label: "Mark regression"
action: update where id = id() set regression=true
- key: "r"
label: "Clear regression"
action: update where id = id() set regression=false
- key: "E"
label: "Escalate"
action: update where id = id() set escalations=escalations + 1
- 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: Triage
description: "Bug triage board — move bugs through the resolution pipeline"
default: true
key: "F1"
lanes:
- name: Open
filter: select where status = "open" order by priority, createdAt
action: update where id = id() set status="open"
- name: Triaged
filter: select where status = "triaged" order by priority, createdAt
action: update where id = id() set status="triaged"
- name: In Progress
filter: select where status = "inProgress" order by priority, createdAt
action: update where id = id() set status="inProgress"
- name: In Review
filter: select where status = "inReview" order by priority, createdAt
action: update where id = id() set status="inReview"
- name: Verified
filter: select where status = "verified" order by priority, createdAt
action: update where id = id() set status="verified"
actions:
- key: "w"
label: "Won't fix"
action: update where id = id() set status="wontFix"
- name: Severity
description: "Bugs grouped by severity level"
key: "F2"
lanes:
- name: Critical
columns: 2
width: 40
filter: select where severity = "critical" and status != "verified" and status != "wontFix" order by priority, createdAt
action: update where id = id() set severity="critical"
- name: High
filter: select where severity = "high" and status != "verified" and status != "wontFix" order by priority, createdAt
action: update where id = id() set severity="high"
- name: Medium
filter: select where severity = "medium" and status != "verified" and status != "wontFix" order by priority, createdAt
action: update where id = id() set severity="medium"
- name: Low
filter: select where severity = "low" and status != "verified" and status != "wontFix" order by priority, createdAt
action: update where id = id() set severity="low"
actions:
- key: "c"
label: "Clear severity"
action: update where id = id() set severity=empty
- name: Environments
description: "Bugs grouped by environment — prod, staging, dev"
key: "F3"
lanes:
- name: Prod
columns: 1
filter: select where environment = "prod" and status != "verified" and status != "wontFix" order by priority, createdAt
action: update where id = id() set environment="prod"
- name: Staging
columns: 1
filter: select where environment = "staging" and status != "verified" and status != "wontFix" order by priority, createdAt
action: update where id = id() set environment="staging"
- name: Dev
columns: 1
filter: select where environment = "dev" and status != "verified" and status != "wontFix" order by priority, createdAt
action: update where id = id() set environment="dev"
- name: Unspecified
columns: 1
filter: select where environment is empty and status != "verified" and status != "wontFix" order by priority, createdAt
- name: SLA Watch
description: "Deadline tracking — overdue, due today, this week, and unscheduled"
key: "F4"
view: expanded
lanes:
- name: Overdue
filter: select where dueBy is not empty and dueBy < now() and status != "verified" and status != "wontFix" order by dueBy
- name: Due Today
filter: select where dueBy >= now() and dueBy < now() + 1day order by priority
- name: This Week
filter: select where dueBy >= now() + 1day and dueBy < now() + 7day order by dueBy
- name: No SLA
filter: select where dueBy is empty and status != "verified" and status != "wontFix" order by priority
actions:
- key: "d"
label: "Clear deadline"
action: update where id = id() set dueBy=empty
- key: "D"
label: "Bump one week"
action: update where id = id() set dueBy=now() + 7day
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
key: "F5"
triggers:
- description: critical bugs must specify environment
ruki: >
before create
where new.severity = "critical" and new.environment is empty
deny "critical bugs require environment"
- description: bugs must pass through review before verification
ruki: >
before update
where new.status = "verified" and old.status != "inReview"
deny "bugs must pass through review before verification"
- description: per-assignee WIP limit of 3 in-progress bugs
ruki: >
before update
where new.status = "inProgress"
and count(select where assignee = new.assignee and status = "inProgress") > 2
deny "assignee already at WIP limit (3 in-progress bugs)"
- description: cannot delete an active bug
ruki: >
before delete
where old.status = "inProgress" or old.status = "inReview"
deny "cannot delete an active bug — move to wontFix or verified first"
- description: auto-prioritize critical bugs and set 24h SLA
ruki: >
after create
where new.severity = "critical"
update where id = new.id set priority=1 dueBy=now() + 1day
- description: tag and bump priority when regression is flagged
ruki: >
after update
where new.regression = true and old.regression = false
update where id = new.id set tags=tags + ["regression"] priority=1
- description: clear SLA on closure
ruki: >
after update
where new.status = "verified" or new.status = "wontFix"
update where id = new.id set dueBy=empty
- description: remove deleted task from dependency lists
ruki: >
after delete
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
- description: auto-escalate overdue critical bugs every hour
ruki: >
every 1hour
update where severity = "critical" and dueBy < now() and status != "verified" and status != "wontFix"
set escalations=escalations + 1
- description: purge closed bugs older than 30 days
ruki: >
every 1day
delete where (status = "verified" or status = "wontFix") and updatedAt < now() - 30day
- description: demote stale in-progress bugs back to triaged
ruki: >
every 1day
update where status = "inProgress" and updatedAt < now() - 7day
set status="triaged"

7
workflows/kanban/new.md Normal file
View file

@ -0,0 +1,7 @@
---
title:
points: 1
priority: 3
tags:
- idea
---

View file

@ -0,0 +1,181 @@
version: 0.5.0
description: |
Kanban-style workflow for small-team software work. Five statuses
(Backlog → Ready → In Progress → Review → Done) and four task types
(Story, Bug, Spike, Epic). Includes an "assign to me" action.
statuses:
- key: backlog
label: Backlog
emoji: "📥"
default: true
- key: ready
label: Ready
emoji: "📋"
active: true
- key: inProgress
label: "In Progress"
emoji: "⚙️"
active: true
- key: review
label: Review
emoji: "👀"
active: true
- key: done
label: Done
emoji: "✅"
done: true
types:
- key: story
label: Story
emoji: "🌀"
- key: bug
label: Bug
emoji: "💥"
- key: spike
label: Spike
emoji: "🔍"
- key: epic
label: Epic
emoji: "🗂️"
views:
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
- key: "y"
label: "Copy ID"
action: select id where id = id() | clipboard()
- key: "Y"
label: "Copy content"
action: select title, description where id = id() | clipboard()
- key: "+"
label: "Priority up"
action: update where id = id() set priority = priority - 1
- key: "-"
label: "Priority down"
action: update where id = id() set priority = priority + 1
- key: "u"
label: "Flag urgent"
action: update where id = id() set priority=1 tags=tags+["urgent"]
hot: false
- key: "A"
label: "Assign to..."
action: update where id = id() set assignee=input()
input: string
hot: false
- key: "t"
label: "Add tag"
action: update where id = id() set tags=tags+[input()]
input: string
hot: false
- key: "T"
label: "Remove tag"
action: update where id = id() set tags=tags-[input()]
input: string
hot: false
plugins:
- name: Kanban
description: "Move tiki to change status, search, create or delete\nShift Left/Right to move"
default: true
key: "F1"
lanes:
- name: Ready
filter: select where status = "ready" and type != "epic" order by priority, createdAt
action: update where id = id() set status="ready"
- name: In Progress
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
action: update where id = id() set status="inProgress"
- name: Review
filter: select where status = "review" and type != "epic" order by priority, createdAt
action: update where id = id() set status="review"
- name: Done
filter: select where status = "done" and type != "epic" order by priority, createdAt
action: update where id = id() set status="done"
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
key: Ctrl-R
lanes:
- name: Recent
columns: 4
filter: select where now() - updatedAt < 24hour order by updatedAt desc
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
key: "F4"
lanes:
- name: Now
columns: 1
width: 25
filter: select where type = "epic" and status = "ready" order by priority, points desc
action: update where id = id() set status="ready"
- name: Next
columns: 1
width: 25
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=1
- name: Later
columns: 2
width: 50
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
key: "F2"
triggers:
- description: block completion with open dependencies
ruki: >
before update
where new.status = "done" and new.dependsOn any status != "done"
deny "cannot complete: has open dependencies"
- description: tasks must pass through review before completion
ruki: >
before update
where new.status = "done" and old.status != "review"
deny "tasks must go through review before marking done"
- description: remove deleted task from dependency lists
ruki: >
after delete
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
- description: clean up completed tasks after 24 hours
ruki: >
every 1day
delete where status = "done" and updatedAt < now() - 1day
- description: tasks must have an assignee before starting
ruki: >
before update
where new.status = "inProgress" and new.assignee is empty
deny "assign someone before moving to in-progress"
- description: auto-complete epics when all child tasks finish
ruki: >
after update
where new.status = "done" and new.type != "epic"
update where type = "epic" and new.id in dependsOn and dependsOn all status = "done"
set status="done"
- description: cannot delete tasks that are actively being worked
ruki: >
before delete
where old.status = "inProgress"
deny "cannot delete an in-progress task — move to backlog or done first"
- description: spawn next occurrence when recurring task completes
ruki: >
after update
where new.status = "done" and old.recurrence is not empty
create title=old.title priority=old.priority tags=old.tags
recurrence=old.recurrence due=next_date(old.recurrence) status="backlog"

4
workflows/todo/new.md Normal file
View file

@ -0,0 +1,4 @@
---
title:
priority: 3
---

View file

@ -0,0 +1,59 @@
version: 0.5.0
description: |
Minimal two-lane todo list. One task type, two statuses (Todo → Done).
Use when you just want a lightweight checklist and don't need prioritization,
multiple task types, or review stages.
statuses:
- key: todo
label: Todo
emoji: "📌"
default: true
active: true
- key: done
label: Done
emoji: "✅"
done: true
types:
- key: task
label: Task
emoji: "📝"
views:
actions:
- 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: "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: Todo
description: "Simple todo list with two lanes"
default: true
key: "F1"
lanes:
- name: Todo
columns: 3
filter: select where status = "todo" order by priority, createdAt
action: update where id = id() set status="todo"
- name: Done
columns: 1
filter: select where status = "done" order by updatedAt desc
action: update where id = id() set status="done"
triggers:
- description: clean up completed tasks after 24 hours
ruki: >
every 1day
delete where status = "done" and updatedAt < now() - 1day