Skip to content

Commit 4303494

Browse files
jongioCopilot
andauthored
feat: add excluded words filter for session list (#154)
* feat: add excluded words filter for session list Add a configurable list of words that hides matching sessions from the dispatch list. Sessions are excluded if their summary or any turn user message contains one of the words (case-insensitive matching). - Add ExcludedWords field to config and FilterOptions - Add SQL filtering with parameterized NOT LIKE + NOT EXISTS subquery - Add Excluded Words text field to the Settings panel (comma-separated) - Reload session list on config panel close to apply changes immediately - Add tests for summary match, turn match, case insensitivity, multi-word Co-authored-by: Copilot <[email protected]> * docs: add word filtering to README, website, and changelog - Add Word filtering feature bullet to README - Update Settings panel field count (10 to 12) in README - Add excluded_words to config.astro options table and JSON example - Add Word Filtering feature section to features.astro with screenshot - Update Settings Panel description in features.astro (12 fields) - Add screenshot capture for excluded words field in screenshot.go - Add Unreleased section to CHANGELOG with the new feature Co-authored-by: Copilot <[email protected]> * chore: regenerate screenshots with excluded words field Regenerated all 190 screenshots across 5 themes. The config panel and config-editing screenshots now include the Excluded Words field, and a new excluded-words screenshot shows the field focused with sample words. Co-authored-by: Copilot <[email protected]> * fix(web): auto-generate changelog page from CHANGELOG.md Replace the hardcoded, stale changelog.astro with a build-time reader that parses the root CHANGELOG.md using marked. The page now stays in sync automatically with every release, no manual HTML updates needed. Also adds an 'All features' link below the feature grid on the landing page for discoverability. Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent 7eff773 commit 4303494

207 files changed

Lines changed: 414 additions & 137 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [Unreleased]
8+
9+
### Added
10+
- **Word filtering** — new `excluded_words` config option and Settings panel field. Enter a comma-separated list of words; sessions whose name or turn content contains any word (case-insensitive) are hidden from the list
11+
712
## [v0.11.1] — 2026-06-21
813

914
### Changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess
2020

2121
- **Full-text search** (`/`) — FTS5 full-text search with BM25 ranking when available, falling back to LIKE for older CLI versions. Two-tier: quick search (summaries, branches, repos, directories) returns results instantly; deep search (turns, checkpoints, files, refs) kicks in after 300ms. Searching a number (e.g. "42", "#42", "PR42") also matches session refs (PRs, issues, commits)
2222
- **Directory filtering** (`f`) — hierarchical tree panel for toggling directory exclusion, persisted to config
23+
- **Word filtering** (Settings panel) — comma-separated list of words to exclude sessions by content. Sessions whose name or conversation turns contain any excluded word (case-insensitive) are hidden from the list
2324
- **Sorting** (`s` / `S`) — 5 fields (updated, folder, name, created, turns) with toggleable direction
2425
- **Grouping (pivot) modes** (`Tab`) — flat, folder, repo, branch, date — displayed as collapsible trees with session counts
2526
- **Time range filtering** (`1``4`) — 1 hour, 1 day, 7 days, all
@@ -34,7 +35,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess
3435
- **Work status detection** — analyzes `plan.md` files to identify sessions with incomplete planned work. Colored dots show completion status in the session list and preview panel. Press `R` to explicitly scan work status. Filter by work completion via the `!` status picker. Supports AI-powered analysis via Copilot SDK `analyze_completion` tool
3536
- **Session hiding** (`h` / `H`) — hide sessions from the list, toggle visibility of hidden sessions, persistent state
3637
- **Session favorites** (`*`) — star sessions as favorites. Filter to show only favorites via the `!` status picker
37-
- **Settings panel** (`,`) — 10 fields: Yolo Mode, Agent, Model, Launch Mode, Pane Direction, Terminal, Shell, Custom Command, Theme, Crash Recovery
38+
- **Settings panel** (`,`) — 12 fields: Yolo Mode, Agent, Model, Launch Mode, Pane Direction, Terminal, Shell, Custom Command, Theme, Crash Recovery, Preview Position, Excluded Words
3839
- **Shell picker** — auto-detects installed shells, modal picker when multiple available
3940
- **5 built-in themes** — Dispatch Dark, Dispatch Light, Campbell, One Half Dark, One Half Light + custom via Windows Terminal JSON
4041
- **Help overlay** (`?`) — two-column grouped keyboard shortcuts

internal/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ type Config struct {
115115
// Terminal and Shell settings are still used.
116116
CustomCommand string `json:"custom_command,omitempty"`
117117

118+
// ExcludedWords is a list of words used to filter sessions from the
119+
// dispatch list. Sessions whose summary or turn content contains any
120+
// of these words (case-insensitive) are hidden from the session list.
121+
ExcludedWords []string `json:"excluded_words,omitempty"`
122+
118123
// HiddenSessions is a list of session IDs that the user has chosen to
119124
// hide from the main session list. They can be revealed with the
120125
// "show hidden" toggle and unhidden individually.

internal/data/models.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ type FilterOptions struct {
208208
HasRefs bool `json:"has_refs,omitempty"`
209209
ExcludedDirs []string `json:"excluded_dirs,omitempty"`
210210

211+
// ExcludedWords is a list of words that hide sessions whose summary
212+
// or turn content contains any of these words (case-insensitive).
213+
ExcludedWords []string `json:"excluded_words,omitempty"`
214+
211215
// DeepSearch controls the breadth of the text search. When false
212216
// (default / quick mode), only session-level fields are searched
213217
// (summary, branch, repository, cwd). When true (deep mode), related

internal/data/store.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,15 @@ func (fb *filterBuilder) apply(f FilterOptions) {
368368
fb.args = append(fb.args, escapeLIKE(dir)+"%")
369369
}
370370
}
371+
if len(f.ExcludedWords) > 0 {
372+
for _, word := range f.ExcludedWords {
373+
pattern := "%" + escapeLIKE(strings.ToLower(word)) + "%"
374+
fb.wheres = append(fb.wheres,
375+
`(LOWER(COALESCE(s.summary,'')) NOT LIKE ? ESCAPE '\'`+
376+
` AND NOT EXISTS (SELECT 1 FROM turns t3 WHERE t3.session_id = s.id AND LOWER(t3.user_message) LIKE ? ESCAPE '\'))`)
377+
fb.args = append(fb.args, pattern, pattern)
378+
}
379+
}
371380
}
372381

373382
func (fb *filterBuilder) joinSQL() string {

internal/data/store_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,90 @@ func TestFilterCombinedRepositoryAndBranch(t *testing.T) {
906906
}
907907
}
908908

909+
func TestFilterByExcludedWords_Summary(t *testing.T) {
910+
s := newTestStore(t)
911+
defer func() { _ = s.Close() }()
912+
populateTestData(t, s)
913+
914+
// "auth" appears in sess-1 summary "Implement auth module"
915+
sessions, err := s.ListSessions(
916+
context.Background(),
917+
FilterOptions{ExcludedWords: []string{"auth"}},
918+
SortOptions{Field: SortByUpdated, Order: Descending}, 0,
919+
)
920+
if err != nil {
921+
t.Fatalf("ListSessions with ExcludedWords: %v", err)
922+
}
923+
for _, sess := range sessions {
924+
if sess.ID == "sess-1" {
925+
t.Error("sess-1 should be excluded by ExcludedWords matching summary")
926+
}
927+
}
928+
}
929+
930+
func TestFilterByExcludedWords_TurnContent(t *testing.T) {
931+
s := newTestStore(t)
932+
defer func() { _ = s.Close() }()
933+
populateTestData(t, s)
934+
935+
// "fuzzy" appears in sess-2 turn "Implement fuzzy search"
936+
sessions, err := s.ListSessions(
937+
context.Background(),
938+
FilterOptions{ExcludedWords: []string{"fuzzy"}},
939+
SortOptions{Field: SortByUpdated, Order: Descending}, 0,
940+
)
941+
if err != nil {
942+
t.Fatalf("ListSessions with ExcludedWords (turn): %v", err)
943+
}
944+
for _, sess := range sessions {
945+
if sess.ID == "sess-2" {
946+
t.Error("sess-2 should be excluded by ExcludedWords matching turn content")
947+
}
948+
}
949+
}
950+
951+
func TestFilterByExcludedWords_CaseInsensitive(t *testing.T) {
952+
s := newTestStore(t)
953+
defer func() { _ = s.Close() }()
954+
populateTestData(t, s)
955+
956+
// "AUTH" should match case-insensitively against "Implement auth module"
957+
sessions, err := s.ListSessions(
958+
context.Background(),
959+
FilterOptions{ExcludedWords: []string{"AUTH"}},
960+
SortOptions{Field: SortByUpdated, Order: Descending}, 0,
961+
)
962+
if err != nil {
963+
t.Fatalf("ListSessions with ExcludedWords (case): %v", err)
964+
}
965+
for _, sess := range sessions {
966+
if sess.ID == "sess-1" {
967+
t.Error("sess-1 should be excluded by case-insensitive ExcludedWords match")
968+
}
969+
}
970+
}
971+
972+
func TestFilterByExcludedWords_MultipleWords(t *testing.T) {
973+
s := newTestStore(t)
974+
defer func() { _ = s.Close() }()
975+
populateTestData(t, s)
976+
977+
// "auth" excludes sess-1, "experiment" excludes sess-4
978+
sessions, err := s.ListSessions(
979+
context.Background(),
980+
FilterOptions{ExcludedWords: []string{"auth", "experiment"}},
981+
SortOptions{Field: SortByUpdated, Order: Descending}, 0,
982+
)
983+
if err != nil {
984+
t.Fatalf("ListSessions with multiple ExcludedWords: %v", err)
985+
}
986+
for _, sess := range sessions {
987+
if sess.ID == "sess-1" || sess.ID == "sess-4" {
988+
t.Errorf("%s should be excluded by ExcludedWords", sess.ID)
989+
}
990+
}
991+
}
992+
909993
func TestFilterNoResults(t *testing.T) {
910994
s := newTestStore(t)
911995
defer func() { _ = s.Close() }()

internal/tui/components/configpanel.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const (
3131
cfgTheme
3232
cfgWorkspaceRecovery
3333
cfgPreviewPosition
34+
cfgExcludedWords
3435
cfgFieldCount
3536
)
3637

@@ -52,6 +53,7 @@ type ConfigPanel struct {
5253
theme string // active color scheme name ("auto" or a scheme name)
5354
workspaceRecovery bool
5455
previewPosition string // "right", "bottom", "left", "top"
56+
excludedWords string // comma-separated list of filter words
5557

5658
// Available options for cycling.
5759
terminals []string
@@ -95,6 +97,7 @@ type ConfigValues struct {
9597
Theme string
9698
WorkspaceRecovery bool
9799
PreviewPosition string
100+
ExcludedWords string // comma-separated filter words
98101
}
99102

100103
// SetValues loads the config panel state from external values.
@@ -110,6 +113,7 @@ func (c *ConfigPanel) SetValues(v ConfigValues) {
110113
c.theme = v.Theme
111114
c.workspaceRecovery = v.WorkspaceRecovery
112115
c.previewPosition = v.PreviewPosition
116+
c.excludedWords = v.ExcludedWords
113117
}
114118

115119
// Values returns the current state of all editable fields.
@@ -126,6 +130,7 @@ func (c *ConfigPanel) Values() ConfigValues {
126130
Theme: c.theme,
127131
WorkspaceRecovery: c.workspaceRecovery,
128132
PreviewPosition: c.previewPosition,
133+
ExcludedWords: c.excludedWords,
129134
}
130135
}
131136

@@ -210,6 +215,11 @@ func (c *ConfigPanel) HandleEnter() tea.Cmd {
210215
c.textInput.SetValue(c.customCommand)
211216
c.textInput.CharLimit = 256
212217
return c.textInput.Focus()
218+
case cfgExcludedWords:
219+
c.editing = true
220+
c.textInput.SetValue(c.excludedWords)
221+
c.textInput.CharLimit = 512
222+
return c.textInput.Focus()
213223
case cfgTheme:
214224
c.theme = c.cycleTheme(c.theme)
215225
case cfgWorkspaceRecovery:
@@ -235,6 +245,8 @@ func (c *ConfigPanel) ConfirmEdit() {
235245
c.model = val
236246
case cfgCustomCommand:
237247
c.customCommand = val
248+
case cfgExcludedWords:
249+
c.excludedWords = val
238250
default:
239251
// Non-editable fields are ignored.
240252
}
@@ -288,6 +300,7 @@ func (c ConfigPanel) View() string {
288300
{"Theme", themeDisplay(c.theme), false},
289301
{"Crash Recovery", boolDisplay(c.workspaceRecovery), false},
290302
{"Preview Position", previewPositionDisplay(c.previewPosition), false},
303+
{"Excluded Words", stringDisplay(c.excludedWords), false},
291304
}
292305

293306
var body strings.Builder
@@ -328,6 +341,14 @@ func (c ConfigPanel) View() string {
328341
body.WriteString(styles.DimmedStyle.Render(" Example: my-tool --session {sessionId}") + "\n")
329342
}
330343

344+
// Contextual help when the Excluded Words field is focused.
345+
if c.cursor == cfgExcludedWords {
346+
body.WriteString("\n")
347+
body.WriteString(styles.DimmedStyle.Render(" Comma-separated words to filter out sessions.") + "\n")
348+
body.WriteString(styles.DimmedStyle.Render(" Matches against session name and turn content.") + "\n")
349+
body.WriteString(styles.DimmedStyle.Render(" Example: MANDATORY, internal, secret") + "\n")
350+
}
351+
331352
body.WriteString("\n")
332353
if c.editing {
333354
body.WriteString(styles.DimmedStyle.Render("Enter confirm · Esc cancel"))

internal/tui/model.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ func NewModel() Model {
284284
Theme: cfg.Theme,
285285
WorkspaceRecovery: cfg.WorkspaceRecovery,
286286
PreviewPosition: cfg.EffectivePreviewPosition(),
287+
ExcludedWords: strings.Join(cfg.ExcludedWords, ", "),
287288
})
288289

289290
// Build the list of available theme names for the config panel.
@@ -344,6 +345,7 @@ func NewModel() Model {
344345

345346
m.filter.Since = timeRangeToSince(m.timeRange)
346347
m.filter.ExcludedDirs = cfg.ExcludedDirs
348+
m.filter.ExcludedWords = cfg.ExcludedWords
347349
m.preview.SetConversationSort(cfg.ConversationNewestFirst)
348350
return m
349351
}
@@ -801,6 +803,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
801803
CustomCommand: m.cfg.CustomCommand,
802804
Theme: m.cfg.Theme,
803805
WorkspaceRecovery: m.cfg.WorkspaceRecovery,
806+
ExcludedWords: strings.Join(m.cfg.ExcludedWords, ", "),
804807
})
805808
m.state = stateConfigPanel
806809
return m, nil
@@ -1215,7 +1218,7 @@ func (m Model) handleConfigKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
12151218
case key.Matches(msg, keys.Enter):
12161219
m.configPanel.ConfirmEdit()
12171220
m.saveConfigFromPanel()
1218-
return m, nil
1221+
return m, m.loadSessionsCmd()
12191222
default:
12201223
var cmd tea.Cmd
12211224
m.configPanel, cmd = m.configPanel.Update(msg)
@@ -1225,9 +1228,8 @@ func (m Model) handleConfigKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
12251228

12261229
switch {
12271230
case key.Matches(msg, keys.Escape):
1228-
// Cancel — close without saving (changes already persisted per-toggle).
12291231
m.state = stateSessionList
1230-
return m, nil
1232+
return m, m.loadSessionsCmd()
12311233
case key.Matches(msg, keys.Up):
12321234
m.configPanel.MoveUp()
12331235
case key.Matches(msg, keys.Down):
@@ -1259,6 +1261,8 @@ func (m *Model) saveConfigFromPanel() {
12591261
m.cfg.WorkspaceRecovery = v.WorkspaceRecovery
12601262
m.cfg.PreviewPosition = v.PreviewPosition
12611263
m.previewPosition = v.PreviewPosition
1264+
m.cfg.ExcludedWords = parseExcludedWords(v.ExcludedWords)
1265+
m.filter.ExcludedWords = m.cfg.ExcludedWords
12621266
resolveTheme(m.cfg)
12631267
// If the user switched back to "auto", re-apply with the detected
12641268
// terminal brightness so colours adapt immediately.
@@ -1272,6 +1276,25 @@ func (m *Model) saveConfigFromPanel() {
12721276
}
12731277
}
12741278

1279+
// parseExcludedWords splits a comma-separated string into trimmed, non-empty words.
1280+
func parseExcludedWords(s string) []string {
1281+
if strings.TrimSpace(s) == "" {
1282+
return nil
1283+
}
1284+
parts := strings.Split(s, ",")
1285+
words := make([]string, 0, len(parts))
1286+
for _, p := range parts {
1287+
w := strings.TrimSpace(p)
1288+
if w != "" {
1289+
words = append(words, w)
1290+
}
1291+
}
1292+
if len(words) == 0 {
1293+
return nil
1294+
}
1295+
return words
1296+
}
1297+
12751298
// ---------------------------------------------------------------------------
12761299
// Mouse handling
12771300
// ---------------------------------------------------------------------------

internal/tui/model_update_test.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1643,8 +1643,8 @@ func TestHandleConfigKey_EscapeClosesPanel(t *testing.T) {
16431643
if rm.state != stateSessionList {
16441644
t.Errorf("state = %v, want stateSessionList", rm.state)
16451645
}
1646-
if cmd != nil {
1647-
t.Error("escape from config should return nil cmd")
1646+
if cmd == nil {
1647+
t.Error("escape from config should return a reload cmd")
16481648
}
16491649
}
16501650

@@ -4064,3 +4064,35 @@ func TestLaunchExternal_ErrorIncludesContext(t *testing.T) {
40644064
t.Errorf("error %q should mention shell name", errStr)
40654065
}
40664066
}
4067+
4068+
func TestParseExcludedWords(t *testing.T) {
4069+
tests := []struct {
4070+
name string
4071+
input string
4072+
want []string
4073+
}{
4074+
{"empty string", "", nil},
4075+
{"whitespace only", " ", nil},
4076+
{"single word", "MANDATORY", []string{"MANDATORY"}},
4077+
{"multiple words", "foo, bar, baz", []string{"foo", "bar", "baz"}},
4078+
{"trailing comma", "foo, bar,", []string{"foo", "bar"}},
4079+
{"leading comma", ",foo", []string{"foo"}},
4080+
{"extra whitespace", " one , two , three ", []string{"one", "two", "three"}},
4081+
{"duplicate commas", "a,,b", []string{"a", "b"}},
4082+
}
4083+
for _, tc := range tests {
4084+
t.Run(tc.name, func(t *testing.T) {
4085+
got := parseExcludedWords(tc.input)
4086+
if len(got) != len(tc.want) {
4087+
t.Fatalf("parseExcludedWords(%q) = %v (len %d), want %v (len %d)",
4088+
tc.input, got, len(got), tc.want, len(tc.want))
4089+
}
4090+
for i := range tc.want {
4091+
if got[i] != tc.want[i] {
4092+
t.Errorf("parseExcludedWords(%q)[%d] = %q, want %q",
4093+
tc.input, i, got[i], tc.want[i])
4094+
}
4095+
}
4096+
})
4097+
}
4098+
}

internal/tui/screenshot.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,18 @@ func (c *captureCtx) captureFeatures(subDir string) []Screenshot {
455455
m.recalcLayout()
456456
addOverlay("config-editing", m)
457457
}
458+
{
459+
m := newBase()
460+
m.state = stateConfigPanel
461+
m.configPanel.SetValues(c.configVals)
462+
m.configPanel.SetThemeOptions(c.themeNames)
463+
// Navigate to the Excluded Words field (index 11).
464+
for range 11 {
465+
m.configPanel.MoveDown()
466+
}
467+
m.recalcLayout()
468+
addOverlay("excluded-words", m)
469+
}
458470

459471
// ── Shell picker ──────────────────────────────────────────────────
460472
{
@@ -640,6 +652,7 @@ func CaptureScreenshots(dbPath string, width, height int) ([]Screenshot, error)
640652
Shell: "pwsh",
641653
Theme: "Dispatch Dark",
642654
WorkspaceRecovery: true,
655+
ExcludedWords: "MANDATORY, context_aware",
643656
},
644657
themeNames: append([]string{"auto"}, styles.BuiltinSchemeNames()...),
645658
}

0 commit comments

Comments
 (0)