action palette phase 2

This commit is contained in:
booleanmaybe 2026-04-18 23:09:01 -04:00
parent 7b7136ce5e
commit db019108be
17 changed files with 1388 additions and 73 deletions

View file

@ -19,6 +19,7 @@ const (
ActionRefresh ActionID = "refresh"
ActionToggleViewMode ActionID = "toggle_view_mode"
ActionToggleHeader ActionID = "toggle_header"
ActionOpenPalette ActionID = "open_palette"
)
// ActionID values for task navigation and manipulation (used by plugins).
@ -270,7 +271,8 @@ func DefaultGlobalActions() *ActionRegistry {
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit", ShowInHeader: true})
r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh", ShowInHeader: true})
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Hide Header", ShowInHeader: true})
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Toggle Header", ShowInHeader: true})
r.Register(Action{ID: ActionOpenPalette, Key: tcell.KeyRune, Rune: '?', Label: "Actions", ShowInHeader: false, HideFromPalette: true})
return r
}

View file

@ -404,11 +404,11 @@ func TestDefaultGlobalActions(t *testing.T) {
registry := DefaultGlobalActions()
actions := registry.GetActions()
if len(actions) != 4 {
t.Errorf("expected 4 global actions, got %d", len(actions))
if len(actions) != 5 {
t.Errorf("expected 5 global actions, got %d", len(actions))
}
expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader}
expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader, ActionOpenPalette}
for i, expected := range expectedActions {
if i >= len(actions) {
t.Errorf("missing action at index %d: want %v", i, expected)
@ -417,8 +417,18 @@ func TestDefaultGlobalActions(t *testing.T) {
if actions[i].ID != expected {
t.Errorf("action at index %d: want %v, got %v", i, expected, actions[i].ID)
}
if !actions[i].ShowInHeader {
t.Errorf("global action %v should have ShowInHeader=true", expected)
}
// ActionOpenPalette should NOT show in header
for _, a := range actions {
if a.ID == ActionOpenPalette {
if a.ShowInHeader {
t.Error("ActionOpenPalette should have ShowInHeader=false")
}
continue
}
if !a.ShowInHeader {
t.Errorf("global action %v should have ShowInHeader=true", a.ID)
}
}
}

View file

@ -54,6 +54,8 @@ type InputRouter struct {
statusline *model.StatuslineConfig
schema ruki.Schema
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
headerConfig *model.HeaderConfig
paletteConfig *model.ActionPaletteConfig
}
// NewInputRouter creates an input router
@ -79,6 +81,16 @@ func NewInputRouter(
}
}
// SetHeaderConfig wires the header config for fullscreen-aware header toggling.
func (ir *InputRouter) SetHeaderConfig(hc *model.HeaderConfig) {
ir.headerConfig = hc
}
// SetPaletteConfig wires the palette config for ActionOpenPalette dispatch.
func (ir *InputRouter) SetPaletteConfig(pc *model.ActionPaletteConfig) {
ir.paletteConfig = pc
}
// HandleInput processes a key event for the current view and routes it to the appropriate handler.
// It processes events through multiple handlers in order:
// 1. Search input (if search is active)
@ -91,6 +103,15 @@ func NewInputRouter(
func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry) bool {
slog.Debug("input received", "name", event.Name(), "key", int(event.Key()), "rune", string(event.Rune()), "modifiers", int(event.Modifiers()))
// 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
}
@ -293,8 +314,6 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool {
switch actionID {
case ActionBack:
if v := ir.navController.GetActiveView(); v != nil && v.GetViewID() == model.TaskEditViewID {
// Cancel edit session (discards changes) and close.
// This keeps the ActionBack behavior consistent across input paths.
return ir.taskEditCoord.CancelAndClose()
}
return ir.navController.HandleBack()
@ -304,11 +323,189 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool {
case ActionRefresh:
_ = ir.taskStore.Reload()
return true
case ActionOpenPalette:
if ir.paletteConfig != nil {
ir.paletteConfig.SetVisible(true)
}
return true
case ActionToggleHeader:
ir.toggleHeader()
return true
default:
return false
}
}
// 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()

View file

@ -272,6 +272,20 @@ type RecurrencePartNavigable interface {
IsRecurrenceValueFocused() bool
}
// PaletteActionHandler is implemented by views that handle palette-dispatched actions
// directly (e.g., DokiView replays navigation as synthetic key events).
// The palette tries this before falling back to InputRouter.HandleAction.
type PaletteActionHandler interface {
HandlePaletteAction(id ActionID) bool
}
// FocusRestorer is implemented by views that can recover focus after the palette closes
// when the originally saved focused primitive is no longer valid (e.g., TaskDetailView
// rebuilds its description primitive during store-driven refresh).
type FocusRestorer interface {
RestoreFocus() bool
}
// ActionChangeNotifier is implemented by views that mutate their action registry
// or live enablement/presentation state while the same view instance stays active.
// RootLayout wires the handler and reruns syncViewContextFromView when fired.

View file

@ -0,0 +1,95 @@
package integration
import (
"testing"
"github.com/boolean-maybe/tiki/testutil"
"github.com/gdamore/tcell/v2"
)
func TestActionPalette_OpenAndClose(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.Draw()
// ? opens the palette
ta.SendKey(tcell.KeyRune, '?', tcell.ModNone)
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should be visible after pressing '?'")
}
// Esc closes it
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should be hidden after pressing Esc")
}
}
func TestActionPalette_F10TogglesHeader(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.Draw()
hc := ta.GetHeaderConfig()
initialVisible := hc.IsVisible()
// F10 should toggle header via the router
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
if hc.IsVisible() == initialVisible {
t.Fatal("F10 should toggle header visibility")
}
// toggle back
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
if hc.IsVisible() != initialVisible {
t.Fatal("second F10 should restore header visibility")
}
}
func TestActionPalette_ModalBlocksGlobals(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.Draw()
hc := ta.GetHeaderConfig()
startVisible := hc.IsVisible()
// open palette
ta.SendKey(tcell.KeyRune, '?', tcell.ModNone)
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should be open")
}
// F10 while palette is open should NOT toggle header
// (app capture returns event unchanged, palette input handler swallows F10)
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
if hc.IsVisible() != startVisible {
t.Fatal("F10 should be blocked while palette is modal")
}
// close palette
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}
func TestActionPalette_QuestionMarkFiltersInPalette(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.Draw()
// open palette
ta.SendKey(tcell.KeyRune, '?', tcell.ModNone)
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should be open")
}
// typing '?' while palette is open should be treated as filter text, not open another palette
ta.SendKeyToFocused(tcell.KeyRune, '?', tcell.ModNone)
// palette should still be open
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should remain open when '?' is typed as filter")
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}

View file

@ -4,8 +4,6 @@ import (
"fmt"
"github.com/rivo/tview"
"github.com/boolean-maybe/tiki/view"
)
// NewApp creates a tview application.
@ -13,10 +11,9 @@ func NewApp() *tview.Application {
return tview.NewApplication()
}
// Run runs the tview application.
// Returns an error if the application fails to run.
func Run(app *tview.Application, rootLayout *view.RootLayout) error {
app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false)
// Run runs the tview application with the given root primitive (typically a tview.Pages).
func Run(app *tview.Application, root tview.Primitive) error {
app.SetRoot(root, true).EnableMouse(false)
if err := app.Run(); err != nil {
return fmt.Errorf("run application: %w", err)
}

View file

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

View file

@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/boolean-maybe/tiki/config"
@ -20,6 +21,7 @@ import (
"github.com/boolean-maybe/tiki/util/sysinfo"
"github.com/boolean-maybe/tiki/view"
"github.com/boolean-maybe/tiki/view/header"
"github.com/boolean-maybe/tiki/view/palette"
"github.com/boolean-maybe/tiki/view/statusline"
)
@ -47,6 +49,10 @@ type Result struct {
StatuslineConfig *model.StatuslineConfig
StatuslineWidget *statusline.StatuslineWidget
RootLayout *view.RootLayout
PaletteConfig *model.ActionPaletteConfig
ActionPalette *palette.ActionPalette
ViewContext *model.ViewContext
AppRoot tview.Primitive // Pages root for app.SetRoot
Context context.Context
CancelFunc context.CancelFunc
TikiSkillContent string
@ -185,9 +191,38 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
background.StartBurndownHistoryBuilder(ctx, tikiStore, headerConfig, application)
triggerEngine.StartScheduler(ctx)
// Phase 11.5: Action palette
paletteConfig := model.NewActionPaletteConfig()
inputRouter.SetHeaderConfig(headerConfig)
inputRouter.SetPaletteConfig(paletteConfig)
actionPalette := palette.NewActionPalette(viewContext, paletteConfig, inputRouter, controllers.Nav)
actionPalette.SetChangedFunc()
// Build Pages root: base = rootLayout, overlay = palette
pages := tview.NewPages()
pages.AddPage("base", rootLayout.GetPrimitive(), true, true)
paletteOverlay := buildPaletteOverlay(actionPalette)
pages.AddPage("palette", paletteOverlay, true, false)
// Wire palette visibility to Pages show/hide and focus management
var previousFocus tview.Primitive
paletteConfig.AddListener(func() {
if paletteConfig.IsVisible() {
previousFocus = application.GetFocus()
actionPalette.OnShow()
pages.ShowPage("palette")
application.SetFocus(actionPalette.GetFilterInput())
} else {
pages.HidePage("palette")
restoreFocusAfterPalette(application, previousFocus, rootLayout)
previousFocus = nil
}
})
// Phase 12: Navigation and input wiring
wireNavigation(controllers.Nav, layoutModel, rootLayout)
app.InstallGlobalInputCapture(application, headerConfig, statuslineConfig, inputRouter, controllers.Nav)
app.InstallGlobalInputCapture(application, paletteConfig, statuslineConfig, inputRouter, controllers.Nav)
// Phase 13: Initial view — use the first plugin marked default: true,
// or fall back to the first plugin in the list.
@ -213,6 +248,10 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
StatuslineConfig: statuslineConfig,
StatuslineWidget: statuslineWidget,
RootLayout: rootLayout,
PaletteConfig: paletteConfig,
ActionPalette: actionPalette,
ViewContext: viewContext,
AppRoot: pages,
Context: ctx,
CancelFunc: cancel,
TikiSkillContent: tikiSkillContent,
@ -241,6 +280,61 @@ func wireNavigation(navController *controller.NavigationController, layoutModel
navController.SetActiveViewGetter(rootLayout.GetContentView)
}
// paletteOverlayFlex is a Flex that recomputes the palette width on every draw
// to maintain 1/3 terminal width with a minimum floor.
type paletteOverlayFlex struct {
*tview.Flex
palette tview.Primitive
spacer *tview.Box
lastPaletteSize int
}
func buildPaletteOverlay(ap *palette.ActionPalette) *paletteOverlayFlex {
overlay := &paletteOverlayFlex{
Flex: tview.NewFlex(),
palette: ap.GetPrimitive(),
}
overlay.Flex.SetBackgroundColor(tcell.ColorDefault)
overlay.spacer = tview.NewBox()
overlay.spacer.SetBackgroundColor(tcell.ColorDefault)
overlay.Flex.AddItem(overlay.spacer, 0, 1, false)
overlay.Flex.AddItem(overlay.palette, palette.PaletteMinWidth, 0, true)
overlay.lastPaletteSize = palette.PaletteMinWidth
return overlay
}
func (o *paletteOverlayFlex) Draw(screen tcell.Screen) {
_, _, w, _ := o.GetRect()
pw := w / 3
if pw < palette.PaletteMinWidth {
pw = palette.PaletteMinWidth
}
if pw != o.lastPaletteSize {
o.Flex.Clear()
o.Flex.AddItem(o.spacer, 0, 1, false)
o.Flex.AddItem(o.palette, pw, 0, true)
o.lastPaletteSize = pw
}
o.Flex.Draw(screen)
}
// restoreFocusAfterPalette restores focus to the previously focused primitive,
// falling back to FocusRestorer on the active view, then to the content view root.
func restoreFocusAfterPalette(application *tview.Application, previousFocus tview.Primitive, rootLayout *view.RootLayout) {
if previousFocus != nil {
application.SetFocus(previousFocus)
return
}
if contentView := rootLayout.GetContentView(); contentView != nil {
if restorer, ok := contentView.(controller.FocusRestorer); ok {
if restorer.RestoreFocus() {
return
}
}
application.SetFocus(contentView.GetPrimitive())
}
}
// InitColorAndGradientSupport collects system information, auto-corrects TERM if needed,
// and initializes gradient support flags based on terminal color capabilities.
// Returns the collected SystemInfo for use in bootstrap result.

View file

@ -126,10 +126,11 @@ func main() {
defer result.App.Stop()
defer result.HeaderWidget.Cleanup()
defer result.RootLayout.Cleanup()
defer result.ActionPalette.Cleanup()
defer result.CancelFunc()
// Run application
if err := app.Run(result.App, result.RootLayout); err != nil {
if err := app.Run(result.App, result.AppRoot); err != nil {
slog.Error("application error", "error", err)
os.Exit(1)
}

View file

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

View file

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

View file

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

View file

@ -223,6 +223,33 @@ func (dv *DokiView) UpdateNavigationActions() {
}
}
// HandlePaletteAction maps palette-dispatched actions to the markdown viewer's
// existing key-driven behavior by replaying synthetic key events.
func (dv *DokiView) HandlePaletteAction(id controller.ActionID) bool {
if dv.md == nil {
return false
}
var event *tcell.EventKey
switch id {
case "navigate_next_link":
event = tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)
case "navigate_prev_link":
event = tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone)
case controller.ActionNavigateBack:
event = tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)
case controller.ActionNavigateForward:
event = tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)
default:
return false
}
handler := dv.md.Viewer().InputHandler()
if handler != nil {
handler(event, nil)
return true
}
return false
}
// internalDokiProvider implements navidown.ContentProvider for embedded/internal docs.
// It treats elem.URL as the lookup key, falling back to elem.Text for initial loads.
type internalDokiProvider struct {

View file

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

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

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

View file

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

View file

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