input action

This commit is contained in:
booleanmaybe 2026-04-19 16:32:10 -04:00
parent f377bb54dd
commit 232737940b
35 changed files with 2430 additions and 348 deletions

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"

View file

@ -236,6 +236,7 @@ Each action has:
- `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:
@ -252,6 +253,52 @@ For example, pressing `b` in the Backlog plugin changes the selected tiki's stat
`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).
@ -324,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

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

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

@ -58,6 +58,21 @@ views:
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"

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

@ -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.
@ -103,6 +106,13 @@ func (ir *InputRouter) SetPaletteConfig(pc *model.ActionPaletteConfig) {
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()))
// if the input box is focused, let it handle all input (including '?' and F10)
if activeView := ir.navController.GetActiveView(); activeView != nil {
if iv, ok := activeView.(InputableView); ok && iv.IsInputBoxFocused() {
return false
}
}
// pre-gate: ActionOpenPalette (?) and ActionToggleHeader (F10) must fire before
// task-edit Prepare and before search/fullscreen/editor gates, so they stay truly
// global without triggering edit-session setup or focus churn.
@ -125,7 +135,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 {
@ -158,20 +168,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
@ -283,28 +293,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
}
@ -365,6 +376,13 @@ func (ir *InputRouter) HandleAction(id ActionID, currentView *ViewEntry) bool {
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)
@ -482,7 +500,6 @@ func (ir *InputRouter) dispatchTaskEditAction(id ActionID, activeView View) bool
// dispatchPluginAction handles palette-dispatched plugin actions by ActionID.
func (ir *InputRouter) dispatchPluginAction(id ActionID, viewID model.ViewID) bool {
// handle plugin activation (switch to plugin)
if targetPluginName := GetPluginNameFromAction(id); targetPluginName != "" {
targetViewID := model.MakePluginViewID(targetPluginName)
if viewID != targetViewID {
@ -498,35 +515,81 @@ func (ir *InputRouter) dispatchPluginAction(id ActionID, viewID model.ViewID) bo
return false
}
// search action needs special wiring
if id == ActionSearch {
return ir.handleSearchAction(ctrl)
return ir.handleSearchInput(ctrl)
}
if _, _, hasInput := ctrl.GetActionInputSpec(id); hasInput {
return ir.startActionInput(ctrl, id)
}
return ctrl.HandleAction(id)
}
// handleSearchAction is a generic handler for ActionSearch across all searchable views
func (ir *InputRouter) handleSearchAction(controller interface{ HandleSearch(string) }) bool {
activeView := ir.navController.GetActiveView()
searchableView, ok := activeView.(SearchableView)
// 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))

View file

@ -143,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
}
@ -178,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:
@ -232,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:
@ -248,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,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_PaletteBlockedDuringModal(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")
}
// '?' should be typed into the input box as text, not open the palette
ta.SendKey(tcell.KeyRune, '?', tcell.ModNone)
if ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should not open while input box is editing")
}
// cancel and 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

@ -84,6 +84,7 @@ type PluginActionConfig struct {
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.
@ -92,6 +93,8 @@ type PluginAction struct {
Label string
Action *ruki.ValidatedStatement
ShowInHeader bool
InputType ruki.ValueType
HasInput bool
}
// PluginLaneConfig represents a lane in YAML or config definitions.

View file

@ -206,10 +206,34 @@ 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)
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
@ -219,6 +243,8 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl
Label: cfg.Label,
Action: actionStmt,
ShowInHeader: showInHeader,
InputType: inputType,
HasInput: hasInput,
})
}

View file

@ -839,3 +839,90 @@ actions:
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

@ -25,11 +25,13 @@ type ValidatedStatement struct {
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,6 +276,13 @@ 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
}
@ -266,6 +290,7 @@ func (v *SemanticValidator) ValidateStatement(stmt *Statement) (*ValidatedStatem
seal: validatedSeal,
runtime: v.runtime,
usesIDFunc: usesID,
usesInputFunc: inputCount == 1,
statement: cloneStatement(stmt),
}, nil
}
@ -503,52 +528,197 @@ 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)
default:
return false, false, nil
sf, err := scanConditionSemanticsEx(e.Where)
if err != nil {
return f, err
}
f.merge(sf)
return f, nil
default:
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 {

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)

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

@ -74,7 +74,7 @@ func NewActionPalette(
ap.filterInput.SetLabel(" ")
ap.filterInput.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
ap.filterInput.SetFieldTextColor(colors.InputFieldTextColor.TCell())
ap.filterInput.SetLabelColor(colors.SearchBoxLabelColor.TCell())
ap.filterInput.SetLabelColor(colors.InputBoxLabelColor.TCell())
ap.filterInput.SetPlaceholder("Type to search")
ap.filterInput.SetPlaceholderStyle(tcell.StyleDefault.
Foreground(colors.TaskDetailPlaceholderColor.TCell()).

View file

@ -1,104 +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()
inputField.SetLabel("> ")
inputField.SetLabelColor(colors.SearchBoxLabelColor.TCell())
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
inputField.SetBorder(true)
inputField.SetBorderColor(colors.TaskBoxUnselectedBorder.TCell())
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
}
// 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

@ -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
@ -82,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
@ -95,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)
@ -256,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()
query := pv.pluginConfig.GetSearchQuery()
searchBox := pv.searchHelper.ShowSearch(query)
inputBox := pv.inputHelper.Show(prompt, initial, inputModeActionInput)
// Rebuild layout with search box
if !wasVisible {
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.inputHelper.GetInputBox(), config.InputBoxHeight, 0, true)
pv.root.AddItem(pv.lanes, 0, 1, false)
return searchBox
}
// HideSearch hides the search box and clears search results
func (pv *PluginView) HideSearch() {
if !pv.searchHelper.IsVisible() {
return
}
pv.searchHelper.HideSearch()
return inputBox
}
// Clear search results (restores pre-search selection)
pv.pluginConfig.ClearSearchResults()
// ShowSearchBox opens the input box in search-editing mode.
func (pv *PluginView) ShowSearchBox() tview.Primitive {
inputBox := pv.inputHelper.ShowSearch("")
// Rebuild layout without search box
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()
}
// 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

@ -83,6 +83,21 @@ views:
- 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"

View file

@ -59,6 +59,21 @@ views:
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"

View file

@ -26,6 +26,16 @@ views:
- 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"