mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
reassign action palette key
This commit is contained in:
parent
ef25f5ff98
commit
7abddcd4c6
7 changed files with 98 additions and 232 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Reference in a new issue