Merge pull request #92 from boolean-maybe/feature/action-palette

Feature/action palette
This commit is contained in:
boolean-maybe 2026-04-19 00:34:04 -04:00 committed by GitHub
commit 2a6c1201f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2175 additions and 1041 deletions

View file

@ -209,12 +209,6 @@ views:
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki

View file

@ -233,8 +233,19 @@ actions:
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `label` - description shown in the header and action palette
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
- `hot` - (optional) controls header visibility. `hot: true` shows the action in the header, `hot: false` hides it. When absent, actions default to visible in the header. This does not affect the action palette — all actions are always discoverable via `?` regardless of the `hot` setting
Example — keeping a verbose action out of the header but still accessible from the palette:
```yaml
actions:
- key: "x"
label: "Archive and notify"
action: update where id = id() set status="done"
hot: false
```
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.

View file

@ -16,7 +16,7 @@ cd /tmp && tiki demo
```
Move your tiki around the board with `Shift ←/Shift →`.
Make sure to press `?` for help.
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
## AI skills
@ -56,7 +56,7 @@ Store your notes in remotes!
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
Read more by pressing `?` for help
Press `?` to open the Action Palette and discover all available actions
# AI skills

View file

@ -92,7 +92,7 @@ this will clone and show a demo project. Once done you can try your own:
`cd` into your **git** repo and run `tiki init` to initialize.
Move your tiki around the board with `Shift ←/Shift →`.
Make sure to press `?` for help.
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
### AI skills
@ -142,7 +142,7 @@ Store your notes in remotes!
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
Read more by pressing `?` for help
Press `?` to open the Action Palette and discover all available actions
## AI skills

View file

@ -4,7 +4,7 @@ import "path"
// AITool defines a supported AI coding assistant.
// To add a new tool, add an entry to the aiTools slice below.
// NOTE: also update view/help/tiki.md which lists tool names in prose.
// NOTE: the action palette (press ?) surfaces available actions; update docs if tool names change.
type AITool struct {
Key string // config identifier: "claude", "gemini", "codex", "opencode"
DisplayName string // human-readable label for UI: "Claude Code"

View file

@ -48,6 +48,12 @@ views:
- key: "Y"
label: "Copy content"
action: select title, description where id = id() | clipboard()
- key: "+"
label: "Priority up"
action: update where id = id() set priority = priority - 1
- key: "-"
label: "Priority down"
action: update where id = id() set priority = priority + 1
plugins:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
@ -104,12 +110,6 @@ views:
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki

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).
@ -92,6 +93,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 +101,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 +132,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,13 +256,23 @@ 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})
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Toggle Header", ShowInHeader: true})
r.Register(Action{ID: ActionOpenPalette, Key: tcell.KeyRune, Rune: '?', Label: "All", ShowInHeader: true, HideFromPalette: true})
return r
}
@ -267,6 +287,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 +346,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 +381,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 +390,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 +407,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 +430,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 +445,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 +515,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 +544,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

@ -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,21 @@ 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 show in header with label "All"
for _, a := range actions {
if a.ID == ActionOpenPalette {
if !a.ShowInHeader {
t.Error("ActionOpenPalette should have ShowInHeader=true")
}
if a.Label != "All" {
t.Errorf("ActionOpenPalette label = %q, want %q", a.Label, "All")
}
continue
}
if !a.ShowInHeader {
t.Errorf("global action %v should have ShowInHeader=true", a.ID)
}
}
}
@ -630,3 +643,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

@ -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,27 @@ 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.
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

@ -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
@ -116,7 +122,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 +169,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,
@ -184,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.
@ -212,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,
@ -219,12 +259,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) {
@ -246,6 +280,59 @@ 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.Flex
lastPaletteSize int
}
func buildPaletteOverlay(ap *palette.ActionPalette) *paletteOverlayFlex {
overlay := &paletteOverlayFlex{
Flex: tview.NewFlex(),
palette: ap.GetPrimitive(),
}
overlay.spacer = tview.NewFlex()
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

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

@ -121,10 +121,12 @@ func (s *TikiStore) loadTaskFile(path string, authorMap map[string]*git.AuthorIn
return nil, err
}
// validate type strictly — missing or unknown types are load errors
taskType, typeOK := taskpkg.ParseType(fm.Type)
if !typeOK {
return nil, fmt.Errorf("invalid or missing type %q", fm.Type)
if fm.Type != "" {
return nil, fmt.Errorf("unknown type %q", fm.Type)
}
taskType = taskpkg.DefaultType()
}
task := &taskpkg.Task{

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"
@ -41,7 +42,11 @@ type TestApp struct {
taskController *controller.TaskController
statuslineConfig *model.StatuslineConfig
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.
@ -113,11 +118,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,
@ -126,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)
@ -136,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 {
@ -147,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
@ -181,7 +220,11 @@ func NewTestApp(t *testing.T) *TestApp {
taskController: taskController,
statuslineConfig: statuslineConfig,
headerConfig: headerConfig,
viewContext: viewContext,
layoutModel: layoutModel,
paletteConfig: paletteConfig,
actionPalette: actionPalette,
pages: pages,
}
// 11. Auto-load plugins since all views are now plugins
@ -194,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()
}
@ -313,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.
@ -383,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() {
return event
}
}
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)
return 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
@ -441,13 +458,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,
@ -477,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

@ -1,7 +1,6 @@
package view
import (
_ "embed"
"fmt"
"log/slog"
@ -18,24 +17,16 @@ import (
"github.com/rivo/tview"
)
//go:embed help/help.md
var helpMd string
//go:embed help/tiki.md
var tikiMd string
//go:embed help/custom.md
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
@ -93,13 +84,15 @@ func (dv *DokiView) build() {
MermaidOptions: dv.mermaidOpts,
})
// fetcher: internal is intentionally preserved as a supported pattern for
// code-only internal docs, even though no default workflow currently uses it.
//
// The default Help plugin previously used this with embedded markdown:
// cnt := map[string]string{"Help": helpMd, "tiki.md": tikiMd, "view.md": customMd}
// provider := &internalDokiProvider{content: cnt}
// That usage was replaced by the action palette (press ? to open).
case "internal":
cnt := map[string]string{
"Help": helpMd,
"tiki.md": tikiMd,
"view.md": customMd,
}
provider := &internalDokiProvider{content: cnt}
provider := &internalDokiProvider{content: map[string]string{}}
content, err = provider.FetchContent(nav.NavElement{Text: dv.pluginDef.Text})
dv.md = markdown.NewNavigableMarkdown(markdown.NavigableMarkdownConfig{
@ -172,6 +165,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 +209,37 @@ func (dv *DokiView) UpdateNavigationActions() {
ShowInHeader: true,
})
}
if dv.actionChangeHandler != nil {
dv.actionChangeHandler()
}
}
// 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.

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

@ -1,249 +0,0 @@
# Customization
First of all, you just navigated to a linked file. To go back press `Left` arrow or `Alt-Left`
To go forward press `Right` arrow or `Alt-Right`
tiki is highly customizable. `workflow.yaml` lets you define your workflow statuses and configure views (plugins) for how tikis are displayed and organized. Statuses define the lifecycle stages your tasks move through, while plugins control what you see and how you interact with your work. This section covers both.
## Statuses
Workflow statuses are defined in `workflow.yaml` under the `statuses:` key. Every tiki project must define its statuses here — there is no hardcoded fallback. The default `workflow.yaml` ships with:
```yaml
statuses:
- key: backlog
label: Backlog
emoji: "📥"
default: true
- key: ready
label: Ready
emoji: "📋"
active: true
- key: in_progress
label: "In Progress"
emoji: "⚙️"
active: true
- key: review
label: Review
emoji: "👀"
active: true
- key: done
label: Done
emoji: "✅"
done: true
```
Each status has:
- `key` — canonical identifier (lowercase, underscores). Used in filters, actions, and frontmatter.
- `label` — display name shown in the UI
- `emoji` — emoji shown alongside the label
- `active` — marks the status as "active work" (used for burndown charts and activity tracking)
- `default` — the status assigned to new tikis (exactly one status should have this)
- `done` — marks the status as "completed" (used for completion tracking)
You can customize these to match your team's workflow. All filters and actions in view definitions must reference valid status keys.
## Plugins
tiki TUI app is much like a lego - everything is a customizable view. Here is, for example,
how Backlog is defined:
```yaml
views:
plugins:
- name: Backlog
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
```
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
You define the name, description, hotkey, and `ruki` expressions for filtering and actions. Save this into a `workflow.yaml` file in the config directory
Likewise the documentation is just a plugin:
```yaml
views:
plugins:
- name: Docs
type: doki
fetcher: file
url: "index.md"
key: "F2"
```
that translates to - show `index.md` file located under `.doc/doki`
installed in the same way
### Multi-lane plugin
Backlog is a pretty simple plugin in that it displays all tikis in a single lane. Multi-lane tiki plugins offer functionality
similar to that of the board. You can define multiple lanes per view and move tikis around with Shift-Left/Shift-Right
much like in the board. You can create a multi-lane plugin by defining multiple lanes in its definition and assigning
actions to each lane. An action defines what happens when you move a tiki into the lane. Here is a multi-lane plugin
definition that roughly mimics the board:
```yaml
name: Custom
key: "F4"
lanes:
- name: Ready
columns: 1
width: 20
filter: select where status = "ready" order by priority, title
action: update where id = id() set status="ready"
- name: In Progress
columns: 1
width: 30
filter: select where status = "inProgress" order by priority, title
action: update where id = id() set status="inProgress"
- name: Review
columns: 1
width: 30
filter: select where status = "review" order by priority, title
action: update where id = id() set status="review"
- name: Done
columns: 1
width: 20
filter: select where status = "done" order by priority, title
action: update where id = id() set status="done"
```
### Lane width
Each lane can optionally specify a `width` as a percentage (1-100) to control how much horizontal space it occupies. Widths are relative proportions — they don't need to sum to 100. If width is omitted, the lane gets an equal share of the remaining space.
```yaml
lanes:
- name: Sidebar
width: 25
- name: Main
width: 50
- name: Details
width: 25
```
If no lanes specify width, all lanes are equally sized (the default behavior).
### Global plugin actions
You can define actions under `views.actions` that are available in **all** tiki plugin views:
```yaml
views:
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
plugins:
- name: Kanban
...
```
Global actions appear in the header alongside per-plugin actions. If a per-plugin action uses the same key, the per-plugin action takes precedence for that view. When multiple workflow files define `views.actions`, they merge by key — later files override same-keyed globals from earlier files.
### Per-plugin actions
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
```yaml
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
```
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki.
### ruki expressions
Plugin filters, lane actions, and plugin actions all use the `ruki` language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
#### Filter (select)
The `filter` field uses a `ruki` `select` statement to determine which tikis appear in a lane. Sorting is part of the select — use `order by` to control display order.
```sql
-- basic filter with sort
select where status = "backlog" and type != "epic" order by priority, id
-- recent items, most recent first
select where now() - updatedAt < 24hour order by updatedAt desc
-- multiple conditions
select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
-- assigned to me
select where assignee = user() order by priority
```
#### Action (update)
The `action` field uses a `ruki` `update` statement. In plugin context, `id()` refers to the currently selected tiki.
```sql
-- set status on move
update where id = id() set status="ready"
-- set multiple fields
update where id = id() set status="backlog" priority=2
-- assign to current user
update where id = id() set assignee=user()
```
#### Supported fields
- `id` - task identifier (e.g., "TIKI-M7N2XK")
- `title` - task title text
- `type` - task type (must match a key defined in `workflow.yaml` types)
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
- `assignee` - assigned user
- `priority` - numeric priority value (1-5)
- `points` - story points estimate
- `tags` - list of tags
- `dependsOn` - list of dependency tiki IDs
- `due` - due date (YYYY-MM-DD format)
- `recurrence` - recurrence pattern (cron format)
- `createdAt` - creation timestamp
- `updatedAt` - last update timestamp
#### Conditions
- **Comparison**: `=`, `!=`, `>`, `>=`, `<`, `<=`
- **Logical**: `and`, `or`, `not` (precedence: not > and > or)
- **Membership**: `"value" in field`, `status not in ["done", "cancelled"]`
- **Emptiness**: `assignee is empty`, `tags is not empty`
- **Quantifiers**: `dependsOn any status != "done"`, `dependsOn all status = "done"`
- **Grouping**: parentheses `()` to control evaluation order
#### Literals and built-ins
- Strings: double-quoted (`"ready"`, `"alex"`)
- Integers: `1`, `5`
- Dates: `2026-03-25`
- Durations: `2hour`, `14day`, `3week`, `1month`
- Lists: `["bug", "frontend"]`
- `user()` — current git user
- `now()` — current timestamp
- `id()` — currently selected tiki (in plugin context)
- `count(select where ...)` — count matching tikis

View file

@ -1,54 +0,0 @@
# About
tiki is a lightweight issue-tracking, project management and knowledge base tool that uses git repo
to store issues, stories and documentation.
- tiki uses Markdown files stored in `tiki` format under `.doc/tiki` subdirectory of a git repo
to track issues, stories or epics. Press `Tab` then `Enter` to select this link: [tiki](tiki.md) and read about `tiki` format
- Project-related documentation is stored under `.doc/doki` also in Markdown format. They can be linked/back-linked
for easier navigation.
>Since they are stored in git they are automatically versioned and can be perfectly synced to the current
state of the repo or its git branch. Also, all past versions and deleted items remain in git history of the repo
## Board
Board is a simple Kanban-style board where tikis can be moved around with `Shift-Right` and `Shift-Left`
As tikis are moved their status changes correspondingly. Statuses are configurable via `workflow.yaml`.
Tikis can be opened for viewing or editing or searched by ID, title, description and tags.
To quickly capture an idea - hit `n` in the board or any tiki view, type in the title and press Enter
You can also edit its status, type and other fields, or open the source file directly for editing in your favorite editor
## Documentation
Documentation is a Wiki-style knowledge base stored alongside the project files
Documentation and various other files such as prompts can also be stored under git version control
The documentation can be organized using Markdown links and navigated in the `tiki` cli using Tab/Shift-Tab and Enter
## AI
Since Markdown is an AI-native format issues and documentation can easily be created and maintained using AI tools.
tiki can optionally install skills to enable AI tools such as `claude`, `gemini`, `codex` or `opencode` to understand its
format. Try:
>create a tiki from @my-markdown.md with title "Fix UI bug"
or:
>mark tiki ABC123 as complete
## Customization
Press `Tab` then `Enter` to read [customize](view.md) to understand how to customize or extend tiki with your own plugins
## Configuration
tiki can be configured via `config.yaml` file stored in the same directory where executable is installed
## Header
- Context help showing keyboard shortcuts for the current view
- Various statistics - tiki count, git branch and current user name
- Burndown chart - number of incomplete tikis remaining

View file

@ -1,230 +0,0 @@
# tiki format
First of all, you just navigated to a linked file. To go back press `Left` arrow or `Alt-Left`
To go forward press `Right` arrow or `Alt-Right`
Keep your tickets in your pockets!
`tiki` refers to a task or a ticket (hence tiki) stored in your **git** repo
- like a ticket it can have a status, priority, assignee, points, type and multiple tags attached to it
- they are essentially just Markdown files and you can use full Markdown syntax to describe a story or a bug
- they are stored in `.doc/tiki` subdirectory and are **git**-controlled - they are added to **git** when they are created,
removed when they are done and the entire history is preserved in **git** repo
- because they are in **git** they can be perfectly synced up to the state of your repo or a branch
- you can use either the `tiki` CLI tool or any of the AI coding assistant to work with your tikis
## tiki format
Tiki stores tickets (aka tikis) and documents (aka dokis) in the git repo along with code
They are stored under `.doc` directory and are supposed to be checked-in/versioned along with all other files
The `.doc/` directory contains two main subdirectories:
- **doki/**: Documentation files (wiki-style markdown pages)
- **tiki/**: Task files (kanban style tasks with YAML frontmatter)
## Directory Structure
```
.doc/
├── doki/
│ ├── index.md
│ ├── page2.md
│ ├── page3.md
│ └── sub/
│ └── page4.md
└── tiki/
├── tiki-k3x9m2.md
├── tiki-7wq4na.md
├── tiki-p8j1fz.md
└── ...
```
## Tiki files
Tiki files are saved in `.doc/tiki` directory and can be managed via:
- `tiki` cli
- AI tools such as `claude`, `gemini`, `codex` or `opencode`
- manually
A tiki is made of its frontmatter that includes all fields related to a tiki status and types and its description
in Markdown format
```text
---
title: Sample title
type: story
status: backlog
assignee: booleanmaybe
priority: 3
points: 10
tags:
- UX
- test
dependsOn:
- TIKI-ABC123
- TIKI-DEF456
due: 2026-04-01
recurrence: 0 0 * * MON
---
This is the description of a tiki in Markdown:
# Tests
Make sure all tests pass
## Integration tests
Integration test cases
```
### Fields
#### title
**Required.** String. The name of the tiki. Must be non-empty, max 200 characters.
```yaml
title: Implement user authentication
```
#### type
Optional string. Defaults to the first type defined in `workflow.yaml`.
Valid values are the type keys defined in the `types:` section of `workflow.yaml`.
Default types: `story`, `bug`, `spike`, `epic`. Each type can have a label and emoji
configured in `workflow.yaml`. Aliases are not supported; use the canonical key.
```yaml
type: bug
```
#### status
Optional string. Must match a status defined in your project's `workflow.yaml`.
Default: the status marked `default: true` in the workflow (typically `backlog`).
If the value doesn't match any status in the workflow, it falls back to the default.
```yaml
status: in_progress
```
#### priority
Optional. Stored in the file as an integer 15 (1 = highest, 5 = lowest). Default: `3`.
In the TUI, priority is displayed as text with a color indicator:
| Value | TUI label | Emoji |
|-------|-----------|-------|
| 1 | High | 🔴 |
| 2 | Medium High | 🟠 |
| 3 | Medium | 🟡 |
| 4 | Medium Low | 🔵 |
| 5 | Low | 🟢 |
Text aliases are accepted when creating tikis (case-insensitive, hyphens/underscores/spaces as separators):
`high` = 1, `medium-high` = 2, `medium` = 3, `medium-low` = 4, `low` = 5.
```yaml
priority: 2
```
#### points
Optional integer. Range: `0` to `maxPoints` (configurable via `tiki.maxPoints`, default max is 10).
`0` means unestimated. Values outside the valid range default to `maxPoints / 2` (typically 5).
```yaml
points: 3
```
#### assignee
Optional string. Free-form text, typically a username. Default: empty.
```yaml
assignee: booleanmaybe
```
#### tags
Optional string list. Arbitrary labels attached to a tiki. Empty and whitespace-only strings are filtered out.
Default: empty. Both YAML list formats are accepted:
```yaml
# block list
tags:
- frontend
- urgent
# inline list
tags: [frontend, urgent]
```
#### dependsOn
Optional string list. Each entry must be a valid tiki ID in `TIKI-XXXXXX` format (6-character alphanumeric suffix)
referencing an existing tiki. IDs are automatically uppercased. A dependency means this tiki is blocked by the listed tikis.
Default: empty. Both YAML list formats are accepted:
```yaml
# block list
dependsOn:
- TIKI-ABC123
- TIKI-DEF456
# inline list
dependsOn: [TIKI-ABC123, TIKI-DEF456]
```
#### due
Optional date in `YYYY-MM-DD` format (date-only, no time component).
Represents when the task should be completed by. Empty string or omitted field means no due date.
```yaml
due: 2026-04-01
```
#### recurrence
Optional cron string specifying a recurrence pattern. Displayed as English in the TUI.
This is metadata-only — it does not auto-create tasks on completion.
Supported patterns:
| Cron | Display |
|------|---------|
| (empty) | None |
| `0 0 * * *` | Daily |
| `0 0 * * MON` | Weekly on Monday |
| `0 0 * * TUE` | Weekly on Tuesday |
| `0 0 * * WED` | Weekly on Wednesday |
| `0 0 * * THU` | Weekly on Thursday |
| `0 0 * * FRI` | Weekly on Friday |
| `0 0 * * SAT` | Weekly on Saturday |
| `0 0 * * SUN` | Weekly on Sunday |
| `0 0 1 * *` | Monthly |
```yaml
recurrence: 0 0 * * MON
```
### Derived fields
Fields such as:
- `created by`
- `created at`
- `updated at`
are not stored and are calculated from git - the time and git user who created a tiki or the time it was last modified
## Doki files
Documents are any file in a Markdown format saved under `.doc/doki` directory. They can be organized in subdirectory
tree and include links between them or to external Markdown files

View file

@ -0,0 +1,536 @@
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
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
lastWidth int // width used for last render, to detect resize
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,
}
// filter input
ap.filterInput = tview.NewInputField()
ap.filterInput.SetLabel(" ")
ap.filterInput.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
ap.filterInput.SetFieldTextColor(colors.InputFieldTextColor.TCell())
ap.filterInput.SetLabelColor(colors.SearchBoxLabelColor.TCell())
ap.filterInput.SetPlaceholder("Type to search")
ap.filterInput.SetPlaceholderStyle(tcell.StyleDefault.
Foreground(colors.TaskDetailPlaceholderColor.TCell()).
Background(colors.ContentBackgroundColor.TCell()))
ap.filterInput.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
// list area
ap.listView = tview.NewTextView().SetDynamicColors(true)
ap.listView.SetBackgroundColor(colors.ContentBackgroundColor.TCell())
ap.listView.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) {
if width != ap.lastWidth && width > 0 {
ap.renderList()
}
return x, y, width, height
})
// bottom hint
ap.hintView = tview.NewTextView().SetDynamicColors(true)
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.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()
}
// Cleanup removes all listeners.
func (ap *ActionPalette) Cleanup() {
ap.viewContext.RemoveListener(ap.viewContextListenerID)
}
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.listView.GetInnerRect()
if width <= 0 {
width = PaletteMinWidth
}
ap.lastWidth = width
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 {
if vi > 0 {
buf.WriteString("\n")
line := strings.Repeat("─", width)
buf.WriteString(fmt.Sprintf("[%s]%s[-]", mutedHex, line))
}
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")
}
// build visible text: key column + label
visibleLen := 1 + keyColWidth + 1 + len([]rune(label)) // leading space + key + space + label
pad := ""
if visibleLen < width {
pad = strings.Repeat(" ", width-visibleLen)
}
if !row.enabled {
buf.WriteString(fmt.Sprintf(" [%s]%-*s %s%s[-]", mutedHex, keyColWidth, keyStr, label, pad))
} else if selected {
buf.WriteString(fmt.Sprintf("[%s:%s:b] %-*s[-:-:-][:%s:] %s%s[-:-:-]",
scheme.keyHex, selBgHex, keyColWidth, keyStr,
selBgHex, label, pad))
} else {
buf.WriteString(fmt.Sprintf(" [%s]%-*s[-] %s%s", scheme.keyHex, keyColWidth, keyStr, label, pad))
}
}
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

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

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

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

View file

@ -68,6 +68,12 @@ views:
- key: "Y"
label: "Copy content"
action: select title, description where id = id() | clipboard()
- key: "+"
label: "Priority up"
action: update where id = id() set priority = priority - 1
- key: "-"
label: "Priority down"
action: update where id = id() set priority = priority + 1
- key: "R"
label: "Mark regression"
action: update where id = id() set regression=true
@ -163,12 +169,6 @@ views:
- key: "D"
label: "Bump one week"
action: update where id = id() set dueBy=now() + 7day
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki

View file

@ -49,6 +49,12 @@ views:
- key: "Y"
label: "Copy content"
action: select title, description where id = id() | clipboard()
- key: "+"
label: "Priority up"
action: update where id = id() set priority = priority - 1
- key: "-"
label: "Priority down"
action: update where id = id() set priority = priority + 1
plugins:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
@ -105,12 +111,6 @@ views:
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki