diff options
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | cmd/tasksamurai/main.go | 2 | ||||
| -rw-r--r-- | internal/ui/keyactions.go | 36 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 4 | ||||
| -rw-r--r-- | internal/ui/table.go | 27 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 133 | ||||
| -rw-r--r-- | internal/ui/ultra.go | 5 |
7 files changed, 203 insertions, 5 deletions
@@ -59,6 +59,7 @@ tasksamurai -- -excludetag +includetag ### Flags - `--browser-cmd <command>`: command used to open URLs (default: firefox on Linux, open on macOS) +- `--agent-hotkey <key>`: hotkey used to toggle the `+agent` / `-agent` filter (default: `3`) - `--debug-log <path>`: path to debug log file for Taskwarrior commands - `--debug-dir <directory>`: directory for runtime debug output (goroutine dumps, profiles) - `--disco`: start Task Samurai in disco mode, changing the theme every time a task is modified diff --git a/cmd/tasksamurai/main.go b/cmd/tasksamurai/main.go index 8596fab..01bd850 100644 --- a/cmd/tasksamurai/main.go +++ b/cmd/tasksamurai/main.go @@ -24,6 +24,7 @@ func main() { debugLog := flag.String("debug-log", "", "path to debug log file") debugDir := flag.String("debug-dir", "", "directory for runtime debug output (goroutine dumps, profiles)") browserCmd := flag.String("browser-cmd", browserCmdDefault, "command used to open URLs") + agentHotkey := flag.String("agent-hotkey", "3", "key used to toggle the +agent/-agent filter") disco := flag.Bool("disco", false, "enable disco mode") ultra := flag.Bool("ultra", false, "start directly in ultra mode") flag.Parse() @@ -43,6 +44,7 @@ func main() { os.Exit(1) } + m.SetAgentFilterHotkey(*agentHotkey) m.SetDisco(*disco) m.SetUltra(*ultra) diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index d0b459f..2fa0c00 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -262,6 +262,14 @@ func (m *Model) handleFilter() (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleToggleAgentFilter() (tea.Model, tea.Cmd) { + m.filters = toggleAgentFilter(m.filters) + if !m.reloadAndReport() { + return m, nil + } + return m, nil +} + func (m *Model) handleAddTask() (tea.Model, tea.Cmd) { m.clearEditingModes() m.addingTask = true @@ -369,6 +377,34 @@ func (m *Model) handleToggleBlink() (tea.Model, tea.Cmd) { return m, nil } +func toggleAgentFilter(filters []string) []string { + next := "+agent" + hasPositive := false + hasNegative := false + + filtered := make([]string, 0, len(filters)+1) + for _, filter := range filters { + switch filter { + case "+agent": + hasPositive = true + continue + case "-agent": + hasNegative = true + continue + } + filtered = append(filtered, filter) + } + + switch { + case hasPositive && !hasNegative: + next = "-agent" + case hasNegative && !hasPositive: + next = "+agent" + } + + return append(filtered, next) +} + func (m *Model) handleRefresh() (tea.Model, tea.Cmd) { m.reloadAndReport() return m, nil diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index 4fe3efb..df82e80 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -44,6 +44,10 @@ func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } } + if msg.String() == m.agentFilterHotkeyLabel() { + return m.handleToggleAgentFilter() + } + switch msg.String() { case "H": return m.handleToggleHelp() diff --git a/internal/ui/table.go b/internal/ui/table.go index f41472b..1fedd19 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -189,10 +189,11 @@ type Model struct { inProgress int due int - filters []string - tasks []task.Task - undoStack []string - browserCmd string + filters []string + tasks []task.Task + undoStack []string + browserCmd string + agentFilterHotkey string theme Theme defaultTheme Theme @@ -370,7 +371,7 @@ func (m *Model) startBlink(id int, markDone bool) tea.Cmd { // New creates a new UI model with the provided rows. func New(filters []string, browserCmd string) (Model, error) { - m := Model{filters: filters, browserCmd: browserCmd, blinkState: blinkState{blinkEnabled: true}} + m := Model{filters: filters, browserCmd: browserCmd, agentFilterHotkey: "3", blinkState: blinkState{blinkEnabled: true}} m.annotateInput = textinput.New() m.annotateInput.Prompt = "annotation: " m.descInput = textinput.New() @@ -928,6 +929,7 @@ func (m Model) helpSections() []helpSection { { title: "View & Search", items: []helpItem{ + {key: m.agentFilterHotkeyLabel(), desc: "toggle +agent/-agent filter"}, {key: "f", desc: "change filter"}, {key: "/, ?", desc: "search"}, {key: "n, N", desc: "next/previous match"}, @@ -1358,3 +1360,18 @@ func (m *Model) SetUltra(u bool) { m.showUltra = u m.ultraStartup = u } + +// SetAgentFilterHotkey configures the key that toggles the agent filter. +func (m *Model) SetAgentFilterHotkey(key string) { + if strings.TrimSpace(key) == "" { + return + } + m.agentFilterHotkey = key +} + +func (m Model) agentFilterHotkeyLabel() string { + if strings.TrimSpace(m.agentFilterHotkey) == "" { + return "3" + } + return m.agentFilterHotkey +} diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index fa6fbfe..17049ac 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -798,6 +798,139 @@ func TestNavigationHotkeys(t *testing.T) { } } +func TestToggleAgentFilter(t *testing.T) { + tests := []struct { + name string + input []string + expect []string + }{ + { + name: "nil filter starts with agent", + input: nil, + expect: []string{"+agent"}, + }, + { + name: "preserves unrelated filters", + input: []string{"project:home"}, + expect: []string{"project:home", "+agent"}, + }, + { + name: "switches +agent to -agent", + input: []string{"project:home", "+agent"}, + expect: []string{"project:home", "-agent"}, + }, + { + name: "switches -agent to +agent", + input: []string{"project:home", "-agent"}, + expect: []string{"project:home", "+agent"}, + }, + { + name: "normalizes contradictory agent filters", + input: []string{"+agent", "-agent", "status:pending"}, + expect: []string{"status:pending", "+agent"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := toggleAgentFilter(tc.input) + if !reflect.DeepEqual(got, tc.expect) { + t.Fatalf("toggleAgentFilter(%v) = %v, want %v", tc.input, got, tc.expect) + } + }) + } +} + +func TestAgentFilterHotkeyDefaultsToThree(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + + script := "#!/bin/sh\n" + + "if echo \"$@\" | grep -q export; then\n" + + " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"d\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" + + "fi\n" + + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + os.Setenv("TASKDATA", tmp) + os.Setenv("TASKRC", "/dev/null") + t.Cleanup(func() { + os.Unsetenv("TASKDATA") + os.Unsetenv("TASKRC") + }) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, _ := (&m).Update(tea.KeyPressMsg{Code: '3', Text: "3"}) + m = *mv.(*Model) + if !reflect.DeepEqual(m.filters, []string{"+agent"}) { + t.Fatalf("3 did not toggle agent filter: %#v", m.filters) + } +} + +func TestAgentFilterHotkeyCanBeRebound(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + logFile := filepath.Join(tmp, "log.txt") + + script := "#!/bin/sh\n" + + "printf '%s ' \"$@\" >> " + logFile + "\n" + + "printf '\\n' >> " + logFile + "\n" + + "if echo \"$@\" | grep -q export; then\n" + + " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"d\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" + + "fi\n" + + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + os.Setenv("TASKDATA", tmp) + os.Setenv("TASKRC", "/dev/null") + t.Cleanup(func() { + os.Unsetenv("TASKDATA") + os.Unsetenv("TASKRC") + }) + + m, err := New([]string{"project:home"}, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + m.SetAgentFilterHotkey("7") + + mv, _ := (&m).Update(tea.KeyPressMsg{Code: '3', Text: "3"}) + m = *mv.(*Model) + if !reflect.DeepEqual(m.filters, []string{"project:home"}) { + t.Fatalf("3 unexpectedly changed filters: %#v", m.filters) + } + + mv, _ = (&m).Update(tea.KeyPressMsg{Code: '7', Text: "7"}) + m = *mv.(*Model) + if !reflect.DeepEqual(m.filters, []string{"project:home", "+agent"}) { + t.Fatalf("7 did not toggle agent filter: %#v", m.filters) + } + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read log: %v", err) + } + if !strings.Contains(string(data), "project:home +agent status:pending export") { + t.Fatalf("toggle did not reload with +agent filter: %s", data) + } +} + func setupUltraTaskSet(t *testing.T, tmp string) string { taskPath := filepath.Join(tmp, "task") script := "#!/bin/sh\n" + diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go index f8c94a4..a3d3d96 100644 --- a/internal/ui/ultra.go +++ b/internal/ui/ultra.go @@ -109,6 +109,7 @@ func (m Model) ultraHelpSections() []helpSection { {key: "a, A", desc: "add/replace annotations"}, {key: "J", desc: "edit project"}, {key: "R", desc: "edit recurrence"}, + {key: m.agentFilterHotkeyLabel(), desc: "toggle +agent/-agent filter"}, {key: "f", desc: "change filter"}, }, }, @@ -998,6 +999,10 @@ func (m *Model) ultraEnsureVisible() { // handleUltraMode handles keyboard input in ultra mode. func (m *Model) handleUltraMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if msg.String() == m.agentFilterHotkeyLabel() { + return m.handleToggleAgentFilter() + } + switch msg.String() { case "H": return m.handleToggleHelp() |
