tiki/controller/input_router.go
2026-04-18 23:09:01 -04:00

649 lines
20 KiB
Go

package controller
import (
"log/slog"
"path/filepath"
"strings"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/service"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// PluginControllerInterface defines the common interface for all plugin controllers
type PluginControllerInterface interface {
GetActionRegistry() *ActionRegistry
GetPluginName() string
HandleAction(ActionID) bool
HandleSearch(string)
ShowNavigation() bool
}
// TikiViewProvider is implemented by controllers that back a TikiPlugin view.
// The view factory uses this to create PluginView without knowing the concrete controller type.
type TikiViewProvider interface {
GetFilteredTasksForLane(lane int) []*task.Task
EnsureFirstNonEmptyLaneSelection() bool
GetActionRegistry() *ActionRegistry
ShowNavigation() bool
}
// InputRouter dispatches input events to appropriate controllers
// InputRouter is a dispatcher. It doesn't know what to do with actions—it only knows where to send them
// - Receive a raw key event
// - Determine which controller should handle it (based on current view)
// - Forward the event to that controller
// - Return whether the event was consumed
type InputRouter struct {
navController *NavigationController
taskController *TaskController
taskEditCoord *TaskEditCoordinator
pluginControllers map[string]PluginControllerInterface // keyed by plugin name
globalActions *ActionRegistry
taskStore store.Store
mutationGate *service.TaskMutationGate
statusline *model.StatuslineConfig
schema ruki.Schema
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
headerConfig *model.HeaderConfig
paletteConfig *model.ActionPaletteConfig
}
// NewInputRouter creates an input router
func NewInputRouter(
navController *NavigationController,
taskController *TaskController,
pluginControllers map[string]PluginControllerInterface,
taskStore store.Store,
mutationGate *service.TaskMutationGate,
statusline *model.StatuslineConfig,
schema ruki.Schema,
) *InputRouter {
return &InputRouter{
navController: navController,
taskController: taskController,
taskEditCoord: NewTaskEditCoordinator(navController, taskController),
pluginControllers: pluginControllers,
globalActions: DefaultGlobalActions(),
taskStore: taskStore,
mutationGate: mutationGate,
statusline: statusline,
schema: schema,
}
}
// SetHeaderConfig wires the header config for fullscreen-aware header toggling.
func (ir *InputRouter) SetHeaderConfig(hc *model.HeaderConfig) {
ir.headerConfig = hc
}
// SetPaletteConfig wires the palette config for ActionOpenPalette dispatch.
func (ir *InputRouter) SetPaletteConfig(pc *model.ActionPaletteConfig) {
ir.paletteConfig = pc
}
// HandleInput processes a key event for the current view and routes it to the appropriate handler.
// It processes events through multiple handlers in order:
// 1. Search input (if search is active)
// 2. Fullscreen escape (Esc key in fullscreen views)
// 3. Inline editors (title/description editing)
// 4. Task edit field focus (field navigation)
// 5. Global actions (Esc, Refresh)
// 6. View-specific actions (based on current view)
// Returns true if the event was handled, false otherwise.
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()))
// 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.
if action := ir.globalActions.Match(event); action != nil {
if action.ID == ActionOpenPalette || action.ID == ActionToggleHeader {
return ir.handleGlobalAction(action.ID)
}
}
if currentView == nil {
return false
}
activeView := ir.navController.GetActiveView()
isTaskEditView := currentView.ViewID == model.TaskEditViewID
// ensure task edit view is prepared even when title/description inputs have focus
if isTaskEditView {
ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(currentView.Params))
}
if stop, handled := ir.maybeHandleSearchInput(activeView, event); stop {
return handled
}
if stop, handled := ir.maybeHandleFullscreenEscape(activeView, event); stop {
return handled
}
if stop, handled := ir.maybeHandleInlineEditors(activeView, isTaskEditView, event); stop {
return handled
}
if stop, handled := ir.maybeHandleTaskEditFieldFocus(activeView, isTaskEditView, event); stop {
return handled
}
// check global actions first
if action := ir.globalActions.Match(event); action != nil {
return ir.handleGlobalAction(action.ID)
}
// route to view-specific controller
switch currentView.ViewID {
case model.TaskDetailViewID:
return ir.handleTaskInput(event, currentView.Params)
case model.TaskEditViewID:
return ir.handleTaskEditInput(event, currentView.Params)
default:
// Check if it's a plugin view
if model.IsPluginViewID(currentView.ViewID) {
return ir.handlePluginInput(event, currentView.ViewID)
}
return false
}
}
// maybeHandleSearchInput handles search 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)
if !ok {
return false, false
}
if searchableView.IsSearchBoxFocused() {
// Search box has focus and handles input through tview.
return true, false
}
// Search is visible but grid has focus - handle Esc to close search.
if searchableView.IsSearchVisible() && event.Key() == tcell.KeyEscape {
searchableView.HideSearch()
return true, true
}
return false, false
}
// maybeHandleFullscreenEscape exits fullscreen before bubbling Esc to global handler.
func (ir *InputRouter) maybeHandleFullscreenEscape(activeView View, event *tcell.EventKey) (stop bool, handled bool) {
fullscreenView, ok := activeView.(FullscreenView)
if !ok {
return false, false
}
if fullscreenView.IsFullscreen() && event.Key() == tcell.KeyEscape {
fullscreenView.ExitFullscreen()
return true, true
}
return false, false
}
// maybeHandleInlineEditors handles focused title/description editors (and their cancel semantics).
func (ir *InputRouter) maybeHandleInlineEditors(activeView View, isTaskEditView bool, event *tcell.EventKey) (stop bool, handled bool) {
// Only TaskEditView has inline editors now
if !isTaskEditView {
return false, false
}
if titleEditableView, ok := activeView.(TitleEditableView); ok {
if titleEditableView.IsTitleInputFocused() {
return true, ir.taskEditCoord.HandleKey(activeView, event)
}
}
if descEditableView, ok := activeView.(DescriptionEditableView); ok {
if descEditableView.IsDescriptionTextAreaFocused() {
return true, ir.taskEditCoord.HandleKey(activeView, event)
}
}
if tagsView, ok := activeView.(interface{ IsTagsTextAreaFocused() bool }); ok {
if tagsView.IsTagsTextAreaFocused() {
return true, ir.taskEditCoord.HandleKey(activeView, event)
}
}
return false, false
}
// maybeHandleTaskEditFieldFocus routes keys to task edit coordinator when an edit field has focus.
func (ir *InputRouter) maybeHandleTaskEditFieldFocus(activeView View, isTaskEditView bool, event *tcell.EventKey) (stop bool, handled bool) {
fieldFocusableView, ok := activeView.(FieldFocusableView)
if !ok || !isTaskEditView {
return false, false
}
if fieldFocusableView.IsEditFieldFocused() {
return true, ir.taskEditCoord.HandleKey(activeView, event)
}
return false, false
}
// SetPluginRegistrar sets the callback used to register dynamically created plugins
// (e.g., the deps editor) with the view factory.
func (ir *InputRouter) SetPluginRegistrar(fn func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)) {
ir.registerPlugin = fn
}
// openDepsEditor creates (or reopens) a deps editor plugin for the given task ID.
func (ir *InputRouter) openDepsEditor(taskID string) bool {
name := "Dependency:" + taskID
viewID := model.MakePluginViewID(name)
// reopen if already created
if _, exists := ir.pluginControllers[name]; exists {
ir.navController.PushView(viewID, nil)
return true
}
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
Name: name,
Description: model.DepsEditorViewDesc,
ConfigIndex: -1,
Type: "tiki",
Background: config.GetColors().DepsEditorBackground,
},
TaskID: taskID,
Lanes: []plugin.TikiLane{
{Name: "Blocks"},
{Name: "All"},
{Name: "Depends"},
},
}
pluginConfig := model.NewPluginConfig("Dependency")
pluginConfig.SetLaneLayout([]int{1, 2, 1}, []int{25, 50, 25})
if vm := config.GetPluginViewMode("Dependency"); vm != "" {
pluginConfig.SetViewMode(vm)
}
ctrl := NewDepsController(ir.taskStore, ir.mutationGate, pluginConfig, pluginDef, ir.navController, ir.statusline, ir.schema)
if ir.registerPlugin != nil {
ir.registerPlugin(name, pluginConfig, pluginDef, ctrl)
}
ir.pluginControllers[name] = ctrl
ir.navController.PushView(viewID, nil)
return true
}
// 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]
if !ok {
slog.Warn("plugin controller not found", "plugin", pluginName)
return false
}
registry := controller.GetActionRegistry()
if action := registry.Match(event); action != nil {
// Handle search action specially - show search box
if action.ID == ActionSearch {
return ir.handleSearchAction(controller)
}
// 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 controller.HandleAction(action.ID)
}
return false
}
// handleGlobalAction processes actions available in all views
func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool {
switch actionID {
case ActionBack:
if v := ir.navController.GetActiveView(); v != nil && v.GetViewID() == model.TaskEditViewID {
return ir.taskEditCoord.CancelAndClose()
}
return ir.navController.HandleBack()
case ActionQuit:
ir.navController.HandleQuit()
return true
case ActionRefresh:
_ = ir.taskStore.Reload()
return true
case ActionOpenPalette:
if ir.paletteConfig != nil {
ir.paletteConfig.SetVisible(true)
}
return true
case ActionToggleHeader:
ir.toggleHeader()
return true
default:
return false
}
}
// toggleHeader toggles the stored user preference and recomputes effective visibility
// against the live active view so fullscreen/header-hidden views stay force-hidden.
func (ir *InputRouter) toggleHeader() {
if ir.headerConfig == nil {
return
}
newPref := !ir.headerConfig.GetUserPreference()
ir.headerConfig.SetUserPreference(newPref)
visible := newPref
if v := ir.navController.GetActiveView(); v != nil {
if hv, ok := v.(interface{ RequiresHeaderHidden() bool }); ok && hv.RequiresHeaderHidden() {
visible = false
}
if fv, ok := v.(FullscreenView); ok && fv.IsFullscreen() {
visible = false
}
}
ir.headerConfig.SetVisible(visible)
}
// HandleAction dispatches a palette-selected action by ID against the given view entry.
// This is the controller-side fallback for palette execution — the palette tries
// view.HandlePaletteAction first, then falls back here.
func (ir *InputRouter) HandleAction(id ActionID, currentView *ViewEntry) bool {
if currentView == nil {
return false
}
// global actions
if ir.globalActions.ContainsID(id) {
return ir.handleGlobalAction(id)
}
activeView := ir.navController.GetActiveView()
switch currentView.ViewID {
case model.TaskDetailViewID:
taskID := model.DecodeTaskDetailParams(currentView.Params).TaskID
if taskID != "" {
ir.taskController.SetCurrentTask(taskID)
}
return ir.dispatchTaskAction(id, currentView.Params)
case model.TaskEditViewID:
if activeView != nil {
ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(currentView.Params))
}
return ir.dispatchTaskEditAction(id, activeView)
default:
if model.IsPluginViewID(currentView.ViewID) {
return ir.dispatchPluginAction(id, currentView.ViewID)
}
return false
}
}
// dispatchTaskAction handles palette-dispatched task detail actions by ActionID.
func (ir *InputRouter) dispatchTaskAction(id ActionID, _ map[string]interface{}) bool {
switch id {
case ActionEditTitle:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: taskID,
Focus: model.EditFieldTitle,
}))
return true
case ActionFullscreen:
activeView := ir.navController.GetActiveView()
if fullscreenView, ok := activeView.(FullscreenView); ok {
if fullscreenView.IsFullscreen() {
fullscreenView.ExitFullscreen()
} else {
fullscreenView.EnterFullscreen()
}
return true
}
return false
case ActionEditDesc:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: taskID,
Focus: model.EditFieldDescription,
DescOnly: true,
}))
return true
case ActionEditTags:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: taskID,
TagsOnly: true,
}))
return true
case ActionEditDeps:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
return ir.openDepsEditor(taskID)
case ActionChat:
agent := config.GetAIAgent()
if agent == "" {
return false
}
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
filename := strings.ToLower(taskID) + ".md"
taskFilePath := filepath.Join(config.GetTaskDir(), filename)
name, args := resolveAgentCommand(agent, taskFilePath)
ir.navController.SuspendAndRun(name, args...)
_ = ir.taskStore.ReloadTask(taskID)
return true
case ActionCloneTask:
return ir.taskController.HandleAction(id)
default:
return ir.taskController.HandleAction(id)
}
}
// dispatchTaskEditAction handles palette-dispatched task edit actions by ActionID.
func (ir *InputRouter) dispatchTaskEditAction(id ActionID, activeView View) bool {
switch id {
case ActionSaveTask:
if activeView != nil {
return ir.taskEditCoord.CommitAndClose(activeView)
}
return false
default:
return false
}
}
// dispatchPluginAction handles palette-dispatched plugin actions by ActionID.
func (ir *InputRouter) dispatchPluginAction(id ActionID, viewID model.ViewID) bool {
// handle plugin activation (switch to plugin)
if targetPluginName := GetPluginNameFromAction(id); targetPluginName != "" {
targetViewID := model.MakePluginViewID(targetPluginName)
if viewID != targetViewID {
ir.navController.ReplaceView(targetViewID, nil)
return true
}
return true
}
pluginName := model.GetPluginName(viewID)
ctrl, ok := ir.pluginControllers[pluginName]
if !ok {
return false
}
// search action needs special wiring
if id == ActionSearch {
return ir.handleSearchAction(ctrl)
}
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)
if !ok {
return false
}
// Set up focus callback
app := ir.navController.GetApp()
searchableView.SetFocusSetter(func(p tview.Primitive) {
app.SetFocus(p)
})
// Wire up search submit handler to controller
searchableView.SetSearchSubmitHandler(controller.HandleSearch)
// Show search box and focus it
searchBox := searchableView.ShowSearch()
if searchBox != nil {
app.SetFocus(searchBox)
}
return true
}
// handleTaskInput routes input to the task controller
func (ir *InputRouter) handleTaskInput(event *tcell.EventKey, params map[string]interface{}) bool {
// set current task from params
taskID := model.DecodeTaskDetailParams(params).TaskID
if taskID != "" {
ir.taskController.SetCurrentTask(taskID)
}
registry := ir.navController.GetActiveView().GetActionRegistry()
if action := registry.Match(event); action != nil {
switch action.ID {
case ActionEditTitle:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: taskID,
Focus: model.EditFieldTitle,
}))
return true
case ActionFullscreen:
activeView := ir.navController.GetActiveView()
if fullscreenView, ok := activeView.(FullscreenView); ok {
if fullscreenView.IsFullscreen() {
fullscreenView.ExitFullscreen()
} else {
fullscreenView.EnterFullscreen()
}
return true
}
return false
case ActionEditDesc:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: taskID,
Focus: model.EditFieldDescription,
DescOnly: true,
}))
return true
case ActionEditTags:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: taskID,
TagsOnly: true,
}))
return true
case ActionEditDeps:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
return ir.openDepsEditor(taskID)
case ActionChat:
agent := config.GetAIAgent()
if agent == "" {
return false
}
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
filename := strings.ToLower(taskID) + ".md"
taskFilePath := filepath.Join(config.GetTaskDir(), filename)
name, args := resolveAgentCommand(agent, taskFilePath)
ir.navController.SuspendAndRun(name, args...)
_ = ir.taskStore.ReloadTask(taskID)
return true
default:
return ir.taskController.HandleAction(action.ID)
}
}
return false
}
// handleTaskEditInput routes input while in the task edit view
func (ir *InputRouter) handleTaskEditInput(event *tcell.EventKey, params map[string]interface{}) bool {
activeView := ir.navController.GetActiveView()
ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(params))
// Handle arrow keys for cycling field values (before checking registry)
key := event.Key()
if key == tcell.KeyUp {
if ir.taskEditCoord.CycleFieldValueUp(activeView) {
return true
}
}
if key == tcell.KeyDown {
if ir.taskEditCoord.CycleFieldValueDown(activeView) {
return true
}
}
registry := ir.taskController.GetEditActionRegistry()
if action := registry.Match(event); action != nil {
switch action.ID {
case ActionSaveTask:
return ir.taskEditCoord.CommitAndClose(activeView)
case ActionNextField:
return ir.taskEditCoord.FocusNextField(activeView)
case ActionPrevField:
return ir.taskEditCoord.FocusPrevField(activeView)
default:
return false
}
}
return false
}