diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-22 16:26:14 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-22 16:26:14 +0300 |
| commit | 4a42f57033bb58c3603422431832ba6fdddec703 (patch) | |
| tree | fd3db35ea7b921cfcd34d2736546f02f4e011fca | |
| parent | 29cca706b843fa1675b8d16956fd1cc1dbbbd3da (diff) | |
h7: fix agent toggle rewrite and hotkey collisions
| -rw-r--r-- | cmd/tasksamurai/main.go | 5 | ||||
| -rw-r--r-- | internal/ui/keyactions.go | 30 | ||||
| -rw-r--r-- | internal/ui/table.go | 80 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 80 |
4 files changed, 165 insertions, 30 deletions
diff --git a/cmd/tasksamurai/main.go b/cmd/tasksamurai/main.go index 01bd850..2b90dc4 100644 --- a/cmd/tasksamurai/main.go +++ b/cmd/tasksamurai/main.go @@ -44,7 +44,10 @@ func main() { os.Exit(1) } - m.SetAgentFilterHotkey(*agentHotkey) + if err := m.SetAgentFilterHotkey(*agentHotkey); err != nil { + fmt.Fprintln(os.Stderr, "invalid --agent-hotkey:", err) + fmt.Fprintln(os.Stderr, "using default hotkey 3") + } m.SetDisco(*disco) m.SetUltra(*ultra) diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index 2fa0c00..15e1b06 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -379,30 +379,30 @@ func (m *Model) handleToggleBlink() (tea.Model, tea.Cmd) { func toggleAgentFilter(filters []string) []string { next := "+agent" - hasPositive := false - hasNegative := false + index := -1 - filtered := make([]string, 0, len(filters)+1) - for _, filter := range filters { + for i, filter := range filters { switch filter { case "+agent": - hasPositive = true - continue + next = "-agent" + index = i case "-agent": - hasNegative = true - continue + next = "+agent" + index = i + } + if index != -1 { + break } - filtered = append(filtered, filter) } - switch { - case hasPositive && !hasNegative: - next = "-agent" - case hasNegative && !hasPositive: - next = "+agent" + if index == -1 { + out := append([]string(nil), filters...) + return append(out, next) } - return append(filtered, next) + out := append([]string(nil), filters...) + out[index] = next + return out } func (m *Model) handleRefresh() (tea.Model, tea.Cmd) { diff --git a/internal/ui/table.go b/internal/ui/table.go index 1fedd19..398aeeb 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1362,11 +1362,19 @@ func (m *Model) SetUltra(u bool) { } // SetAgentFilterHotkey configures the key that toggles the agent filter. -func (m *Model) SetAgentFilterHotkey(key string) { - if strings.TrimSpace(key) == "" { - return +// The chosen key must not collide with any existing command in normal or +// ultra mode. If it does, the current hotkey is left unchanged and an error is +// returned so callers can surface the conflict. +func (m *Model) SetAgentFilterHotkey(key string) error { + key = strings.TrimSpace(key) + if key == "" { + return nil + } + if err := validateAgentFilterHotkey(key); err != nil { + return err } m.agentFilterHotkey = key + return nil } func (m Model) agentFilterHotkeyLabel() string { @@ -1375,3 +1383,69 @@ func (m Model) agentFilterHotkeyLabel() string { } return m.agentFilterHotkey } + +func validateAgentFilterHotkey(key string) error { + key = strings.TrimSpace(key) + if key == "" { + return nil + } + if _, ok := reservedAgentHotkeys[key]; ok { + return fmt.Errorf("agent hotkey %q conflicts with an existing command", key) + } + return nil +} + +var reservedAgentHotkeys = map[string]struct{}{ + "+": {}, + "0": {}, + "1": {}, + "2": {}, + "A": {}, + "B": {}, + "C": {}, + "E": {}, + "G": {}, + "H": {}, + "J": {}, + "N": {}, + "R": {}, + "T": {}, + "U": {}, + "W": {}, + "a": {}, + "b": {}, + "c": {}, + "d": {}, + "down": {}, + "e": {}, + "end": {}, + "enter": {}, + "esc": {}, + "f": {}, + "g": {}, + "home": {}, + "i": {}, + "h": {}, + "j": {}, + "k": {}, + "l": {}, + "left": {}, + "n": {}, + "o": {}, + "p": {}, + "pgdn": {}, + "pgdown": {}, + "pgup": {}, + "q": {}, + "r": {}, + "right": {}, + "s": {}, + "space": {}, + "t": {}, + "u": {}, + "up": {}, + "w": {}, + "x": {}, + "?": {}, + "/": {}, +} diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 17049ac..9d0766e 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -815,28 +815,32 @@ func TestToggleAgentFilter(t *testing.T) { 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 in place", + input: []string{"project:home", "+agent", "status:pending"}, + expect: []string{"project:home", "-agent", "status:pending"}, }, { - name: "switches -agent to +agent", - input: []string{"project:home", "-agent"}, - expect: []string{"project:home", "+agent"}, + name: "switches -agent to +agent in place", + input: []string{"project:home", "-agent", "status:pending"}, + expect: []string{"project:home", "+agent", "status:pending"}, }, { - name: "normalizes contradictory agent filters", - input: []string{"+agent", "-agent", "status:pending"}, - expect: []string{"status:pending", "+agent"}, + name: "preserves complex filter structure", + input: []string{"(", "project:home", "or", "project:work", ")", "+agent", "status:pending"}, + expect: []string{"(", "project:home", "or", "project:work", ")", "-agent", "status:pending"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := toggleAgentFilter(tc.input) + input := append([]string(nil), tc.input...) + got := toggleAgentFilter(input) if !reflect.DeepEqual(got, tc.expect) { t.Fatalf("toggleAgentFilter(%v) = %v, want %v", tc.input, got, tc.expect) } + if !reflect.DeepEqual(input, tc.input) { + t.Fatalf("toggleAgentFilter mutated input: got %v want %v", input, tc.input) + } }) } } @@ -908,7 +912,9 @@ func TestAgentFilterHotkeyCanBeRebound(t *testing.T) { if err != nil { t.Fatalf("New: %v", err) } - m.SetAgentFilterHotkey("7") + if err := m.SetAgentFilterHotkey("7"); err != nil { + t.Fatalf("SetAgentFilterHotkey: %v", err) + } mv, _ := (&m).Update(tea.KeyPressMsg{Code: '3', Text: "3"}) m = *mv.(*Model) @@ -931,6 +937,58 @@ func TestAgentFilterHotkeyCanBeRebound(t *testing.T) { } } +func TestAgentFilterHotkeyCollisionIsRejected(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) + } + + if err := m.SetAgentFilterHotkey("u"); err == nil { + t.Fatalf("expected collision for hotkey u") + } + if got := m.agentFilterHotkeyLabel(); got != "3" { + t.Fatalf("colliding hotkey changed label: got %q want %q", got, "3") + } + + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + m = *mv.(*Model) + if !m.showUltra { + t.Fatalf("u no longer entered ultra mode after rejected hotkey") + } + if len(m.filters) != 0 { + t.Fatalf("u unexpectedly changed filters after rejected hotkey: %#v", m.filters) + } + + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + m = *mv.(*Model) + if m.showUltra { + t.Fatalf("u no longer exited ultra mode after rejected hotkey") + } +} + func setupUltraTaskSet(t *testing.T, tmp string) string { taskPath := filepath.Join(tmp, "task") script := "#!/bin/sh\n" + |
