diff --git a/.doc/doki/doc/config.md b/.doc/doki/doc/config.md index a477a97..ac12cf3 100644 --- a/.doc/doki/doc/config.md +++ b/.doc/doki/doc/config.md @@ -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" diff --git a/.doc/doki/doc/customization.md b/.doc/doki/doc/customization.md index a78c8c7..dbb2a3e 100644 --- a/.doc/doki/doc/customization.md +++ b/.doc/doki/doc/customization.md @@ -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). \ No newline at end of file diff --git a/.doc/doki/doc/ruki/operators-and-builtins.md b/.doc/doki/doc/ruki/operators-and-builtins.md index e9f3234..f02b2f2 100644 --- a/.doc/doki/doc/ruki/operators-and-builtins.md +++ b/.doc/doki/doc/ruki/operators-and-builtins.md @@ -210,6 +210,7 @@ create title="x" dependsOn=dependsOn + tags | `next_date(...)` | `date` | exactly 1 | argument must be `recurrence` | | `blocks(...)` | `list` | 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(...)` diff --git a/.doc/doki/doc/ruki/validation-and-errors.md b/.doc/doki/doc/ruki/validation-and-errors.md index 3268d51..c89cf63 100644 --- a/.doc/doki/doc/ruki/validation-and-errors.md +++ b/.doc/doki/doc/ruki/validation-and-errors.md @@ -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`. + diff --git a/config/colors.go b/config/colors.go index 91ed124..0d69d5f 100644 --- a/config/colors.go +++ b/config/colors.go @@ -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, diff --git a/config/default_workflow.yaml b/config/default_workflow.yaml index 09cac6f..453283d 100644 --- a/config/default_workflow.yaml +++ b/config/default_workflow.yaml @@ -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" diff --git a/config/dimensions.go b/config/dimensions.go index bd62c78..40e09ea 100644 --- a/config/dimensions.go +++ b/config/dimensions.go @@ -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 diff --git a/controller/doki_controller.go b/controller/doki_controller.go index ab703dd..4d25e36 100644 --- a/controller/doki_controller.go +++ b/controller/doki_controller.go @@ -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 } diff --git a/controller/input_router.go b/controller/input_router.go index b5b3363..098c7a8 100644 --- a/controller/input_router.go +++ b/controller/input_router.go @@ -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 diff --git a/controller/interfaces.go b/controller/interfaces.go index c727994..546251e 100644 --- a/controller/interfaces.go +++ b/controller/interfaces.go @@ -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)) diff --git a/controller/plugin.go b/controller/plugin.go index 63df2fe..a093d7e 100644 --- a/controller/plugin.go +++ b/controller/plugin.go @@ -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 == "" { diff --git a/controller/plugin_base.go b/controller/plugin_base.go index 79bcdfc..91d0cdf 100644 --- a/controller/plugin_base.go +++ b/controller/plugin_base.go @@ -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) diff --git a/controller/plugin_selection_test.go b/controller/plugin_selection_test.go index 2c72de2..a02f032 100644 --- a/controller/plugin_selection_test.go +++ b/controller/plugin_selection_test.go @@ -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 diff --git a/integration/input_action_test.go b/integration/input_action_test.go new file mode 100644 index 0000000..00674fc --- /dev/null +++ b/integration/input_action_test.go @@ -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) + } +} diff --git a/plugin/definition.go b/plugin/definition.go index a241132..faf7fb1 100644 --- a/plugin/definition.go +++ b/plugin/definition.go @@ -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. diff --git a/plugin/parser.go b/plugin/parser.go index 440268c..37ad0b5 100644 --- a/plugin/parser.go +++ b/plugin/parser.go @@ -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) - if err != nil { - return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err) + var ( + actionStmt *ruki.ValidatedStatement + inputType ruki.ValueType + hasInput bool + ) + + if cfg.Input != "" { + typ, err := ruki.ParseScalarTypeName(cfg.Input) + if err != nil { + return nil, fmt.Errorf("action %d (key %q) input: %w", i, cfg.Key, err) + } + actionStmt, err = parser.ParseAndValidateStatementWithInput(cfg.Action, ruki.ExecutorRuntimePlugin, typ) + if err != nil { + return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err) + } + if !actionStmt.UsesInputBuiltin() { + return nil, fmt.Errorf("action %d (key %q) declares 'input: %s' but does not use input()", i, cfg.Key, cfg.Input) + } + inputType = typ + hasInput = true + } else { + var err error + actionStmt, err = parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin) + if err != nil { + return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err) + } } + showInHeader := true if cfg.Hot != nil { showInHeader = *cfg.Hot @@ -219,6 +243,8 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl Label: cfg.Label, Action: actionStmt, ShowInHeader: showInHeader, + InputType: inputType, + HasInput: hasInput, }) } diff --git a/plugin/parser_test.go b/plugin/parser_test.go index b42f2f8..22d8fc8 100644 --- a/plugin/parser_test.go +++ b/plugin/parser_test.go @@ -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") + } +} diff --git a/ruki/executor.go b/ruki/executor.go index 65c9493..0580321 100644 --- a/ruki/executor.go +++ b/ruki/executor.go @@ -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") diff --git a/ruki/executor_runtime.go b/ruki/executor_runtime.go index 943b1ce..b4a7700 100644 --- a/ruki/executor_runtime.go +++ b/ruki/executor_runtime.go @@ -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") diff --git a/ruki/input_builtin_test.go b/ruki/input_builtin_test.go new file mode 100644 index 0000000..4610e37 --- /dev/null +++ b/ruki/input_builtin_test.go @@ -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") + } +} diff --git a/ruki/lower.go b/ruki/lower.go index 190ec5d..cb628c8 100644 --- a/ruki/lower.go +++ b/ruki/lower.go @@ -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 } diff --git a/ruki/parse_scalar.go b/ruki/parse_scalar.go new file mode 100644 index 0000000..24a5d6a --- /dev/null +++ b/ruki/parse_scalar.go @@ -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)) + } +} diff --git a/ruki/parse_scalar_test.go b/ruki/parse_scalar_test.go new file mode 100644 index 0000000..a6119dd --- /dev/null +++ b/ruki/parse_scalar_test.go @@ -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") + } +} diff --git a/ruki/parser.go b/ruki/parser.go index b78e7f8..d69d3bf 100644 --- a/ruki/parser.go +++ b/ruki/parser.go @@ -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. diff --git a/ruki/semantic_validate.go b/ruki/semantic_validate.go index 551fc46..e146c88 100644 --- a/ruki/semantic_validate.go +++ b/ruki/semantic_validate.go @@ -22,14 +22,16 @@ func (e *UnvalidatedWrapperError) Error() string { // ValidatedStatement is an immutable, semantically validated statement wrapper. type ValidatedStatement struct { - seal *validationSeal - runtime ExecutorRuntimeMode - usesIDFunc bool - statement *Statement + seal *validationSeal + runtime ExecutorRuntimeMode + usesIDFunc bool + usesInputFunc bool + statement *Statement } func (v *ValidatedStatement) RuntimeMode() ExecutorRuntimeMode { return v.runtime } func (v *ValidatedStatement) UsesIDBuiltin() bool { return v.usesIDFunc } +func (v *ValidatedStatement) UsesInputBuiltin() bool { return v.usesInputFunc } func (v *ValidatedStatement) RequiresCreateTemplate() bool { return v != nil && v.statement != nil && v.statement.Create != nil } @@ -199,6 +201,21 @@ func (p *Parser) ParseAndValidateStatement(input string, runtime ExecutorRuntime return NewSemanticValidator(runtime).ValidateStatement(stmt) } +// ParseAndValidateStatementWithInput parses a statement with an input() type +// declaration and applies runtime-aware semantic validation. The inputType is +// set on the parser for the duration of the parse so that inferExprType can +// resolve input() calls. +func (p *Parser) ParseAndValidateStatementWithInput(input string, runtime ExecutorRuntimeMode, inputType ValueType) (*ValidatedStatement, error) { + p.inputType = &inputType + defer func() { p.inputType = nil }() + + stmt, err := p.ParseStatement(input) + if err != nil { + return nil, err + } + return NewSemanticValidator(runtime).ValidateStatement(stmt) +} + // ParseAndValidateTrigger parses an event trigger and applies runtime-aware semantic validation. func (p *Parser) ParseAndValidateTrigger(input string, runtime ExecutorRuntimeMode) (*ValidatedTrigger, error) { trig, err := p.ParseTrigger(input) @@ -259,14 +276,22 @@ func (v *SemanticValidator) ValidateStatement(stmt *Statement) (*ValidatedStatem if usesID && v.runtime != ExecutorRuntimePlugin { return nil, fmt.Errorf("id() is only available in plugin runtime") } + inputCount, err := countInputUsage(stmt) + if err != nil { + return nil, err + } + if inputCount > 1 { + return nil, fmt.Errorf("input() may only be used once per action") + } if err := validateStatementAssignmentsSemantics(stmt); err != nil { return nil, err } return &ValidatedStatement{ - seal: validatedSeal, - runtime: v.runtime, - usesIDFunc: usesID, - statement: cloneStatement(stmt), + seal: validatedSeal, + runtime: v.runtime, + usesIDFunc: usesID, + usesInputFunc: inputCount == 1, + statement: cloneStatement(stmt), }, nil } @@ -503,54 +528,199 @@ func scanConditionSemantics(cond Condition) (usesID bool, hasCall bool, err erro } } +type semanticFlags struct { + usesID bool + hasCall bool + inputCount int +} + +func (f *semanticFlags) merge(other semanticFlags) { + f.usesID = f.usesID || other.usesID + f.hasCall = f.hasCall || other.hasCall + f.inputCount += other.inputCount +} + func scanExprSemantics(expr Expr) (usesID bool, hasCall bool, err error) { + flags, err := scanExprSemanticsEx(expr) + if err != nil { + return false, false, err + } + return flags.usesID, flags.hasCall, nil +} + +func scanExprSemanticsEx(expr Expr) (semanticFlags, error) { + var f semanticFlags if expr == nil { - return false, false, nil + return f, nil } switch e := expr.(type) { case *FunctionCall: if e.Name == "id" { - usesID = true + f.usesID = true } if e.Name == "call" { - hasCall = true + f.hasCall = true + } + if e.Name == "input" { + f.inputCount++ } for _, arg := range e.Args { - u, c, err := scanExprSemantics(arg) + af, err := scanExprSemanticsEx(arg) if err != nil { - return false, false, err + return f, err } - usesID = usesID || u - hasCall = hasCall || c + f.merge(af) } - return usesID, hasCall, nil + return f, nil case *BinaryExpr: - u1, c1, err := scanExprSemantics(e.Left) + lf, err := scanExprSemanticsEx(e.Left) if err != nil { - return false, false, err + return f, err } - u2, c2, err := scanExprSemantics(e.Right) + rf, err := scanExprSemanticsEx(e.Right) if err != nil { - return false, false, err + return f, err } - return u1 || u2, c1 || c2, nil + f.merge(lf) + f.merge(rf) + return f, nil case *ListLiteral: for _, elem := range e.Elements { - u, c, err := scanExprSemantics(elem) + ef, err := scanExprSemanticsEx(elem) if err != nil { - return false, false, err + return f, err } - usesID = usesID || u - hasCall = hasCall || c + f.merge(ef) } - return usesID, hasCall, nil + return f, nil case *SubQuery: - return scanConditionSemantics(e.Where) + sf, err := scanConditionSemanticsEx(e.Where) + if err != nil { + return f, err + } + f.merge(sf) + return f, nil default: - return false, false, nil + return f, nil } } +func scanConditionSemanticsEx(cond Condition) (semanticFlags, error) { + var f semanticFlags + if cond == nil { + return f, nil + } + switch c := cond.(type) { + case *BinaryCondition: + lf, err := scanConditionSemanticsEx(c.Left) + if err != nil { + return f, err + } + rf, err := scanConditionSemanticsEx(c.Right) + if err != nil { + return f, err + } + f.merge(lf) + f.merge(rf) + return f, nil + case *NotCondition: + return scanConditionSemanticsEx(c.Inner) + case *CompareExpr: + lf, err := scanExprSemanticsEx(c.Left) + if err != nil { + return f, err + } + rf, err := scanExprSemanticsEx(c.Right) + if err != nil { + return f, err + } + f.merge(lf) + f.merge(rf) + return f, nil + case *IsEmptyExpr: + return scanExprSemanticsEx(c.Expr) + case *InExpr: + vf, err := scanExprSemanticsEx(c.Value) + if err != nil { + return f, err + } + cf, err := scanExprSemanticsEx(c.Collection) + if err != nil { + return f, err + } + f.merge(vf) + f.merge(cf) + return f, nil + case *QuantifierExpr: + ef, err := scanExprSemanticsEx(c.Expr) + if err != nil { + return f, err + } + cf, err := scanConditionSemanticsEx(c.Condition) + if err != nil { + return f, err + } + f.merge(ef) + f.merge(cf) + return f, nil + default: + return f, fmt.Errorf("unknown condition type %T", c) + } +} + +func countInputUsage(stmt *Statement) (int, error) { + var total semanticFlags + switch { + case stmt.Select != nil: + if stmt.Select.Where != nil { + f, err := scanConditionSemanticsEx(stmt.Select.Where) + if err != nil { + return 0, err + } + total.merge(f) + } + if stmt.Select.Pipe != nil && stmt.Select.Pipe.Run != nil { + f, err := scanExprSemanticsEx(stmt.Select.Pipe.Run.Command) + if err != nil { + return 0, err + } + total.merge(f) + } + case stmt.Create != nil: + for _, a := range stmt.Create.Assignments { + f, err := scanExprSemanticsEx(a.Value) + if err != nil { + return 0, err + } + total.merge(f) + } + case stmt.Update != nil: + if stmt.Update.Where != nil { + f, err := scanConditionSemanticsEx(stmt.Update.Where) + if err != nil { + return 0, err + } + total.merge(f) + } + for _, a := range stmt.Update.Set { + f, err := scanExprSemanticsEx(a.Value) + if err != nil { + return 0, err + } + total.merge(f) + } + case stmt.Delete != nil: + if stmt.Delete.Where != nil { + f, err := scanConditionSemanticsEx(stmt.Delete.Where) + if err != nil { + return 0, err + } + total.merge(f) + } + } + return total.inputCount, nil +} + func cloneStatement(stmt *Statement) *Statement { if stmt == nil { return nil diff --git a/ruki/validate.go b/ruki/validate.go index 497ffbf..17a79de 100644 --- a/ruki/validate.go +++ b/ruki/validate.go @@ -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) diff --git a/view/input_box.go b/view/input_box.go new file mode 100644 index 0000000..636be94 --- /dev/null +++ b/view/input_box.go @@ -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 +} diff --git a/view/input_helper.go b/view/input_helper.go new file mode 100644 index 0000000..704a62d --- /dev/null +++ b/view/input_helper.go @@ -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 +} diff --git a/view/palette/action_palette.go b/view/palette/action_palette.go index 8f51925..063baec 100644 --- a/view/palette/action_palette.go +++ b/view/palette/action_palette.go @@ -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()). diff --git a/view/search_box.go b/view/search_box.go deleted file mode 100644 index f7139d4..0000000 --- a/view/search_box.go +++ /dev/null @@ -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 -} diff --git a/view/search_helper.go b/view/search_helper.go deleted file mode 100644 index d47bb4a..0000000 --- a/view/search_helper.go +++ /dev/null @@ -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 -} diff --git a/view/tiki_plugin_view.go b/view/tiki_plugin_view.go index 35557c7..e6c50c3 100644 --- a/view/tiki_plugin_view.go +++ b/view/tiki_plugin_view.go @@ -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() + + inputBox := pv.inputHelper.Show(prompt, initial, inputModeActionInput) + + if !wasVisible { + pv.root.Clear() + pv.root.AddItem(pv.titleBar, 1, 0, false) + pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, true) + pv.root.AddItem(pv.lanes, 0, 1, false) } - query := pv.pluginConfig.GetSearchQuery() - searchBox := pv.searchHelper.ShowSearch(query) - - // Rebuild layout with search box - pv.root.Clear() - pv.root.AddItem(pv.titleBar, 1, 0, false) - pv.root.AddItem(pv.searchHelper.GetSearchBox(), config.SearchBoxHeight, 0, true) - pv.root.AddItem(pv.lanes, 0, 1, false) - - return searchBox + return inputBox } -// HideSearch hides the search box and clears search results -func (pv *PluginView) HideSearch() { - if !pv.searchHelper.IsVisible() { +// ShowSearchBox opens the input box in search-editing mode. +func (pv *PluginView) ShowSearchBox() tview.Primitive { + inputBox := pv.inputHelper.ShowSearch("") + + pv.root.Clear() + pv.root.AddItem(pv.titleBar, 1, 0, false) + pv.root.AddItem(pv.inputHelper.GetInputBox(), config.InputBoxHeight, 0, true) + pv.root.AddItem(pv.lanes, 0, 1, false) + + return inputBox +} + +// HideInputBox hides the input box without touching search state. +func (pv *PluginView) HideInputBox() { + if !pv.inputHelper.IsVisible() { return } + pv.inputHelper.Hide() + pv.removeInputBoxFromLayout() +} - pv.searchHelper.HideSearch() - - // Clear search results (restores pre-search selection) - pv.pluginConfig.ClearSearchResults() - - // Rebuild layout without search box +// removeInputBoxFromLayout rebuilds the layout without the input box and restores focus. +func (pv *PluginView) removeInputBoxFromLayout() { pv.root.Clear() pv.root.AddItem(pv.titleBar, 1, 0, false) pv.root.AddItem(pv.lanes, 0, 1, true) - // explicitly transfer focus to lanes (clears cursor from removed search box) - if pv.searchHelper.GetFocusSetter() != nil { - pv.searchHelper.GetFocusSetter()(pv.lanes) + if pv.inputHelper.GetFocusSetter() != nil { + pv.inputHelper.GetFocusSetter()(pv.lanes) } } -// IsSearchVisible returns whether the search box is currently visible -func (pv *PluginView) IsSearchVisible() bool { - return pv.searchHelper.IsVisible() +// cancelCurrentInput handles Esc based on the current input mode. +func (pv *PluginView) cancelCurrentInput() { + switch pv.inputHelper.Mode() { + case inputModeSearchEditing, inputModeSearchPassive: + pv.inputHelper.Hide() + pv.pluginConfig.ClearSearchResults() + pv.removeInputBoxFromLayout() + case inputModeActionInput: + // finishInput handles restore-passive-search vs full-close + pv.inputHelper.finishInput() + default: + pv.inputHelper.Hide() + pv.removeInputBoxFromLayout() + } } -// IsSearchBoxFocused returns whether the search box currently has focus -func (pv *PluginView) IsSearchBoxFocused() bool { - return pv.searchHelper.HasFocus() +// CancelInputBox triggers mode-aware cancel from the router +func (pv *PluginView) CancelInputBox() { + pv.cancelCurrentInput() } -// SetSearchSubmitHandler sets the callback for when search is submitted -func (pv *PluginView) SetSearchSubmitHandler(handler func(text string)) { - pv.searchHelper.SetSubmitHandler(handler) +// IsInputBoxVisible returns whether the input box is currently visible +func (pv *PluginView) IsInputBoxVisible() bool { + return pv.inputHelper.IsVisible() +} + +// IsInputBoxFocused returns whether the input box currently has focus +func (pv *PluginView) IsInputBoxFocused() bool { + return pv.inputHelper.HasFocus() +} + +// IsSearchPassive returns true if search is applied and the input box is passive +func (pv *PluginView) IsSearchPassive() bool { + return pv.inputHelper.IsSearchPassive() +} + +// SetInputSubmitHandler sets the callback for when input is submitted +func (pv *PluginView) SetInputSubmitHandler(handler func(text string) controller.InputSubmitResult) { + pv.inputHelper.SetSubmitHandler(handler) +} + +// SetInputCancelHandler sets the callback for when input is cancelled +func (pv *PluginView) SetInputCancelHandler(handler func()) { + pv.inputHelper.SetCancelHandler(handler) } // SetFocusSetter sets the callback for requesting focus changes func (pv *PluginView) SetFocusSetter(setter func(p tview.Primitive)) { - pv.searchHelper.SetFocusSetter(setter) + pv.inputHelper.SetFocusSetter(setter) } // GetStats returns stats for the header and statusline (Total count of filtered tasks) diff --git a/workflows/bug-tracker/workflow.yaml b/workflows/bug-tracker/workflow.yaml index 68dceec..a7531c5 100644 --- a/workflows/bug-tracker/workflow.yaml +++ b/workflows/bug-tracker/workflow.yaml @@ -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" diff --git a/workflows/kanban/workflow.yaml b/workflows/kanban/workflow.yaml index f91b68a..88cf366 100644 --- a/workflows/kanban/workflow.yaml +++ b/workflows/kanban/workflow.yaml @@ -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" diff --git a/workflows/todo/workflow.yaml b/workflows/todo/workflow.yaml index f70c47a..74fac7d 100644 --- a/workflows/todo/workflow.yaml +++ b/workflows/todo/workflow.yaml @@ -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"