replace header stats with view info

This commit is contained in:
booleanmaybe 2026-03-23 21:50:12 -04:00
parent a0faa499ef
commit 5947292e0c
26 changed files with 272 additions and 476 deletions

View file

@ -114,6 +114,7 @@ statuses:
views:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
foreground: "#87ceeb"
background: "#25496a"
key: "F1"
@ -132,6 +133,7 @@ views:
action: status = 'done'
sort: Priority, CreatedAt
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
foreground: "#5fff87"
background: "#0b3d2e"
key: "F3"
@ -145,6 +147,7 @@ views:
action: status = 'ready'
sort: Priority, ID
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
foreground: "#f4d6a6"
background: "#5a3d1b"
key: Ctrl-R
@ -154,6 +157,7 @@ views:
filter: NOW - UpdatedAt < 24hours
sort: UpdatedAt DESC
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
foreground: "#e2e8f0"
background: "#2a5f5a"
key: "F4"
@ -173,6 +177,7 @@ views:
sort: Priority, Points DESC
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
@ -180,6 +185,7 @@ views:
background: "#003399"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"

View file

@ -51,6 +51,7 @@ how Backlog is defined:
```yaml
views:
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
foreground: "#5fff87"
background: "#0b3d2e"
key: "F3"
@ -67,13 +68,14 @@ views:
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, caption colors, hotkey, tiki filter and sorting. Save this into a `workflow.yaml` file in the config directory
You define the name, description, caption colors, hotkey, tiki filter and sorting. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory
Likewise the documentation is just a plugin:
```yaml
views:
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"

View file

@ -69,10 +69,11 @@ type ColorConfig struct {
BurndownHeaderGradientTo Gradient
// Header view colors
HeaderInfoLabel string // tview color string like "[orange]"
HeaderInfoValue string // tview color string like "[white]"
HeaderKeyBinding string // tview color string like "[yellow]"
HeaderKeyText string // tview color string like "[white]"
HeaderInfoLabel string // tview color string for view name (bold)
HeaderInfoSeparator string // tview color string for horizontal rule below name
HeaderInfoDesc string // tview color string for view description
HeaderKeyBinding string // tview color string like "[yellow]"
HeaderKeyText string // tview color string like "[white]"
// Points visual bar colors
PointsFilledColor string // tview color string for filled segments
@ -93,6 +94,7 @@ type ColorConfig struct {
StatuslineAccentFg string // hex color for accent segment text, e.g. "#1c1c2e"
StatuslineMessageFg string // hex color for right-section message text, e.g. "#ff8787"
StatuslineMessageBg string // hex color for right-section message background, e.g. "#3a3a3a"
StatuslineFillBg string // hex color for empty statusline area between segments
}
// DefaultColors returns the default color configuration
@ -178,10 +180,11 @@ func DefaultColors() *ColorConfig {
PointsUnfilledColor: "[#5f6982]", // Gray for unfilled segments
// Header
HeaderInfoLabel: "[orange]",
HeaderInfoValue: "[#cccccc]",
HeaderKeyBinding: "[yellow]",
HeaderKeyText: "[white]",
HeaderInfoLabel: "[orange]",
HeaderInfoSeparator: "[#555555]",
HeaderInfoDesc: "[#888888]",
HeaderKeyBinding: "[yellow]",
HeaderKeyText: "[white]",
// Header context help actions
HeaderActionGlobalKeyColor: "#ffff00", // yellow for global actions
@ -198,6 +201,7 @@ func DefaultColors() *ColorConfig {
StatuslineAccentFg: "#1c1c2e", // dark text on accent
StatuslineMessageFg: "#ff8787", // soft red for error/message text
StatuslineMessageBg: "#3a3a3a", // dark gray for message background
StatuslineFillBg: "#2a2a45", // muted dark indigo fill for empty statusline area
}
}

View file

@ -22,6 +22,7 @@ statuses:
views:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
default: true
foreground: "#87ceeb"
background: "#25496a"
@ -41,6 +42,7 @@ views:
action: status = 'done'
sort: Priority, CreatedAt
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
foreground: "#5fff87"
background: "#0b3d2e"
key: "F3"
@ -54,6 +56,7 @@ views:
action: status = 'ready'
sort: Priority, ID
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
foreground: "#f4d6a6"
background: "#5a3d1b"
key: Ctrl-R
@ -63,6 +66,7 @@ views:
filter: NOW - UpdatedAt < 24hours
sort: UpdatedAt DESC
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
foreground: "#e2e8f0"
background: "#2a5f5a"
key: "F4"
@ -82,6 +86,7 @@ views:
sort: Priority, Points DESC
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
@ -89,6 +94,7 @@ views:
background: "#003399"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"

View file

@ -216,6 +216,7 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
Name: name,
Description: model.DepsEditorViewDesc,
ConfigIndex: -1,
Type: "tiki",
Background: config.DepsEditorBackground,

View file

@ -271,9 +271,15 @@ type RecurrencePartNavigable interface {
MoveRecurrencePartRight() bool
}
// StatsProvider is a view that provides statistics for the header
// ViewInfoProvider is a view that provides its name and description for the header info section
type ViewInfoProvider interface {
GetViewName() string
GetViewDescription() string
}
// StatsProvider is a view that provides statistics for the statusline
type StatsProvider interface {
// GetStats returns stats to display in the header for this view
// GetStats returns stats to display in the statusline for this view
GetStats() []store.Stat
}

View file

@ -98,7 +98,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
// Phase 5: Model initialization
headerConfig, layoutModel := InitHeaderAndLayoutModels()
InitHeaderBaseStats(headerConfig, tikiStore)
statuslineConfig := InitStatuslineModel(tikiStore)
// Phase 6: Plugin system

View file

@ -20,16 +20,6 @@ func InitHeaderAndLayoutModels() (*model.HeaderConfig, *model.LayoutModel) {
return headerConfig, layoutModel
}
// InitHeaderBaseStats initializes base header stats that are always visible regardless of view.
func InitHeaderBaseStats(headerConfig *model.HeaderConfig, tikiStore *tikistore.TikiStore) {
headerConfig.SetBaseStat("Version", config.Version, 0)
headerConfig.SetBaseStat("Mode", "kanban", 1)
headerConfig.SetBaseStat("Store", "local", 2)
for _, stat := range tikiStore.GetStats() {
headerConfig.SetBaseStat(stat.Name, stat.Value, stat.Order)
}
}
// InitStatuslineModel creates and populates the statusline config with base stats (version, branch, user).
func InitStatuslineModel(tikiStore *tikistore.TikiStore) *model.StatuslineConfig {
cfg := model.NewStatuslineConfig()

View file

@ -1,7 +1,6 @@
package model
import (
"maps"
"sync"
"github.com/boolean-maybe/tiki/store"
@ -20,7 +19,7 @@ type HeaderAction struct {
ShowInHeader bool
}
// StatValue represents a single stat entry for the header
// StatValue represents a single stat entry for the statusline
type StatValue struct {
Value string
Priority int
@ -32,11 +31,11 @@ type HeaderConfig struct {
mu sync.RWMutex
// Content state
viewActions []HeaderAction
pluginActions []HeaderAction
baseStats map[string]StatValue // global stats (version, store, user, branch, etc.)
viewStats map[string]StatValue // view-specific stats (e.g., board "Total")
burndown []store.BurndownPoint
viewActions []HeaderAction
pluginActions []HeaderAction
viewName string // current view name for info section
viewDescription string // current view description for info section
burndown []store.BurndownPoint
// Visibility state
visible bool // current header visibility (may be overridden by fullscreen view)
@ -50,8 +49,6 @@ type HeaderConfig struct {
// NewHeaderConfig creates a new header config with default state
func NewHeaderConfig() *HeaderConfig {
return &HeaderConfig{
baseStats: make(map[string]StatValue),
viewStats: make(map[string]StatValue),
visible: true,
userPreference: true,
listeners: make(map[int]func()),
@ -89,39 +86,27 @@ func (hc *HeaderConfig) GetPluginActions() []HeaderAction {
return hc.pluginActions
}
// SetBaseStat sets a global stat (displayed in all views)
func (hc *HeaderConfig) SetBaseStat(key, value string, priority int) {
// SetViewInfo sets the current view name and description for the header info section
func (hc *HeaderConfig) SetViewInfo(name, description string) {
hc.mu.Lock()
hc.baseStats[key] = StatValue{Value: value, Priority: priority}
hc.viewName = name
hc.viewDescription = description
hc.mu.Unlock()
hc.notifyListeners()
}
// SetViewStat sets a view-specific stat
func (hc *HeaderConfig) SetViewStat(key, value string, priority int) {
hc.mu.Lock()
hc.viewStats[key] = StatValue{Value: value, Priority: priority}
hc.mu.Unlock()
hc.notifyListeners()
}
// ClearViewStats clears all view-specific stats
func (hc *HeaderConfig) ClearViewStats() {
hc.mu.Lock()
hc.viewStats = make(map[string]StatValue)
hc.mu.Unlock()
hc.notifyListeners()
}
// GetStats returns all stats (base + view) merged together
func (hc *HeaderConfig) GetStats() map[string]StatValue {
// GetViewName returns the current view name
func (hc *HeaderConfig) GetViewName() string {
hc.mu.RLock()
defer hc.mu.RUnlock()
return hc.viewName
}
result := make(map[string]StatValue)
maps.Copy(result, hc.baseStats)
maps.Copy(result, hc.viewStats)
return result
// 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

View file

@ -35,8 +35,12 @@ func TestNewHeaderConfig(t *testing.T) {
t.Error("initial GetPluginActions() should be empty")
}
if len(hc.GetStats()) != 0 {
t.Error("initial GetStats() 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 {
@ -102,122 +106,42 @@ func TestHeaderConfig_PluginActions(t *testing.T) {
}
}
func TestHeaderConfig_BaseStats(t *testing.T) {
func TestHeaderConfig_ViewInfo(t *testing.T) {
hc := NewHeaderConfig()
// Set base stats
hc.SetBaseStat("version", "v1.0.0", 100)
hc.SetBaseStat("user", "testuser", 90)
hc.SetViewInfo("Kanban", "Tasks moving through stages")
stats := hc.GetStats()
if len(stats) != 2 {
t.Errorf("len(GetStats()) = %d, want 2", len(stats))
if got := hc.GetViewName(); got != "Kanban" {
t.Errorf("GetViewName() = %q, want %q", got, "Kanban")
}
if stats["version"].Value != "v1.0.0" {
t.Errorf("stats[version].Value = %q, want %q", stats["version"].Value, "v1.0.0")
if got := hc.GetViewDescription(); got != "Tasks moving through stages" {
t.Errorf("GetViewDescription() = %q, want %q", got, "Tasks moving through stages")
}
if stats["version"].Priority != 100 {
t.Errorf("stats[version].Priority = %d, want 100", stats["version"].Priority)
// update overwrites
hc.SetViewInfo("Backlog", "Upcoming tasks")
if got := hc.GetViewName(); got != "Backlog" {
t.Errorf("GetViewName() after update = %q, want %q", got, "Backlog")
}
if stats["user"].Value != "testuser" {
t.Errorf("stats[user].Value = %q, want %q", stats["user"].Value, "testuser")
if got := hc.GetViewDescription(); got != "Upcoming tasks" {
t.Errorf("GetViewDescription() after update = %q, want %q", got, "Upcoming tasks")
}
}
func TestHeaderConfig_ViewStats(t *testing.T) {
func TestHeaderConfig_ViewInfoEmptyDescription(t *testing.T) {
hc := NewHeaderConfig()
// Set view stats
hc.SetViewStat("total", "42", 50)
hc.SetViewStat("selected", "5", 60)
hc.SetViewInfo("Task Detail", "")
stats := hc.GetStats()
if len(stats) != 2 {
t.Errorf("len(GetStats()) = %d, want 2", len(stats))
if got := hc.GetViewName(); got != "Task Detail" {
t.Errorf("GetViewName() = %q, want %q", got, "Task Detail")
}
if stats["total"].Value != "42" {
t.Errorf("stats[total].Value = %q, want %q", stats["total"].Value, "42")
}
if stats["selected"].Value != "5" {
t.Errorf("stats[selected].Value = %q, want %q", stats["selected"].Value, "5")
}
}
func TestHeaderConfig_StatsMerging(t *testing.T) {
hc := NewHeaderConfig()
// Set base stats
hc.SetBaseStat("version", "v1.0.0", 100)
hc.SetBaseStat("user", "testuser", 90)
// Set view stats (including one that overrides base)
hc.SetViewStat("total", "42", 50)
hc.SetViewStat("user", "viewuser", 95) // Override base stat
stats := hc.GetStats()
// Should have 3 unique keys (version, user, total)
if len(stats) != 3 {
t.Errorf("len(GetStats()) = %d, want 3", len(stats))
}
// View stat should override base stat
if stats["user"].Value != "viewuser" {
t.Errorf("stats[user].Value = %q, want %q (view should override base)",
stats["user"].Value, "viewuser")
}
if stats["user"].Priority != 95 {
t.Errorf("stats[user].Priority = %d, want 95", stats["user"].Priority)
}
// Base stats should still be present
if stats["version"].Value != "v1.0.0" {
t.Error("base stat 'version' missing after merge")
}
// View stat should be present
if stats["total"].Value != "42" {
t.Error("view stat 'total' missing after merge")
}
}
func TestHeaderConfig_ClearViewStats(t *testing.T) {
hc := NewHeaderConfig()
// Set both base and view stats
hc.SetBaseStat("version", "v1.0.0", 100)
hc.SetViewStat("total", "42", 50)
hc.SetViewStat("selected", "5", 60)
stats := hc.GetStats()
if len(stats) != 3 {
t.Errorf("len(GetStats()) before clear = %d, want 3", len(stats))
}
// Clear view stats
hc.ClearViewStats()
stats = hc.GetStats()
// Should only have base stats now
if len(stats) != 1 {
t.Errorf("len(GetStats()) after clear = %d, want 1", len(stats))
}
if stats["version"].Value != "v1.0.0" {
t.Error("base stats should remain after ClearViewStats")
}
if _, ok := stats["total"]; ok {
t.Error("view stats should be cleared")
if got := hc.GetViewDescription(); got != "" {
t.Errorf("GetViewDescription() = %q, want empty", got)
}
}
@ -341,9 +265,7 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) {
}{
{"SetViewActions", func() { hc.SetViewActions([]HeaderAction{{ID: "test"}}) }},
{"SetPluginActions", func() { hc.SetPluginActions([]HeaderAction{{ID: "test"}}) }},
{"SetBaseStat", func() { hc.SetBaseStat("key", "value", 1) }},
{"SetViewStat", func() { hc.SetViewStat("key", "value", 1) }},
{"ClearViewStats", func() { hc.ClearViewStats() }},
{"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() }},
@ -423,7 +345,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
id2 := hc.AddListener(listener2)
// Both should be notified
hc.SetBaseStat("test", "value", 1)
hc.SetViewInfo("Test", "desc")
time.Sleep(10 * time.Millisecond)
@ -436,7 +358,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
// Remove one
hc.RemoveListener(id1)
hc.SetViewStat("another", "test", 1)
hc.SetViewInfo("Test2", "desc2")
time.Sleep(10 * time.Millisecond)
@ -449,7 +371,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
// Remove second
hc.RemoveListener(id2)
hc.ClearViewStats()
hc.SetViewInfo("Test3", "desc3")
time.Sleep(10 * time.Millisecond)
@ -474,14 +396,10 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
done <- true
}()
// Writer goroutine - stats
// Writer goroutine - view info
go func() {
for i := range 50 {
hc.SetBaseStat("key", "value", i)
hc.SetViewStat("viewkey", "viewvalue", i)
if i%10 == 0 {
hc.ClearViewStats()
}
hc.SetViewInfo("View"+string(rune('a'+i%26)), "desc")
}
done <- true
}()
@ -502,7 +420,8 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
for range 100 {
_ = hc.GetViewActions()
_ = hc.GetPluginActions()
_ = hc.GetStats()
_ = hc.GetViewName()
_ = hc.GetViewDescription()
_ = hc.GetBurndown()
_ = hc.IsVisible()
_ = hc.GetUserPreference()
@ -540,35 +459,6 @@ func TestHeaderConfig_EmptyCollections(t *testing.T) {
}
}
func TestHeaderConfig_StatPriorityOrdering(t *testing.T) {
hc := NewHeaderConfig()
// Set stats with different priorities
hc.SetBaseStat("low", "value", 10)
hc.SetBaseStat("high", "value", 100)
hc.SetBaseStat("medium", "value", 50)
stats := hc.GetStats()
// Verify all stats are present (priority doesn't filter, just orders)
if len(stats) != 3 {
t.Errorf("len(stats) = %d, want 3", len(stats))
}
// Verify priorities are preserved
if stats["low"].Priority != 10 {
t.Errorf("stats[low].Priority = %d, want 10", stats["low"].Priority)
}
if stats["high"].Priority != 100 {
t.Errorf("stats[high].Priority = %d, want 100", stats["high"].Priority)
}
if stats["medium"].Priority != 50 {
t.Errorf("stats[medium].Priority = %d, want 50", stats["medium"].Priority)
}
}
func TestHeaderConfig_ListenerIDUniqueness(t *testing.T) {
hc := NewHeaderConfig()

View file

@ -14,6 +14,18 @@ const (
PluginViewIDPrefix ViewID = "plugin:" // Prefix for plugin views
)
// built-in view names and descriptions for the header info section
const (
TaskDetailViewName = "Tiki Detail"
TaskDetailViewDesc = "tiki overview. Quick edit, edit dependencies, tags or edit source file"
TaskEditViewName = "Task Edit"
TaskEditViewDesc = "Cycle through fields to edit title, status, priority and other"
DepsEditorViewName = "Dependencies"
DepsEditorViewDesc = "Move a tiki to Blocks to make it block edited tiki. Move it to Depends to make edited tiki depend on it"
)
// IsPluginViewID checks if a ViewID is for a plugin view
func IsPluginViewID(id ViewID) bool {
return strings.HasPrefix(string(id), string(PluginViewIDPrefix))

View file

@ -9,6 +9,7 @@ import (
// Plugin interface defines the common methods for all plugins
type Plugin interface {
GetName() string
GetDescription() string
GetActivationKey() (tcell.Key, rune, tcell.ModMask)
GetFilePath() string
GetConfigIndex() int
@ -19,6 +20,7 @@ type Plugin interface {
// BasePlugin holds the common fields for all plugins
type BasePlugin struct {
Name string // display name shown in caption
Description string // short description shown in header info section
Key tcell.Key // tcell key constant (e.g. KeyCtrlH)
Rune rune // printable character (e.g. 'L')
Modifier tcell.ModMask // modifier keys (Alt, Shift, Ctrl, etc.)
@ -34,6 +36,10 @@ func (p *BasePlugin) GetName() string {
return p.Name
}
func (p *BasePlugin) GetDescription() string {
return p.Description
}
func (p *BasePlugin) GetActivationKey() (tcell.Key, rune, tcell.ModMask) {
return p.Key, p.Rune, p.Modifier
}

View file

@ -6,20 +6,21 @@ import (
// pluginFileConfig represents the YAML structure of a plugin file
type pluginFileConfig struct {
Name string `yaml:"name"`
Foreground string `yaml:"foreground"` // hex color like "#ff0000" or named color
Background string `yaml:"background"`
Key string `yaml:"key"` // single character
Filter string `yaml:"filter"`
Sort string `yaml:"sort"`
View string `yaml:"view"` // "compact" or "expanded" (default: compact)
Type string `yaml:"type"` // "tiki" or "doki" (default: tiki)
Fetcher string `yaml:"fetcher"`
Text string `yaml:"text"`
URL string `yaml:"url"`
Lanes []PluginLaneConfig `yaml:"lanes"`
Actions []PluginActionConfig `yaml:"actions"`
Default bool `yaml:"default"`
Name string `yaml:"name"`
Description string `yaml:"description"` // short description shown in header info
Foreground string `yaml:"foreground"` // hex color like "#ff0000" or named color
Background string `yaml:"background"`
Key string `yaml:"key"` // single character
Filter string `yaml:"filter"`
Sort string `yaml:"sort"`
View string `yaml:"view"` // "compact" or "expanded" (default: compact)
Type string `yaml:"type"` // "tiki" or "doki" (default: tiki)
Fetcher string `yaml:"fetcher"`
Text string `yaml:"text"`
URL string `yaml:"url"`
Lanes []PluginLaneConfig `yaml:"lanes"`
Actions []PluginActionConfig `yaml:"actions"`
Default bool `yaml:"default"`
}
// mergePluginDefinitions merges a base plugin with a configured override.
@ -33,6 +34,7 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
result := &TikiPlugin{
BasePlugin: BasePlugin{
Name: baseTiki.Name,
Description: baseTiki.Description,
Key: baseTiki.Key,
Rune: baseTiki.Rune,
Modifier: baseTiki.Modifier, // FIXED: Copy modifier from base
@ -50,6 +52,9 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
}
// Apply overrides for non-zero values
if overrideTiki.Description != "" {
result.Description = overrideTiki.Description
}
if overrideTiki.Key != 0 || overrideTiki.Rune != 0 || overrideTiki.Modifier != 0 {
result.Key = overrideTiki.Key
result.Rune = overrideTiki.Rune

View file

@ -35,6 +35,7 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
base := BasePlugin{
Name: cfg.Name,
Description: cfg.Description,
Key: key,
Rune: r,
Modifier: mod,

View file

@ -139,6 +139,12 @@ func (dv *DokiView) rebuildLayout() {
// ShowNavigation returns true — doki views always show plugin navigation keys.
func (dv *DokiView) ShowNavigation() bool { return true }
// GetViewName returns the plugin name for the header info section
func (dv *DokiView) GetViewName() string { return dv.pluginDef.GetName() }
// GetViewDescription returns the plugin description for the header info section
func (dv *DokiView) GetViewDescription() string { return dv.pluginDef.GetDescription() }
func (dv *DokiView) GetPrimitive() tview.Primitive {
return dv.root
}

View file

@ -1,8 +1,6 @@
package header
import (
"sort"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/model"
@ -42,12 +40,12 @@ const (
HeaderColumnSpacing = 2 // spaces between action columns in ContextHelp
)
// HeaderWidget displays stats, available actions and burndown chart
// HeaderWidget displays view info, available actions and burndown chart
type HeaderWidget struct {
*tview.Flex
// Components
stats *StatsWidget
info *InfoWidget
contextHelp *ContextHelpWidget
chart *ChartWidget
@ -68,7 +66,7 @@ type HeaderWidget struct {
// NewHeaderWidget creates a header widget that observes HeaderConfig for all state
func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget {
stats := NewStatsWidget()
info := NewInfoWidget()
contextHelp := NewContextHelpWidget()
chart := NewChartWidgetSimple() // No store dependency, data comes from HeaderConfig
@ -81,7 +79,7 @@ func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget {
hw := &HeaderWidget{
Flex: flex,
stats: stats,
info: info,
leftSpacer: tview.NewBox(),
contextHelp: contextHelp,
gap: tview.NewBox(),
@ -101,9 +99,8 @@ func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget {
// rebuild reads all data from HeaderConfig and updates display
func (h *HeaderWidget) rebuild() {
// Update stats from HeaderConfig
stats := h.headerConfig.GetStats()
h.rebuildStats(stats)
// Update view info from HeaderConfig
h.info.SetViewInfo(h.headerConfig.GetViewName(), h.headerConfig.GetViewDescription())
// Update burndown chart from HeaderConfig
burndown := h.headerConfig.GetBurndown()
@ -119,35 +116,6 @@ func (h *HeaderWidget) rebuild() {
}
}
// rebuildStats updates the stats widget from HeaderConfig stats
func (h *HeaderWidget) rebuildStats(stats map[string]model.StatValue) {
// Remove stats that are no longer in the config
for _, key := range h.stats.GetKeys() {
if _, exists := stats[key]; !exists {
h.stats.RemoveStat(key)
}
}
// Sort stats by priority for consistent ordering
type statEntry struct {
key string
value string
priority int
}
entries := make([]statEntry, 0, len(stats))
for k, v := range stats {
entries = append(entries, statEntry{key: k, value: v.Value, priority: v.Priority})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].priority < entries[j].priority
})
// Update stats widget
for _, e := range entries {
h.stats.AddStat(e.key, e.value, e.priority)
}
}
// Draw overrides to implement responsive layout
func (h *HeaderWidget) Draw(screen tcell.Screen) {
_, _, width, _ := h.GetRect()
@ -172,7 +140,7 @@ func (h *HeaderWidget) rebuildLayout(width int) {
// and to physically remove the chart when hidden.
h.Clear()
h.SetDirection(tview.FlexColumn)
h.AddItem(h.stats.Primitive(), StatsWidth, 0, false)
h.AddItem(h.info.Primitive(), InfoWidth, 0, false)
h.AddItem(h.leftSpacer, 0, 1, false)
h.AddItem(h.contextHelp.Primitive(), layout.ContextWidth, 0, false)
if layout.ChartVisible {

View file

@ -3,7 +3,7 @@ package header
// layout constants for the header widget. kept here alongside the pure layout
// function so the algorithm and its inputs are co-located.
const (
StatsWidth = 30 // fixed width for stats section
InfoWidth = 40 // fixed width for info section (view name + description)
ChartWidth = 14 // fixed width for burndown chart
LogoWidth = 25 // fixed width for logo
MinContextWidth = 40 // minimum width for context help to remain readable
@ -20,12 +20,12 @@ type HeaderLayout struct {
// CalculateHeaderLayout computes header component widths from two integer inputs.
//
// Rules:
// 1. availableBetween = totalWidth - StatsWidth - LogoWidth (clamped to 0)
// 1. availableBetween = totalWidth - InfoWidth - LogoWidth (clamped to 0)
// 2. requiredContext = max(contextHelpWidth, MinContextWidth) when contextHelpWidth > 0
// 3. chart is visible when availableBetween >= requiredContext + ChartSpacing + ChartWidth
// 4. contextWidth is clamped to availableBetween minus chart reservation when chart visible
func CalculateHeaderLayout(totalWidth, contextHelpWidth int) HeaderLayout {
availableBetween := max(totalWidth-StatsWidth-LogoWidth, 0)
availableBetween := max(totalWidth-InfoWidth-LogoWidth, 0)
requiredContext := contextHelpWidth
if requiredContext < MinContextWidth && requiredContext > 0 {

View file

@ -9,42 +9,42 @@ import (
// --- pure layout function tests ---
func TestCalculateHeaderLayout_chartVisibleAtThreshold(t *testing.T) {
// availableBetween = 119 - 30 - 25 = 64
// availableBetween = 129 - 40 - 25 = 64
// requiredContext = max(10, 40) = 40
// required for chart = 40 + 10 + 14 = 64 → exactly fits
layout := CalculateHeaderLayout(119, 10)
layout := CalculateHeaderLayout(129, 10)
if !layout.ChartVisible {
t.Fatal("expected chart visible at width=119, contextHelp=10")
t.Fatal("expected chart visible at width=129, contextHelp=10")
}
}
func TestCalculateHeaderLayout_chartHiddenJustBelow(t *testing.T) {
// availableBetween = 118 - 30 - 25 = 63 < 64
layout := CalculateHeaderLayout(118, 10)
// availableBetween = 128 - 40 - 25 = 63 < 64
layout := CalculateHeaderLayout(128, 10)
if layout.ChartVisible {
t.Fatal("expected chart hidden at width=118, contextHelp=10")
t.Fatal("expected chart hidden at width=128, contextHelp=10")
}
}
func TestCalculateHeaderLayout_chartThresholdGrowsWithContextHelp(t *testing.T) {
// contextHelpWidth=60 already >= MinContextWidth so requiredContext=60
// required = 60 + 10 + 14 = 84; availableBetween must be >= 84
// totalWidth = 84 + 30 + 25 = 139
layout := CalculateHeaderLayout(139, 60)
// totalWidth = 84 + 40 + 25 = 149
layout := CalculateHeaderLayout(149, 60)
if !layout.ChartVisible {
t.Fatal("expected chart visible at width=139, contextHelp=60")
t.Fatal("expected chart visible at width=149, contextHelp=60")
}
layout = CalculateHeaderLayout(138, 60)
layout = CalculateHeaderLayout(148, 60)
if layout.ChartVisible {
t.Fatal("expected chart hidden at width=138, contextHelp=60")
t.Fatal("expected chart hidden at width=148, contextHelp=60")
}
}
func TestCalculateHeaderLayout_contextWidthWithChart(t *testing.T) {
// width=200, contextHelp=50
// availableBetween = 200 - 30 - 25 = 145
// requiredContext = 50, chart required = 50+10+14 = 74 <= 145 → chart visible
// maxContextWidth = 145 - (10+14) = 121; contextWidth = min(50, 121) = 50
// availableBetween = 200 - 40 - 25 = 135
// requiredContext = 50, chart required = 50+10+14 = 74 <= 135 → chart visible
// maxContextWidth = 135 - (10+14) = 111; contextWidth = min(50, 111) = 50
layout := CalculateHeaderLayout(200, 50)
if !layout.ChartVisible {
t.Fatal("expected chart visible")
@ -56,15 +56,15 @@ func TestCalculateHeaderLayout_contextWidthWithChart(t *testing.T) {
func TestCalculateHeaderLayout_contextWidthClampedByAvailable(t *testing.T) {
// width=100, contextHelp=200 (too wide)
// availableBetween = 100 - 30 - 25 = 45
// requiredContext = 200; chart required = 214 > 45 → chart hidden
// maxContextWidth = 45; contextWidth clamped to 45
// availableBetween = 100 - 40 - 25 = 35
// requiredContext = 200; chart required = 214 > 35 → chart hidden
// maxContextWidth = 35; contextWidth clamped to 35
layout := CalculateHeaderLayout(100, 200)
if layout.ChartVisible {
t.Fatal("expected chart hidden when context too wide")
}
if layout.ContextWidth != 45 {
t.Errorf("contextWidth = %d, want 45", layout.ContextWidth)
if layout.ContextWidth != 35 {
t.Errorf("contextWidth = %d, want 35", layout.ContextWidth)
}
}
@ -80,8 +80,8 @@ func TestCalculateHeaderLayout_contextWidthFlooredAtMinContextWidth(t *testing.T
func TestCalculateHeaderLayout_zeroContextHelp(t *testing.T) {
// contextHelpWidth=0: requiredContext stays 0 (guard: > 0 check)
// required for chart = 0 + 10 + 14 = 24
// availableBetween at width=119 = 64 >= 24 → chart visible
layout := CalculateHeaderLayout(119, 0)
// availableBetween at width=129 = 64 >= 24 → chart visible
layout := CalculateHeaderLayout(129, 0)
if !layout.ChartVisible {
t.Fatal("expected chart visible with zero-width context help")
}
@ -98,7 +98,7 @@ func TestCalculateHeaderLayout_negativeContextHelp(t *testing.T) {
}
func TestCalculateHeaderLayout_veryNarrowTerminal(t *testing.T) {
// width < StatsWidth + LogoWidth → availableBetween clamped to 0
// width < InfoWidth + LogoWidth → availableBetween clamped to 0
// chart cannot be visible; contextWidth = 0
layout := CalculateHeaderLayout(40, 30)
if layout.ChartVisible {
@ -109,11 +109,11 @@ func TestCalculateHeaderLayout_veryNarrowTerminal(t *testing.T) {
}
}
func TestCalculateHeaderLayout_exactlyStatsAndLogo(t *testing.T) {
// width = StatsWidth + LogoWidth = 55 → availableBetween = 0
layout := CalculateHeaderLayout(StatsWidth+LogoWidth, 10)
func TestCalculateHeaderLayout_exactlyInfoAndLogo(t *testing.T) {
// width = InfoWidth + LogoWidth = 65 → availableBetween = 0
layout := CalculateHeaderLayout(InfoWidth+LogoWidth, 10)
if layout.ChartVisible {
t.Fatal("expected chart hidden when no space between stats and logo")
t.Fatal("expected chart hidden when no space between info and logo")
}
if layout.ContextWidth != 0 {
t.Errorf("contextWidth = %d, want 0", layout.ContextWidth)
@ -122,10 +122,10 @@ func TestCalculateHeaderLayout_exactlyStatsAndLogo(t *testing.T) {
func TestCalculateHeaderLayout_chartHiddenContextFillsAvailable(t *testing.T) {
// chart hidden; contextWidth should use full availableBetween
// width=118, contextHelp=63
// availableBetween = 63; chart requires 40+24=64 > 63 → hidden
// width=128, contextHelp=63
// availableBetween = 128 - 40 - 25 = 63; chart requires 63+24=87 > 63 → hidden
// maxContextWidth = 63; contextWidth = min(63, 63) = 63
layout := CalculateHeaderLayout(118, 63)
layout := CalculateHeaderLayout(128, 63)
if layout.ChartVisible {
t.Fatal("expected chart hidden")
}
@ -142,13 +142,13 @@ func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) {
defer h.Cleanup()
h.contextHelp.width = 10
h.rebuildLayout(119)
h.rebuildLayout(129)
if !h.chartVisible {
t.Fatalf("expected chart visible at width=119")
t.Fatalf("expected chart visible at width=129")
}
h.rebuildLayout(118)
h.rebuildLayout(128)
if h.chartVisible {
t.Fatalf("expected chart hidden at width=118")
t.Fatalf("expected chart hidden at width=128")
}
}
@ -159,13 +159,13 @@ func TestHeaderWidget_chartVisibilityThreshold_growsWithContextHelp(t *testing.T
h.contextHelp.width = 60
h.rebuildLayout(138)
h.rebuildLayout(148)
if h.chartVisible {
t.Fatalf("expected chart hidden at width=138 for context=60")
t.Fatalf("expected chart hidden at width=148 for context=60")
}
h.rebuildLayout(139)
h.rebuildLayout(149)
if !h.chartVisible {
t.Fatalf("expected chart visible at width=139 for context=60")
t.Fatalf("expected chart visible at width=149 for context=60")
}
}

56
view/header/info.go Normal file
View file

@ -0,0 +1,56 @@
package header
import (
"fmt"
"strings"
"github.com/boolean-maybe/tiki/config"
"github.com/rivo/tview"
)
// InfoWidget displays the current view name and description in the header
type InfoWidget struct {
*tview.TextView
}
// NewInfoWidget creates a new info display widget
func NewInfoWidget() *InfoWidget {
tv := tview.NewTextView()
tv.SetDynamicColors(true)
tv.SetTextAlign(tview.AlignLeft)
tv.SetWrap(true)
tv.SetWordWrap(true)
tv.SetBorderPadding(0, 0, 1, 0)
return &InfoWidget{TextView: tv}
}
// SetViewInfo updates the displayed view name and description
func (iw *InfoWidget) SetViewInfo(name, description string) {
colors := config.GetColors()
// convert "[orange]" to "[orange::b]" for bold name
boldColor := makeBold(colors.HeaderInfoLabel)
separator := strings.Repeat("─", InfoWidth)
var text string
if description != "" {
text = fmt.Sprintf("%s%s[-::-]\n%s%s[-]\n%s%s", boldColor, name, colors.HeaderInfoSeparator, separator, colors.HeaderInfoDesc, description)
} else {
text = fmt.Sprintf("%s%s[-::-]", boldColor, name)
}
iw.SetText(text)
}
// makeBold converts a tview color tag like "[orange]" to "[orange::b]"
func makeBold(colorTag string) string {
return strings.TrimSuffix(colorTag, "]") + "::b]"
}
// Primitive returns the underlying tview primitive
func (iw *InfoWidget) Primitive() tview.Primitive {
return iw.TextView
}

View file

@ -1,159 +0,0 @@
package header
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/boolean-maybe/tiki/config"
"github.com/rivo/tview"
)
// StatCollector allows components to register and manage dynamic stats
// displayed in the header's stats widget.
type StatCollector interface {
// AddStat registers or updates a stat. Lower priority values display higher.
// Returns false if the stat limit (6) is reached and key doesn't exist.
AddStat(key, value string, priority int) bool
// RemoveStat removes a stat by key. Returns true if stat existed.
RemoveStat(key string) bool
}
// statEntry represents a single stat in the widget
type statEntry struct {
key string
value string
priority int
}
// StatsWidget displays application statistics dynamically
type StatsWidget struct {
*tview.TextView
stats map[string]*statEntry // key -> entry for O(1) lookup
sorted []*statEntry // sorted by priority for rendering
mu sync.RWMutex // thread safety
maxStats int // fixed at 6
}
// NewStatsWidget creates a new stats display widget
func NewStatsWidget() *StatsWidget {
tv := tview.NewTextView()
tv.SetDynamicColors(true)
tv.SetTextAlign(tview.AlignLeft)
tv.SetWrap(false)
sw := &StatsWidget{
TextView: tv,
stats: make(map[string]*statEntry),
sorted: make([]*statEntry, 0, 6),
maxStats: 6,
}
return sw
}
// AddStat registers or updates a stat. Lower priority values display higher.
// Returns false if the stat limit (6) is reached and key doesn't exist.
func (sw *StatsWidget) AddStat(key, value string, priority int) bool {
sw.mu.Lock()
defer sw.mu.Unlock()
// Check if key exists (update case)
if entry, exists := sw.stats[key]; exists {
entry.value = value
entry.priority = priority
sw.rebuildSorted()
sw.update()
return true
}
// Check limit for new key
if len(sw.stats) >= sw.maxStats {
return false
}
// Add new entry
entry := &statEntry{
key: key,
value: value,
priority: priority,
}
sw.stats[key] = entry
sw.rebuildSorted()
sw.update()
return true
}
// RemoveStat removes a stat by key. Returns true if stat existed.
func (sw *StatsWidget) RemoveStat(key string) bool {
sw.mu.Lock()
defer sw.mu.Unlock()
if _, exists := sw.stats[key]; !exists {
return false
}
delete(sw.stats, key)
sw.rebuildSorted()
sw.update()
return true
}
// GetKeys returns all current stat keys
func (sw *StatsWidget) GetKeys() []string {
sw.mu.RLock()
defer sw.mu.RUnlock()
keys := make([]string, 0, len(sw.stats))
for k := range sw.stats {
keys = append(keys, k)
}
return keys
}
// Primitive returns the underlying tview primitive
func (sw *StatsWidget) Primitive() tview.Primitive {
return sw.TextView
}
// rebuildSorted rebuilds the sorted slice from the map (must be called with lock held)
func (sw *StatsWidget) rebuildSorted() {
sw.sorted = make([]*statEntry, 0, len(sw.stats))
for _, entry := range sw.stats {
sw.sorted = append(sw.sorted, entry)
}
sort.Slice(sw.sorted, func(i, j int) bool {
return sw.sorted[i].priority < sw.sorted[j].priority
})
}
// update refreshes the stats display (must be called with lock held)
func (sw *StatsWidget) update() {
if len(sw.sorted) == 0 {
sw.SetText("")
return
}
// find max label length for value alignment
maxLabelLen := 0
for _, entry := range sw.sorted {
if len(entry.key) > maxLabelLen {
maxLabelLen = len(entry.key)
}
}
colors := config.GetColors()
var lines []string
for _, entry := range sw.sorted {
// pad after colon to align values
padding := strings.Repeat(" ", maxLabelLen-len(entry.key))
lines = append(lines, fmt.Sprintf("%s%s:%s%s %s", colors.HeaderInfoLabel, entry.key, colors.HeaderInfoValue, padding, entry.value))
}
sw.SetText(strings.Join(lines, "\n"))
}

View file

@ -149,8 +149,12 @@ func (rl *RootLayout) onLayoutChange() {
rl.headerConfig.SetPluginActions(nil)
}
// Apply view-specific stats from the view
rl.updateViewStats(newView)
// Update header info section with view name and description
if vip, ok := newView.(controller.ViewInfoProvider); ok {
rl.headerConfig.SetViewInfo(vip.GetViewName(), vip.GetViewDescription())
}
// Update statusline stats from the view
rl.updateStatuslineViewStats(newView)
// Run view activated callback (for focus setters, etc.)
@ -301,21 +305,10 @@ func (rl *RootLayout) Cleanup() {
// onStoreChange is called when the task store changes (task created/updated/deleted)
func (rl *RootLayout) onStoreChange() {
if rl.contentView != nil {
rl.updateViewStats(rl.contentView)
rl.updateStatuslineViewStats(rl.contentView)
}
}
// updateViewStats reads stats from the view and updates the header
func (rl *RootLayout) updateViewStats(v controller.View) {
rl.headerConfig.ClearViewStats()
if sp, ok := v.(controller.StatsProvider); ok {
for _, stat := range sp.GetStats() {
rl.headerConfig.SetViewStat(stat.Name, stat.Value, stat.Order)
}
}
}
// updateStatuslineViewStats reads stats from the view and updates the statusline right section.
// Reuses StatsProvider — no separate interface needed until header and statusline stats diverge.
func (rl *RootLayout) updateStatuslineViewStats(v controller.View) {

View file

@ -97,12 +97,12 @@ func (sw *StatuslineWidget) render(width int) {
msgRendered := sw.renderMessage(msg, colors)
msgLen := visibleLen(msg)
// pad to fill the width
// pad to fill the width with the fill background color
padLen := width - leftLen - msgLen - rightStatsLen
if padLen < 1 {
padLen = 1
}
padding := strings.Repeat(" ", padLen)
padding := fmt.Sprintf("[-:%s]%s[-:-]", colors.StatuslineFillBg, strings.Repeat(" ", padLen))
sw.SetText(left + padding + msgRendered + rightStats)
}
@ -123,8 +123,8 @@ func (sw *StatuslineWidget) renderLeftSegments(segments []statSegment, colors *c
// segment text: " value "
fmt.Fprintf(&b, "[%s:%s] %s ", fg, bg, seg.value)
// separator: fg = current bg (creates the arrow), bg = next segment's bg or default
nextBg := "-" // terminal default for last segment
// separator: fg = current bg (creates the arrow), bg = next segment's bg or fill
nextBg := colors.StatuslineFillBg
if i < len(segments)-1 {
nextBg, _ = segmentColors(i+1, colors)
}
@ -148,8 +148,8 @@ func (sw *StatuslineWidget) renderRightSegments(segments []statSegment, colors *
for i, seg := range segments {
bg, fg := segmentColors(i, colors)
// separator before segment: fg = segment bg, bg = previous segment bg (or terminal default)
prevBg := "-" // terminal default for first right segment
// separator before segment: fg = segment bg, bg = previous segment bg (or fill)
prevBg := colors.StatuslineFillBg
if i > 0 {
prevBg, _ = segmentColors(i-1, colors)
}

View file

@ -17,6 +17,7 @@ func testColors() *config.ColorConfig {
StatuslineAccentFg: "#accent_fg",
StatuslineMessageFg: "#msg_fg",
StatuslineMessageBg: "#msg_bg",
StatuslineFillBg: "#fill_bg",
}
}
@ -108,9 +109,9 @@ func TestRenderLeftSegments_singleSegment(t *testing.T) {
if !strings.Contains(result, "[#accent_fg:#accent_bg] v1.0 ") {
t.Errorf("first segment should use accent colors, got %q", result)
}
// separator: fg=accent_bg (current), bg="-" (last segment)
if !strings.Contains(result, "[#accent_bg:-]"+separatorRight) {
t.Errorf("separator should transition to terminal default, got %q", result)
// separator: fg=accent_bg (current), bg=fill (last segment)
if !strings.Contains(result, "[#accent_bg:#fill_bg]"+separatorRight) {
t.Errorf("separator should transition to fill background, got %q", result)
}
// ends with color reset
if !strings.HasSuffix(result, "[-:-]") {
@ -161,9 +162,9 @@ func TestRenderRightSegments_singleSegment(t *testing.T) {
if !strings.Contains(result, "[#accent_fg:#accent_bg] 42 ") {
t.Errorf("segment 0 should use accent colors, got %q", result)
}
// separator: fg=accent_bg, bg="-" (terminal default, first segment)
if !strings.Contains(result, "[#accent_bg:-]"+separatorLeft) {
t.Errorf("separator should be accent→terminal, got %q", result)
// separator: fg=accent_bg, bg=fill (first right segment)
if !strings.Contains(result, "[#accent_bg:#fill_bg]"+separatorLeft) {
t.Errorf("separator should be accent→fill, got %q", result)
}
}

View file

@ -62,6 +62,12 @@ func (tv *TaskDetailView) GetViewID() model.ViewID {
return tv.viewID
}
// GetViewName returns the view name for the header info section
func (tv *TaskDetailView) GetViewName() string { return model.TaskDetailViewName }
// GetViewDescription returns the view description for the header info section
func (tv *TaskDetailView) GetViewDescription() string { return model.TaskDetailViewDesc }
// OnFocus is called when the view becomes active
func (tv *TaskDetailView) OnFocus() {
// Register listener for live updates

View file

@ -131,6 +131,12 @@ func (ev *TaskEditView) GetViewID() model.ViewID {
return ev.viewID
}
// GetViewName returns the view name for the header info section
func (ev *TaskEditView) GetViewName() string { return model.TaskEditViewName }
// GetViewDescription returns the view description for the header info section
func (ev *TaskEditView) GetViewDescription() string { return model.TaskEditViewDesc }
// SetDescOnly enables description-only edit mode where metadata is read-only.
func (ev *TaskEditView) SetDescOnly(descOnly bool) {
ev.descOnly = descOnly

View file

@ -195,6 +195,12 @@ func (pv *PluginView) GetActionRegistry() *controller.ActionRegistry {
// ShowNavigation returns whether plugin navigation keys should be shown in the header.
func (pv *PluginView) ShowNavigation() bool { return pv.showNavigation }
// GetViewName returns the plugin name for the header info section
func (pv *PluginView) GetViewName() string { return pv.pluginDef.GetName() }
// GetViewDescription returns the plugin description for the header info section
func (pv *PluginView) GetViewDescription() string { return pv.pluginDef.GetDescription() }
// GetViewID returns the view identifier
func (pv *PluginView) GetViewID() model.ViewID {
return model.MakePluginViewID(pv.pluginDef.Name)