mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
Merge pull request #92 from boolean-maybe/feature/action-palette
Feature/action palette
This commit is contained in:
commit
2a6c1201f8
44 changed files with 2175 additions and 1041 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
95
integration/action_palette_test.go
Normal file
95
integration/action_palette_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
3
main.go
3
main.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
80
model/action_palette_config.go
Normal file
80
model/action_palette_config.go
Normal 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()
|
||||
}
|
||||
}
|
||||
87
model/action_palette_config_test.go
Normal file
87
model/action_palette_config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
96
model/view_context.go
Normal 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()
|
||||
}
|
||||
}
|
||||
93
model/view_context_test.go
Normal file
93
model/view_context_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 1–5 (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
|
||||
536
view/palette/action_palette.go
Normal file
536
view/palette/action_palette.go
Normal 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
34
view/palette/fuzzy.go
Normal 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)
|
||||
}
|
||||
81
view/palette/fuzzy_test.go
Normal file
81
view/palette/fuzzy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue