diff --git a/config/aitools.go b/config/aitools.go index b4f9eb7..1ba95ef 100644 --- a/config/aitools.go +++ b/config/aitools.go @@ -4,7 +4,7 @@ import "path" // AITool defines a supported AI coding assistant. // To add a new tool, add an entry to the aiTools slice below. -// NOTE: the action palette (press *) surfaces available actions; update docs if tool names change. +// NOTE: the action palette (press Ctrl+A) surfaces available actions; update docs if tool names change. type AITool struct { Key string // config identifier: "claude", "gemini", "codex", "opencode" DisplayName string // human-readable label for UI: "Claude Code" diff --git a/controller/actions.go b/controller/actions.go index 2185113..f737ebc 100644 --- a/controller/actions.go +++ b/controller/actions.go @@ -272,7 +272,7 @@ func DefaultGlobalActions() *ActionRegistry { r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit", ShowInHeader: true}) r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh", ShowInHeader: true}) r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Toggle Header", ShowInHeader: true}) - r.Register(Action{ID: ActionOpenPalette, Key: tcell.KeyRune, Rune: '*', Label: "All", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionOpenPalette, Key: tcell.KeyCtrlA, Modifier: tcell.ModCtrl, Label: "All", ShowInHeader: true, HideFromPalette: true}) return r } diff --git a/controller/actions_test.go b/controller/actions_test.go index 5f004a3..11ff6a1 100644 --- a/controller/actions_test.go +++ b/controller/actions_test.go @@ -419,7 +419,7 @@ func TestDefaultGlobalActions(t *testing.T) { } } - // ActionOpenPalette should show in header with label "All" + // ActionOpenPalette should show in header with label "All" and use Ctrl+A binding for _, a := range actions { if a.ID == ActionOpenPalette { if !a.ShowInHeader { @@ -428,6 +428,15 @@ func TestDefaultGlobalActions(t *testing.T) { if a.Label != "All" { t.Errorf("ActionOpenPalette label = %q, want %q", a.Label, "All") } + if a.Key != tcell.KeyCtrlA { + t.Errorf("ActionOpenPalette Key = %v, want KeyCtrlA", a.Key) + } + if a.Modifier != tcell.ModCtrl { + t.Errorf("ActionOpenPalette Modifier = %v, want ModCtrl", a.Modifier) + } + if a.Rune != 0 { + t.Errorf("ActionOpenPalette Rune = %v, want 0", a.Rune) + } continue } if !a.ShowInHeader { diff --git a/controller/input_router.go b/controller/input_router.go index d72cf22..417d985 100644 --- a/controller/input_router.go +++ b/controller/input_router.go @@ -106,7 +106,14 @@ func (ir *InputRouter) SetPaletteConfig(pc *model.ActionPaletteConfig) { func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry) bool { slog.Debug("input received", "name", event.Name(), "key", int(event.Key()), "rune", string(event.Rune()), "modifiers", int(event.Modifiers())) - // if the input box is focused, let it handle all input (including '*' and F10) + // palette fires regardless of focus context (Ctrl+A can't conflict with typing) + if action := ir.globalActions.Match(event); action != nil { + if action.ID == ActionOpenPalette { + return ir.handleGlobalAction(action.ID) + } + } + + // if the input box is focused, let it handle all remaining input (including F10) if activeView := ir.navController.GetActiveView(); activeView != nil { if iv, ok := activeView.(InputableView); ok && iv.IsInputBoxFocused() { return false @@ -114,11 +121,9 @@ func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry } // pre-gate: global actions that must fire before task-edit Prepare() and before - // search/fullscreen/editor gates. ActionOpenPalette is suppressed in TaskEditView - // because '*' is a typeable rune that should be inserted into fields. + // search/fullscreen/editor gates if action := ir.globalActions.Match(event); action != nil { - isTaskEdit := currentView != nil && currentView.ViewID == model.TaskEditViewID - if action.ID == ActionToggleHeader || (action.ID == ActionOpenPalette && !isTaskEdit) { + if action.ID == ActionToggleHeader { return ir.handleGlobalAction(action.ID) } } diff --git a/integration/action_palette_test.go b/integration/action_palette_test.go index c4940b0..97abe6a 100644 --- a/integration/action_palette_test.go +++ b/integration/action_palette_test.go @@ -1,13 +1,12 @@ package integration import ( - "strings" "testing" + "github.com/boolean-maybe/tiki/controller" "github.com/boolean-maybe/tiki/model" taskpkg "github.com/boolean-maybe/tiki/task" "github.com/boolean-maybe/tiki/testutil" - "github.com/boolean-maybe/tiki/view/taskdetail" "github.com/gdamore/tcell/v2" ) @@ -17,10 +16,10 @@ func TestActionPalette_OpenAndClose(t *testing.T) { defer ta.Cleanup() ta.Draw() - // * opens the palette - ta.SendKey(tcell.KeyRune, '*', tcell.ModNone) + // Ctrl+A opens the palette + ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl) if !ta.GetPaletteConfig().IsVisible() { - t.Fatal("palette should be visible after pressing '*'") + t.Fatal("palette should be visible after pressing Ctrl+A") } // Esc closes it @@ -60,7 +59,7 @@ func TestActionPalette_ModalBlocksGlobals(t *testing.T) { startVisible := hc.IsVisible() // open palette - ta.SendKey(tcell.KeyRune, '*', tcell.ModNone) + ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl) if !ta.GetPaletteConfig().IsVisible() { t.Fatal("palette should be open") } @@ -76,13 +75,13 @@ func TestActionPalette_ModalBlocksGlobals(t *testing.T) { ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) } -func TestActionPalette_AsteriskFiltersInPalette(t *testing.T) { +func TestActionPalette_AsteriskIsFilterTextInPalette(t *testing.T) { ta := testutil.NewTestApp(t) defer ta.Cleanup() ta.Draw() // open palette - ta.SendKey(tcell.KeyRune, '*', tcell.ModNone) + ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl) if !ta.GetPaletteConfig().IsVisible() { t.Fatal("palette should be open") } @@ -98,215 +97,68 @@ func TestActionPalette_AsteriskFiltersInPalette(t *testing.T) { ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) } -// getEditView type-asserts the active view to *taskdetail.TaskEditView. -func getEditView(t *testing.T, ta *testutil.TestApp) *taskdetail.TaskEditView { - t.Helper() +func TestActionPalette_AsteriskDoesNotOpenPalette(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + ta.Draw() + + // send '*' as a rune on the plugin view + ta.SendKey(tcell.KeyRune, '*', tcell.ModNone) + + if ta.GetPaletteConfig().IsVisible() { + t.Fatal("palette should NOT open when '*' is pressed — only Ctrl+A should open it") + } +} + +func TestActionPalette_OpensInTaskEdit(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + + if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create task: %v", err) + } + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("reload: %v", err) + } + + ta.NavController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{ + TaskID: "TIKI-1", + Focus: model.EditFieldTitle, + })) + ta.Draw() + + // Ctrl+A should open the palette even in task edit + ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl) + + if !ta.GetPaletteConfig().IsVisible() { + t.Fatal("palette should open when Ctrl+A is pressed in task edit view") + } + + ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) +} + +func TestActionPalette_OpensWithInputBoxFocused(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + + ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil) + ta.Draw() + + // open search to focus input box + ta.SendKey(tcell.KeyRune, '/', tcell.ModNone) + v := ta.NavController.GetActiveView() - ev, ok := v.(*taskdetail.TaskEditView) - if !ok { - t.Fatalf("expected *taskdetail.TaskEditView, got %T", v) - } - return ev -} - -func TestActionPalette_BlockedInTaskEdit_DraftFirstKey(t *testing.T) { - ta := testutil.NewTestApp(t) - defer ta.Cleanup() - - ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil) - ta.Draw() - - // press 'n' to create new task (draft path, title focused) - ta.SendKey(tcell.KeyRune, 'n', tcell.ModNone) - - // very first key in edit view is '*' - ta.SendKey(tcell.KeyRune, '*', tcell.ModNone) - - if ta.GetPaletteConfig().IsVisible() { - t.Fatal("palette should NOT open when '*' is pressed in task edit (draft, first key)") - } - ev := getEditView(t, ta) - if got := ev.GetEditedTitle(); got != "*" { - t.Fatalf("title = %q, want %q", got, "*") - } -} - -func TestActionPalette_BlockedInTaskEdit_ExistingFirstKey(t *testing.T) { - ta := testutil.NewTestApp(t) - defer ta.Cleanup() - - if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { - t.Fatalf("create task: %v", err) - } - if err := ta.TaskStore.Reload(); err != nil { - t.Fatalf("reload: %v", err) - } - - // push task edit directly (non-draft path) - ta.NavController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{ - TaskID: "TIKI-1", - Focus: model.EditFieldTitle, - })) - ta.Draw() - - // first key is '*' - ta.SendKey(tcell.KeyRune, '*', tcell.ModNone) - - if ta.GetPaletteConfig().IsVisible() { - t.Fatal("palette should NOT open when '*' is pressed in task edit (existing, first key)") - } - ev := getEditView(t, ta) - if got := ev.GetEditedTitle(); got != "Test*" { - t.Fatalf("title = %q, want %q", got, "Test*") - } -} - -func TestActionPalette_BlockedInTaskEdit_TitleMidText(t *testing.T) { - ta := testutil.NewTestApp(t) - defer ta.Cleanup() - - ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil) - ta.Draw() - - ta.SendKey(tcell.KeyRune, 'n', tcell.ModNone) - ta.SendText("ab*cd") - - if ta.GetPaletteConfig().IsVisible() { - t.Fatal("palette should NOT open when '*' is typed mid-title") - } - ev := getEditView(t, ta) - if got := ev.GetEditedTitle(); got != "ab*cd" { - t.Fatalf("title = %q, want %q", got, "ab*cd") - } -} - -func TestActionPalette_BlockedInTaskEdit_Description(t *testing.T) { - ta := testutil.NewTestApp(t) - defer ta.Cleanup() - - if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { - t.Fatalf("create task: %v", err) - } - if err := ta.TaskStore.Reload(); err != nil { - t.Fatalf("reload: %v", err) - } - - ta.NavController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{ - TaskID: "TIKI-1", - Focus: model.EditFieldDescription, - DescOnly: true, - })) - ta.Draw() - - ta.SendText("x*y") - - if ta.GetPaletteConfig().IsVisible() { - t.Fatal("palette should NOT open when '*' is typed in description") - } - ev := getEditView(t, ta) - desc := ev.GetEditedDescription() - if !strings.Contains(desc, "x*y") { - t.Fatalf("description = %q, should contain %q", desc, "x*y") - } -} - -func TestActionPalette_BlockedInTaskEdit_Tags(t *testing.T) { - ta := testutil.NewTestApp(t) - defer ta.Cleanup() - - if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { - t.Fatalf("create task: %v", err) - } - if err := ta.TaskStore.Reload(); err != nil { - t.Fatalf("reload: %v", err) - } - - ta.NavController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{ - TaskID: "TIKI-1", - TagsOnly: true, - })) - ta.Draw() - - ta.SendText("tag*val") - - if ta.GetPaletteConfig().IsVisible() { - t.Fatal("palette should NOT open when '*' is typed in tags") - } - ev := getEditView(t, ta) - tags := ev.GetEditedTags() - if len(tags) != 1 || tags[0] != "tag*val" { - t.Fatalf("tags = %v, want [tag*val]", tags) - } -} - -func TestActionPalette_BlockedInTaskEdit_Assignee(t *testing.T) { - ta := testutil.NewTestApp(t) - defer ta.Cleanup() - - if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { - t.Fatalf("create task: %v", err) - } - if err := ta.TaskStore.Reload(); err != nil { - t.Fatalf("reload: %v", err) - } - - // push task edit (default title focus) - ta.NavController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{ - TaskID: "TIKI-1", - Focus: model.EditFieldTitle, - })) - ta.Draw() - - // tab 5× to Assignee: Title→Status→Type→Priority→Points→Assignee - for i := 0; i < 5; i++ { - ta.SendKey(tcell.KeyTab, 0, tcell.ModNone) - } - - ta.SendKey(tcell.KeyRune, '*', tcell.ModNone) - - if ta.GetPaletteConfig().IsVisible() { - t.Fatal("palette should NOT open when '*' is pressed on Assignee field") - } - editTask := ta.EditingTask() - if editTask == nil { - t.Fatal("expected editing task to be non-nil") - } - if editTask.Assignee != "Unassigned*" { - t.Fatalf("assignee = %q, want %q ('*' appended to default value)", editTask.Assignee, "Unassigned*") - } -} - -func TestActionPalette_BlockedInTaskEdit_NonTypeableField(t *testing.T) { - ta := testutil.NewTestApp(t) - defer ta.Cleanup() - - if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { - t.Fatalf("create task: %v", err) - } - if err := ta.TaskStore.Reload(); err != nil { - t.Fatalf("reload: %v", err) - } - - // push task edit (default title focus) - ta.NavController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{ - TaskID: "TIKI-1", - Focus: model.EditFieldTitle, - })) - ta.Draw() - - // tab 1× to Status (non-typeable field) - ta.SendKey(tcell.KeyTab, 0, tcell.ModNone) - - ta.SendKey(tcell.KeyRune, '*', tcell.ModNone) - - if ta.GetPaletteConfig().IsVisible() { - t.Fatal("palette should NOT open when '*' is pressed on Status field") - } - editTask := ta.EditingTask() - if editTask == nil { - t.Fatal("expected editing task to be non-nil") - } - if editTask.Status != taskpkg.StatusReady { - t.Fatalf("status = %v, want %v (rune should be silently ignored)", editTask.Status, taskpkg.StatusReady) + iv, ok := v.(controller.InputableView) + if !ok || !iv.IsInputBoxFocused() { + t.Fatal("input box should be focused after '/'") } + + // Ctrl+A should open the palette even with input box focused + ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl) + + if !ta.GetPaletteConfig().IsVisible() { + t.Fatal("palette should open when Ctrl+A is pressed with input box focused") + } + + ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) } diff --git a/integration/input_action_test.go b/integration/input_action_test.go index b352718..05868b7 100644 --- a/integration/input_action_test.go +++ b/integration/input_action_test.go @@ -372,7 +372,7 @@ func TestInputAction_EmptySearchEnterIsNoOp(t *testing.T) { ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) } -func TestInputAction_PaletteBlockedDuringModal(t *testing.T) { +func TestInputAction_PaletteOpensDuringModal(t *testing.T) { ta := setupInputActionTest(t) defer ta.Cleanup() @@ -383,13 +383,13 @@ func TestInputAction_PaletteBlockedDuringModal(t *testing.T) { t.Fatal("input box should be focused") } - // '*' should be typed into the input box as text, not open the palette - ta.SendKey(tcell.KeyRune, '*', tcell.ModNone) - if ta.GetPaletteConfig().IsVisible() { - t.Fatal("palette should not open while input box is editing") + // Ctrl+A should open the palette even while input box is focused + ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl) + if !ta.GetPaletteConfig().IsVisible() { + t.Fatal("palette should open when Ctrl+A is pressed with input box focused") } - // cancel and clean up + // clean up ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) } diff --git a/view/doki_plugin_view.go b/view/doki_plugin_view.go index ba399b3..3da480c 100644 --- a/view/doki_plugin_view.go +++ b/view/doki_plugin_view.go @@ -90,7 +90,7 @@ func (dv *DokiView) build() { // The default Help plugin previously used this with embedded markdown: // cnt := map[string]string{"Help": helpMd, "tiki.md": tikiMd, "view.md": customMd} // provider := &internalDokiProvider{content: cnt} - // That usage was replaced by the action palette (press * to open). + // That usage was replaced by the action palette (press Ctrl+A to open). case "internal": provider := &internalDokiProvider{content: map[string]string{}} content, err = provider.FetchContent(nav.NavElement{Text: dv.pluginDef.Text})