diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6f50b18..f14df8b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -71,9 +71,15 @@ jobs: - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: Run govulncheck + # govulncheck can emit duplicate stack entries for the same result, + # which upload-sarif rejects as invalid SARIF. Deduplicate stacks + # before uploading so the report is always schema-valid. run: | govulncheck -C . -format sarif ./... > results.sarif + jq '(.runs[].results[] | select(.stacks != null) | .stacks) |= unique' results.sarif > results.dedup.sarif + mv results.dedup.sarif results.sarif - uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + if: always() with: sarif_file: results.sarif diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 354927b..dd5ae7f 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -477,6 +477,7 @@ func (c *Commands) defaultCommands() []*CommandItem { NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}), NewCommandItem(c.com.Styles, "switch_cron_session", "Scheduled Job Sessions", "", ActionOpenDialog{CronSessionsID}), NewCommandItem(c.com.Styles, "switch_cron_jobs", "Scheduled Jobs", "", ActionOpenDialog{CronJobsID}), + NewCommandItem(c.com.Styles, "skills", "Skills", "", ActionOpenDialog{SkillsID}), NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}), NewCommandItem(c.com.Styles, "custom_providers", "Manage Custom Providers", "", ActionOpenDialog{DialogID: ProviderManagerID}), NewCommandItem(c.com.Styles, "add_custom_provider", "Add Custom Provider", "", ActionOpenDialog{DialogID: ProviderWizardID}), diff --git a/internal/ui/dialog/skill_detail.go b/internal/ui/dialog/skill_detail.go new file mode 100644 index 0000000..93fdc82 --- /dev/null +++ b/internal/ui/dialog/skill_detail.go @@ -0,0 +1,262 @@ +package dialog + +import ( + "os" + "path/filepath" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" + "github.com/hackafterdark/phosphor/internal/ui/common" + "github.com/hackafterdark/phosphor/pkg/skills" +) + +const ( + // SkillDetailID is the identifier for the skill detail dialog. + SkillDetailID = "skill_detail" + skillDetailMinWidth = 56 + skillDetailMaxWidth = 140 + skillDetailWidthRatio = 0.9 + skillDetailMinHeight = 14 + skillDetailMaxHeight = 44 + skillDetailHeightRatio = 0.8 + skillDetailContentPad = 1 +) + +// SkillDetail is a read-only viewer for one skill's SKILL.md: the full +// file content in a scrolling pane, sized generously so lengthy skills +// stay readable. It is opened from the skills dialog via the details key. +type SkillDetail struct { + com *common.Common + help help.Model + body viewport.Model + + skill *skills.Skill + source skills.SourceType + loading bool + content string + err error + contentWidth int + + keyMap struct { + ScrollUp key.Binding + ScrollDown key.Binding + Close key.Binding + } +} + +var ( + _ Dialog = (*SkillDetail)(nil) + _ help.KeyMap = (*SkillDetail)(nil) +) + +// NewSkillDetail creates the detail dialog in its loading state; the +// owner sends a [SkillDetailLoadedMsg] to fill it. +func NewSkillDetail(com *common.Common, skill *skills.Skill, source skills.SourceType) *SkillDetail { + s := &SkillDetail{com: com, skill: skill, source: source, loading: true} + + h := help.New() + h.Styles = com.Styles.DialogHelpStyles() + s.help = h + + s.keyMap.ScrollUp = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑/↓", "scroll"), + ) + s.keyMap.ScrollDown = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + ) + s.keyMap.Close = CloseKey + + body := viewport.New() + body.SoftWrap = true + body.FillHeight = true + body.MouseWheelEnabled = true + body.KeyMap = viewport.KeyMap{ + Up: s.keyMap.ScrollUp, + Down: s.keyMap.ScrollDown, + PageUp: key.NewBinding(key.WithKeys("pgup")), + PageDown: key.NewBinding(key.WithKeys("pgdn")), + HalfPageUp: key.NewBinding(key.WithDisabled()), + HalfPageDown: key.NewBinding(key.WithDisabled()), + // Everything soft-wraps; there is nothing sideways to scroll. + Left: key.NewBinding(key.WithDisabled()), + Right: key.NewBinding(key.WithDisabled()), + } + s.body = body + + return s +} + +// SkillDetailLoadedMsg carries the skill file content for the detail +// dialog. The owner reads the file on a tea.Cmd so disk or embedded +// reads never block the update loop. +type SkillDetailLoadedMsg struct { + Content string + Err error +} + +// SkillFileContent reads the raw SKILL.md for a skill, resolving builtin +// skills through the embedded filesystem. +func SkillFileContent(skill *skills.Skill) (string, error) { + if skill == nil { + return "", nil + } + if strings.HasPrefix(skill.SkillFilePath, skills.BuiltinPrefix) { + rel := strings.TrimPrefix(skill.SkillFilePath, skills.BuiltinPrefix) + data, err := skills.BuiltinFS().ReadFile("builtin/" + filepath.ToSlash(rel)) + return string(data), err + } + data, err := os.ReadFile(skill.SkillFilePath) + return string(data), err +} + +// ID implements Dialog. +func (s *SkillDetail) ID() string { + return SkillDetailID +} + +// HandleMsg implements [Dialog]. +func (s *SkillDetail) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case SkillDetailLoadedMsg: + s.loading = false + s.err = msg.Err + s.content = msg.Content + s.body.SetContent(s.content) + s.body.GotoTop() + return nil + + case common.CoalescedWheelMsg: + if msg.DeltaY != 0 { + s.body, _ = s.body.Update(tea.MouseWheelMsg(msg.Mouse)) + } + return nil + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, s.keyMap.Close): + return ActionClose{} + default: + // ↑/↓ (and pgup/pgdn) belong to the reading pane. + s.body, _ = s.body.Update(msg) + } + } + return nil +} + +// Cursor returns the cursor for the dialog. +func (s *SkillDetail) Cursor() *tea.Cursor { + return nil +} + +// measure is the dialog's fixed geometry for a screen, clamped to the +// terminal so a tiny window never draws a frame taller than it can show. +func (s *SkillDetail) measure(area uv.Rectangle) (width, height, bodyHeight int) { + t := s.com.Styles + width = max(min(skillDetailMinWidth, area.Dx()), + min(int(float64(area.Dx())*skillDetailWidthRatio), skillDetailMaxWidth)) + height = max(min(skillDetailMinHeight, area.Dy()), + min(int(float64(area.Dy())*skillDetailHeightRatio), skillDetailMaxHeight)) + + verticalBudget := t.Dialog.View.GetVerticalFrameSize() + + t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + 1 + // the subtitle line, always present + 1 + // the title gap row + 2 + // one gap row each above and below the body + t.Dialog.HelpView.GetVerticalFrameSize() + bodyHeight = max(1, height-verticalBudget) + return +} + +// Draw implements [Dialog]. +func (s *SkillDetail) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + width, _, bodyHeight := s.measure(area) + DrawCenter(scr, area, s.render(width, bodyHeight)) + return nil +} + +// render assembles the framed dialog: title, subtitle, the reading pane, +// and the key help. +func (s *SkillDetail) render(width, bodyHeight int) string { + t := s.com.Styles + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() + + rc := NewRenderContext(t, width) + rc.Gap = 1 + rc.TitleGap = 1 + rc.SubtitleStyle = t.Dialog.NormalItem + rc.Title = s.title() + rc.Subtitle = s.subtitle() + + switch { + case s.loading: + rc.AddPart(padded(t.Dialog.SecondaryText.Render("Reading skill file..."), innerWidth, bodyHeight)) + case s.err != nil: + rc.AddPart(padded(t.Dialog.SecondaryText.Render("Failed to read skill file: "+s.err.Error()), innerWidth, bodyHeight)) + case s.content == "": + rc.AddPart(padded(t.Dialog.SecondaryText.Render("(empty file)"), innerWidth, bodyHeight)) + default: + rc.AddPart(s.renderBody(innerWidth, bodyHeight)) + } + + rc.Help = s.help.View(s) + return rc.Render() +} + +// title shows the skill name, or the file path if the name is missing. +func (s *SkillDetail) title() string { + if s.skill == nil { + return "Skill" + } + if s.skill.Name != "" { + return s.skill.Name + } + return filepath.Base(s.skill.SkillFilePath) +} + +// subtitle shows where the skill comes from and its description. +func (s *SkillDetail) subtitle() string { + if s.skill == nil { + return " " + } + source := string(s.source) + if s.skill.Description != "" { + return source + " · " + s.skill.Description + } + return source +} + +// renderBody is the scrolling reading pane holding the raw SKILL.md. +func (s *SkillDetail) renderBody(innerWidth, bodyHeight int) string { + contentWidth := max(20, innerWidth-2*skillDetailContentPad) + if contentWidth != s.contentWidth { + s.body.SetContent(s.content) + s.contentWidth = contentWidth + } + s.body.SetWidth(contentWidth) + s.body.SetHeight(bodyHeight) + return lipgloss.NewStyle(). + Width(contentWidth+2*skillDetailContentPad). + Padding(0, skillDetailContentPad). + Height(bodyHeight). + MaxHeight(bodyHeight). + Render(s.body.View()) +} + +// ShortHelp implements [help.KeyMap]. +func (s *SkillDetail) ShortHelp() []key.Binding { + return []key.Binding{ + s.keyMap.ScrollUp, + s.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (s *SkillDetail) FullHelp() [][]key.Binding { + return [][]key.Binding{s.ShortHelp()} +} diff --git a/internal/ui/dialog/skills.go b/internal/ui/dialog/skills.go index ccaf8f1..aa39df9 100644 --- a/internal/ui/dialog/skills.go +++ b/internal/ui/dialog/skills.go @@ -10,6 +10,7 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" "github.com/hackafterdark/phosphor/internal/ui/common" "github.com/hackafterdark/phosphor/internal/ui/list" "github.com/hackafterdark/phosphor/internal/ui/styles" @@ -23,8 +24,17 @@ import ( const SkillsID = "skills" const ( - skillsDialogMaxWidth = 70 - skillsDialogMaxHeight = 20 + skillsDialogMinWidth = 60 + skillsDialogMaxWidth = 120 + skillsDialogWidthRatio = 0.9 + skillsDialogMinHeight = 14 + skillsDialogMaxHeight = 36 + skillsDialogHeightRatio = 0.75 + // skillSourceColWidth is the fixed width of the source column so the + // scope labels line up across rows; skillTitleReserve keeps enough + // room for the icon and skill name. + skillSourceColWidth = 8 + skillTitleReserve = 24 ) // ActionToggleSkill is sent when a skill is toggled in the skills dialog. @@ -33,6 +43,13 @@ type ActionToggleSkill struct { Disable bool } +// ActionShowSkillDetail is sent when the selected skill's content should +// be shown in the skill detail dialog. +type ActionShowSkillDetail struct { + Skill *skills.Skill + Source skills.SourceType +} + // SkillItem represents a single skill entry in the skills dialog. type SkillItem struct { *list.Versioned @@ -40,6 +57,7 @@ type SkillItem struct { Description string Source skills.SourceType Disabled bool + Skill *skills.Skill t *styles.Styles focused bool m fuzzy.Match @@ -58,6 +76,7 @@ type Skills struct { keyMap struct { Toggle key.Binding + Details key.Binding Next key.Binding Previous key.Binding Close key.Binding @@ -83,6 +102,10 @@ func NewSkills(com *common.Common) *Skills { key.WithKeys("enter", "space", "ctrl+y"), key.WithHelp("enter/space", "toggle"), ) + s.keyMap.Details = key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "details"), + ) s.keyMap.Next = key.NewBinding( key.WithKeys("down", "ctrl+n"), key.WithHelp("↓", "next item"), @@ -127,6 +150,19 @@ func (s *Skills) HandleMsg(msg tea.Msg) Action { } s.list.SelectNext() s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Details): + selectedItem := s.list.SelectedItem() + if selectedItem == nil { + break + } + skillItem, ok := selectedItem.(*SkillItem) + if !ok || skillItem.Skill == nil { + break + } + return ActionShowSkillDetail{ + Skill: skillItem.Skill, + Source: skillItem.Source, + } case key.Matches(msg, s.keyMap.Toggle): selectedItem := s.list.SelectedItem() if selectedItem == nil { @@ -155,10 +191,13 @@ func (s *Skills) Cursor() *tea.Cursor { // Draw implements [Dialog]. func (s *Skills) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := s.com.Styles - width := max(0, min(skillsDialogMaxWidth, area.Dx())) - height := max(0, min(skillsDialogMaxHeight, area.Dy())) + width := max(min(skillsDialogMinWidth, area.Dx()), + min(int(float64(area.Dx())*skillsDialogWidthRatio), skillsDialogMaxWidth)) + height := max(min(skillsDialogMinHeight, area.Dy()), + min(int(float64(area.Dy())*skillsDialogHeightRatio), skillsDialogMaxHeight)) innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + 1 + // the title gap row t.Dialog.HelpView.GetVerticalFrameSize() + t.Dialog.View.GetVerticalFrameSize() @@ -167,6 +206,7 @@ func (s *Skills) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { rc := NewRenderContext(t, width) rc.Title = "Skills" + rc.TitleGap = 1 rc.Subtitle = s.subtitle() rc.Gap = 1 @@ -204,6 +244,7 @@ func (s *Skills) subtitle() string { func (s *Skills) ShortHelp() []key.Binding { return []key.Binding{ s.keyMap.Toggle, + s.keyMap.Details, s.keyMap.Close, } } @@ -213,6 +254,7 @@ func (s *Skills) FullHelp() [][]key.Binding { m := [][]key.Binding{} slice := []key.Binding{ s.keyMap.Toggle, + s.keyMap.Details, s.keyMap.Next, s.keyMap.Previous, s.keyMap.Close, @@ -245,6 +287,7 @@ func (s *Skills) setItems() { Description: skill.Description, Source: s.sourceFor(skill), Disabled: disabled[skill.Name], + Skill: skill, t: s.com.Styles, }) } @@ -346,13 +389,29 @@ func (s *SkillItem) SetMatch(m fuzzy.Match) { // Render implements [list.FilterableItem]. func (s *SkillItem) Render(width int) string { - info := string(s.Source) + // The leading icon carries the enabled/disabled state: a filled dot + // in the success color for enabled skills, a hollow dot for disabled + // ones. + icon, iconStyle := "●", s.t.Dialog.CheckboxChecked if s.Disabled { - info += " · disabled" - } - if s.Description != "" { - info += " · " + s.Description + icon, iconStyle = "○", s.t.Dialog.CheckboxUnchecked } + title := iconStyle.Render(icon) + " " + s.Name + + var info string + // Collapse any newlines/runs of whitespace and truncate the + // description to a fixed budget, then pad to that width. The source + // column is padded too, so the info block keeps a constant width + // across rows and renderItem's right-alignment puts the source and + // description columns at the same offsets on every row, including + // rows with an empty description. + desc := strings.Join(strings.Fields(s.Description), " ") + budget := max(0, width-skillTitleReserve-skillSourceColWidth-3) + desc = ansi.Truncate(desc, budget, "…") + desc += strings.Repeat(" ", max(0, budget-ansi.StringWidth(desc))) + source := string(s.Source) + source += strings.Repeat(" ", max(0, skillSourceColWidth-ansi.StringWidth(source))) + info = source + " · " + desc itemStyles := ListItemStyles{ ItemBlurred: s.t.Dialog.NormalItem, @@ -361,11 +420,14 @@ func (s *SkillItem) Render(width int) string { InfoTextFocused: s.t.Dialog.Sessions.InfoFocused, } if s.Disabled { - itemStyles.InfoTextBlurred = s.t.Dialog.SecondaryText - itemStyles.InfoTextFocused = s.t.Dialog.SecondaryText + // Padding(0, 0): SecondaryText carries its own horizontal padding, + // which would widen the disabled row's info block and shift its + // columns. The padded block width must stay identical across rows. + itemStyles.InfoTextBlurred = s.t.Dialog.SecondaryText.Padding(0, 0) + itemStyles.InfoTextFocused = s.t.Dialog.SecondaryText.Padding(0, 0) } - return renderItem(itemStyles, s.Name, info, s.focused, width, nil, &s.m) + return renderItem(itemStyles, title, info, s.focused, width, nil, &s.m) } // ToggleSkill adds or removes a skill name from the disabled-skills list diff --git a/internal/ui/dialog/skills_test.go b/internal/ui/dialog/skills_test.go index cf827df..76ab39e 100644 --- a/internal/ui/dialog/skills_test.go +++ b/internal/ui/dialog/skills_test.go @@ -1,14 +1,17 @@ package dialog import ( + "strings" "testing" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/hackafterdark/phosphor/internal/ui/common" "github.com/hackafterdark/phosphor/internal/ui/list" uis "github.com/hackafterdark/phosphor/internal/ui/styles" "github.com/hackafterdark/phosphor/internal/workspace" "github.com/hackafterdark/phosphor/pkg/config" + "github.com/hackafterdark/phosphor/pkg/skills" "github.com/stretchr/testify/require" ) @@ -169,3 +172,80 @@ func TestToggleSkillNoOpWhenUnchanged(t *testing.T) { // Ensure the item type satisfies the filterable list contract. var _ list.FilterableItem = (*SkillItem)(nil) + +func TestSkillItemRenderFitsWidth(t *testing.T) { + t.Parallel() + + st := uis.CharmtonePantera() + item := &SkillItem{ + Versioned: list.NewVersioned(), + Name: "long-desc-skill", + Description: "Line one of the description.\n" + strings.Repeat("x", 900) + " trailing text", + Source: skills.SourceUser, + t: &st, + } + + out := item.Render(60) + // The row must stay on a single line no matter how long the + // description is; the item style may add its own padding on top. + require.Equal(t, 1, lipgloss.Height(out), "rendered item must be a single line") + require.Contains(t, out, "long-desc-skill") + require.Contains(t, out, "…", "long description must be truncated") + require.NotContains(t, out, strings.Repeat("x", 900)) +} + +func TestSkillsDialogDetailsOpensDetail(t *testing.T) { + t.Parallel() + + ws := &skillsTestWorkspace{cfg: &config.Config{}} + d := newSkillsTestDialog(t, ws) + + d.list.SetSelected(0) + action := d.HandleMsg(tea.KeyPressMsg{Code: 'd', Text: "d"}) + detail, ok := action.(ActionShowSkillDetail) + require.True(t, ok) + require.NotNil(t, detail.Skill) + + // The detail dialog loads the skill file and keeps its ID. + st := uis.CharmtonePantera() + sd := NewSkillDetail(&common.Common{Workspace: ws, Styles: &st}, detail.Skill, detail.Source) + require.Equal(t, SkillDetailID, sd.ID()) +} + +func TestSkillDetailScrollAndClose(t *testing.T) { + t.Parallel() + + ws := &skillsTestWorkspace{cfg: &config.Config{}} + st := uis.CharmtonePantera() + com := &common.Common{Workspace: ws, Styles: &st} + + // A builtin skill is guaranteed to exist and to have content. + builtin := skills.DiscoverBuiltin() + require.NotEmpty(t, builtin) + sd := NewSkillDetail(com, builtin[0], skills.SourceSystem) + content, err := SkillFileContent(builtin[0]) + require.NoError(t, err) + require.NotEmpty(t, content) + require.Nil(t, sd.HandleMsg(SkillDetailLoadedMsg{Content: content})) + require.False(t, sd.loading) + + // The pane is sized to 4 rows so scrolling is observable; the long + // content guarantees there is somewhere to scroll to. + longContent := strings.Repeat("scroll me\n", 50) + require.Nil(t, sd.HandleMsg(SkillDetailLoadedMsg{Content: longContent})) + // The viewport only measures its content when it is rendered, so the + // scroll keys need the pane to have painted once at its size. + sd.body.SetContent(longContent) + sd.body.SetWidth(38) + sd.body.SetHeight(4) + sd.body.View() + require.Equal(t, 0, sd.body.YOffset()) + + // Scrolling keys belong to the reading pane and produce no action. + // Down scrolls by one line: the reading pane must follow the key. + require.Nil(t, sd.HandleMsg(tea.KeyPressMsg{Code: tea.KeyDown})) + require.Greater(t, sd.body.YOffset(), 0) + + // Escape closes. + require.Equal(t, ActionClose{}, sd.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEscape})) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a3a6c7d..dfca73d 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1941,6 +1941,26 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, cmd) } + // Skills dialog messages. + case dialog.ActionToggleSkill: + name, disable := msg.Name, msg.Disable + cmds = append(cmds, func() tea.Msg { + if err := dialog.ToggleSkill(m.com.Workspace, name, disable); err != nil { + return util.NewErrorMsg(err) + } + if disable { + return util.NewInfoMsg("Skill " + name + " disabled") + } + return util.NewInfoMsg("Skill " + name + " enabled") + }) + case dialog.ActionShowSkillDetail: + m.dialog.OpenDialog(dialog.NewSkillDetail(m.com, msg.Skill, msg.Source)) + skill := msg.Skill + cmds = append(cmds, func() tea.Msg { + content, err := dialog.SkillFileContent(skill) + return dialog.SkillDetailLoadedMsg{Content: content, Err: err} + }) + // Command dialog messages. case dialog.ActionToggleYoloMode: yolo := !m.com.Workspace.PermissionSkipRequests() @@ -4718,6 +4738,10 @@ func (m *UI) openDialog(id string) tea.Cmd { if cmd := m.openCronJobsDialog(); cmd != nil { cmds = append(cmds, cmd) } + case dialog.SkillsID: + if cmd := m.openSkillsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } case dialog.ModelsID: if cmd := m.openModelsDialog(); cmd != nil { cmds = append(cmds, cmd) @@ -5263,6 +5287,18 @@ func (m *UI) openCronJobsDialog() tea.Cmd { return nil } +// openSkillsDialog opens the skills dialog. +func (m *UI) openSkillsDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.SkillsID) { + // Bring to front + m.dialog.BringToFront(dialog.SkillsID) + return nil + } + + m.dialog.OpenDialog(dialog.NewSkills(m.com)) + return nil +} + // openFilesDialog opens the file picker dialog. func (m *UI) openFilesDialog() tea.Cmd { if m.dialog.ContainsDialog(dialog.FilePickerID) {