action palette phase 1

This commit is contained in:
booleanmaybe 2026-04-18 22:27:14 -04:00
parent 80e7f1e510
commit 7b7136ce5e
21 changed files with 748 additions and 387 deletions

View file

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

View file

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

View file

@ -272,6 +272,13 @@ type RecurrencePartNavigable interface {
IsRecurrenceValueFocused() bool
}
// ActionChangeNotifier is implemented by views that mutate their action registry
// or live enablement/presentation state while the same view instance stays active.
// RootLayout wires the handler and reruns syncViewContextFromView when fired.
type ActionChangeNotifier interface {
SetActionChangeHandler(handler func())
}
// ViewInfoProvider is a view that provides its name and description for the header info section
type ViewInfoProvider interface {
GetViewName() string

View file

@ -55,13 +55,17 @@ func NewPluginController(
"plugin", pluginDef.Name, "key", string(a.Rune),
"plugin_action", a.Label, "built_in_action", existing.Label)
}
pc.registry.Register(Action{
action := Action{
ID: pluginActionID(a.Rune),
Key: tcell.KeyRune,
Rune: a.Rune,
Label: a.Label,
ShowInHeader: true,
})
ShowInHeader: a.ShowInHeader,
}
if a.Action != nil && (a.Action.IsUpdate() || a.Action.IsDelete() || a.Action.IsPipe()) {
action.IsEnabled = selectionRequired
}
pc.registry.Register(action)
}
return pc

View file

@ -116,7 +116,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
return nil, err
}
InitPluginActionRegistry(plugins)
syncHeaderPluginActions(headerConfig)
viewContext := model.NewViewContext()
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
// Phase 6.5: Trigger system
@ -163,11 +163,12 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
viewFactory.RegisterPlugin(name, cfg, def, ctrl)
})
headerWidget := header.NewHeaderWidget(headerConfig)
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
Header: headerWidget,
HeaderConfig: headerConfig,
ViewContext: viewContext,
LayoutModel: layoutModel,
ViewFactory: viewFactory,
TaskStore: taskStore,
@ -219,12 +220,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
}, nil
}
// syncHeaderPluginActions syncs plugin action shortcuts from the controller registry
// into the header model.
func syncHeaderPluginActions(headerConfig *model.HeaderConfig) {
headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions())
}
// wireOnViewActivated wires focus setters into views as they become active.
func wireOnViewActivated(rootLayout *view.RootLayout, app *tview.Application) {
rootLayout.SetOnViewActivated(func(v controller.View) {

View file

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

View file

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

96
model/view_context.go Normal file
View file

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

View file

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

View file

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

View file

@ -46,7 +46,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct
return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)}
}
if len(wf.Views.Plugins) == 0 {
if len(wf.Views.Plugins) == 0 && len(wf.Views.Actions) == 0 {
return nil, nil, nil
}
@ -55,6 +55,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct
for i := range wf.Views.Plugins {
totalConverted += transformer.ConvertPluginConfig(&wf.Views.Plugins[i])
}
totalConverted += convertLegacyGlobalActions(transformer, wf.Views.Actions)
if totalConverted > 0 {
slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path)
}
@ -257,6 +258,27 @@ func mergeGlobalActionsIntoPlugins(plugins []Plugin, globalActions []PluginActio
}
}
// convertLegacyGlobalActions converts legacy action expressions in global views.actions
// to ruki format, matching the same conversion applied to per-plugin actions.
func convertLegacyGlobalActions(transformer *LegacyConfigTransformer, actions []PluginActionConfig) int {
count := 0
for i := range actions {
action := &actions[i]
if action.Action != "" && !isRukiAction(action.Action) {
newAction, err := transformer.ConvertAction(action.Action)
if err != nil {
slog.Warn("failed to convert legacy global action, passing through",
"error", err, "action", action.Action, "key", action.Key)
continue
}
slog.Debug("converted legacy global action", "old", action.Action, "new", newAction, "key", action.Key)
action.Action = newAction
count++
}
}
return count
}
// DefaultPlugin returns the first plugin marked as default, or the first plugin
// in the list if none are marked. The caller must ensure plugins is non-empty.
func DefaultPlugin(plugins []Plugin) Plugin {

View file

@ -210,10 +210,15 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl
if err != nil {
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
}
showInHeader := true
if cfg.Hot != nil {
showInHeader = *cfg.Hot
}
actions = append(actions, PluginAction{
Rune: r,
Label: cfg.Label,
Action: actionStmt,
Rune: r,
Label: cfg.Label,
Action: actionStmt,
ShowInHeader: showInHeader,
})
}

View file

@ -761,3 +761,81 @@ foreground: "#00ff00"
t.Errorf("Expected URL, got %q", dokiPlugin.URL)
}
}
func TestParsePluginActions_HotDefault(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !actions[0].ShowInHeader {
t.Error("absent hot should default to ShowInHeader=true")
}
}
func TestParsePluginActions_HotExplicitFalse(t *testing.T) {
parser := testParser()
hotFalse := false
configs := []PluginActionConfig{
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotFalse},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if actions[0].ShowInHeader {
t.Error("hot: false should set ShowInHeader=false")
}
}
func TestParsePluginActions_HotExplicitTrue(t *testing.T) {
parser := testParser()
hotTrue := true
configs := []PluginActionConfig{
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotTrue},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !actions[0].ShowInHeader {
t.Error("hot: true should set ShowInHeader=true")
}
}
func TestParsePluginYAML_HotFlagFromYAML(t *testing.T) {
yamlData := []byte(`
name: Test
key: T
lanes:
- name: Backlog
filter: select where status = "backlog"
actions:
- key: "b"
label: "Board"
action: update where id = id() set status = "ready"
hot: false
- key: "a"
label: "Assign"
action: update where id = id() set assignee = user()
`)
p, err := parsePluginYAML(yamlData, "test.yaml", testSchema())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tiki, ok := p.(*TikiPlugin)
if !ok {
t.Fatalf("expected TikiPlugin, got %T", p)
}
if tiki.Actions[0].ShowInHeader {
t.Error("action with hot: false should have ShowInHeader=false")
}
if !tiki.Actions[1].ShowInHeader {
t.Error("action without hot should default to ShowInHeader=true")
}
}

View file

@ -41,6 +41,7 @@ type TestApp struct {
taskController *controller.TaskController
statuslineConfig *model.StatuslineConfig
headerConfig *model.HeaderConfig
viewContext *model.ViewContext
layoutModel *model.LayoutModel
}
@ -113,11 +114,13 @@ func NewTestApp(t *testing.T) *TestApp {
viewFactory := view.NewViewFactory(taskStore)
// 7. Create header widget, statusline, and RootLayout
headerWidget := header.NewHeaderWidget(headerConfig)
viewContext := model.NewViewContext()
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
Header: headerWidget,
HeaderConfig: headerConfig,
ViewContext: viewContext,
LayoutModel: layoutModel,
ViewFactory: viewFactory,
TaskStore: taskStore,
@ -181,6 +184,7 @@ func NewTestApp(t *testing.T) *TestApp {
taskController: taskController,
statuslineConfig: statuslineConfig,
headerConfig: headerConfig,
viewContext: viewContext,
layoutModel: layoutModel,
}
@ -441,13 +445,14 @@ func (ta *TestApp) LoadPlugins() error {
})
// Recreate RootLayout with new view factory
headerWidget := header.NewHeaderWidget(ta.headerConfig)
headerWidget := header.NewHeaderWidget(ta.headerConfig, ta.viewContext)
ta.RootLayout.Cleanup()
slConfig := model.NewStatuslineConfig()
slWidget := statusline.NewStatuslineWidget(slConfig)
ta.RootLayout = view.NewRootLayout(view.RootLayoutOpts{
Header: headerWidget,
HeaderConfig: ta.headerConfig,
ViewContext: ta.viewContext,
LayoutModel: ta.layoutModel,
ViewFactory: viewFactory,
TaskStore: ta.TaskStore,

View file

@ -29,13 +29,14 @@ var customMd string
// DokiView renders a documentation plugin (navigable markdown)
type DokiView struct {
root *tview.Flex
titleBar tview.Primitive
md *markdown.NavigableMarkdown
pluginDef *plugin.DokiPlugin
registry *controller.ActionRegistry
imageManager *navtview.ImageManager
mermaidOpts *nav.MermaidOptions
root *tview.Flex
titleBar tview.Primitive
md *markdown.NavigableMarkdown
pluginDef *plugin.DokiPlugin
registry *controller.ActionRegistry
imageManager *navtview.ImageManager
mermaidOpts *nav.MermaidOptions
actionChangeHandler func()
}
// NewDokiView creates a doki view
@ -172,6 +173,10 @@ func (dv *DokiView) OnBlur() {
}
}
func (dv *DokiView) SetActionChangeHandler(handler func()) {
dv.actionChangeHandler = handler
}
// UpdateNavigationActions updates the registry to reflect current navigation state
func (dv *DokiView) UpdateNavigationActions() {
// Clear and rebuild the registry
@ -212,6 +217,10 @@ func (dv *DokiView) UpdateNavigationActions() {
ShowInHeader: true,
})
}
if dv.actionChangeHandler != nil {
dv.actionChangeHandler()
}
}
// internalDokiProvider implements navidown.ContentProvider for embedded/internal docs.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ type PluginView struct {
selectionListenerID int
getLaneTasks func(lane int) []*task.Task // injected from controller
ensureSelection func() bool // injected from controller
actionChangeHandler func()
}
// NewPluginView creates a plugin view
@ -186,6 +187,14 @@ func (pv *PluginView) refresh() {
// Sync scroll offset from view to model for later lane navigation
pv.pluginConfig.SetScrollOffsetForLane(laneIdx, laneContainer.GetScrollOffset())
}
if pv.actionChangeHandler != nil {
pv.actionChangeHandler()
}
}
func (pv *PluginView) SetActionChangeHandler(handler func()) {
pv.actionChangeHandler = handler
}
// GetPrimitive returns the root tview primitive