reassign action palette key

This commit is contained in:
booleanmaybe 2026-04-19 20:24:03 -04:00
parent ef25f5ff98
commit 7abddcd4c6
7 changed files with 98 additions and 232 deletions

View file

@ -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"

View file

@ -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
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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
iv, ok := v.(controller.InputableView)
if !ok || !iv.IsInputBoxFocused() {
t.Fatal("input box should be focused after '/'")
}
func TestActionPalette_BlockedInTaskEdit_DraftFirstKey(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Ctrl+A should open the palette even with input box focused
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
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, "*")
}
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should open when Ctrl+A is pressed with input box focused")
}
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)
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}

View file

@ -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)
}

View file

@ -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})