diff --git a/.doc/doki/doc/quick-start.md b/.doc/doki/doc/quick-start.md index 22fe4dc..b7a2d54 100644 --- a/.doc/doki/doc/quick-start.md +++ b/.doc/doki/doc/quick-start.md @@ -17,6 +17,7 @@ Press `F1` to open a sample doc root. Follow links with `Tab/Enter` ## AI skills You will be prompted to install skills for - [Claude Code](https://code.claude.com) +- [Gemini CLI](https://github.com/google-gemini/gemini-cli) - [Codex](https://openai.com/codex) - [Opencode](https://opencode.ai) @@ -57,7 +58,7 @@ Read more by pressing `?` for help `tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization If installed you can: -- work with [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts +- work with [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts - create, find, modify and delete tikis using AI - create tikis/dokis directly from Markdown files - Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx` diff --git a/.doc/doki/doc/skills.md b/.doc/doki/doc/skills.md index 339b593..2d7a62f 100644 --- a/.doc/doki/doc/skills.md +++ b/.doc/doki/doc/skills.md @@ -3,7 +3,7 @@ `tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization If installed you can: -- work with [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts +- work with [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts - create, find, modify and delete tikis using AI - create tikis/dokis directly from Markdown files - Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx` diff --git a/.doc/doki/doc/tiki-format.md b/.doc/doki/doc/tiki-format.md index bcaca2f..f83a010 100644 --- a/.doc/doki/doc/tiki-format.md +++ b/.doc/doki/doc/tiki-format.md @@ -43,7 +43,7 @@ The `.doc/` directory contains two main subdirectories: Tiki files are saved in `.doc/tiki` directory and can be managed via: - `tiki` cli -- AI tools such as `claude`, `codex` or `opencode` +- AI tools such as `claude`, `gemini`, `codex` or `opencode` - manually A tiki is made of its frontmatter that includes all fields related to a tiki status and types and its description diff --git a/README.md b/README.md index 885a493..c313eb8 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ Follow me on X: [![X Badge](https://img.shields.io/badge/-%23000000.svg?style=fl UPDATE: +New `chat with AI` action - open a tiki and press `c - Chat`. Opens your preferred terminal coding agent like +[Claude Code](https://code.claude.com) and loads tiki description. You can chat or edit the tiki with your agent + + +Agent must be configured in `config.yaml`: +```yaml +ai: + agent: [claude, codex, gemini, opencode] +``` + +UPDATE: + Now support images and Mermaid diagrams in Kitty-compatible terminals (iTerm2, Kitty, WezTerm, Ghostty) ![Intro](assets/images.gif) @@ -33,7 +45,7 @@ and take them through an agile lifecycle. `tiki` helps you save and organize the - Keep a **to-do list** with priorities, status, assignee and size - Issue management with **Kanban/Scrum** style board and burndown chart - **Plugin-first** architecture - user-defined plugins with filters and actions like Backlog, Recent, Roadmap -- AI **skills** to enable [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) work with natural language commands like +- AI **skills** to enable [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) work with natural language commands like "_create a tiki from @my-file.md_" "_mark tiki ABC123 as complete_" @@ -95,8 +107,9 @@ Make sure to press `?` for help. Press `F1` to open a sample doc root. Follow links with `Tab/Enter` ### AI skills -You will be prompted to install skills for +You will be prompted to install skills for - [Claude Code](https://code.claude.com) +- [Gemini CLI](https://github.com/google-gemini/gemini-cli) - [Codex](https://openai.com/codex) - [Opencode](https://opencode.ai) @@ -149,7 +162,7 @@ Read more by pressing `?` for help `tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization If installed you can: -- work with [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts +- work with [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts - create, find, modify and delete tikis using AI - create tikis/dokis directly from Markdown files - Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx` diff --git a/config/aitools.go b/config/aitools.go new file mode 100644 index 0000000..f719496 --- /dev/null +++ b/config/aitools.go @@ -0,0 +1,75 @@ +package config + +import "path/filepath" + +// AITool defines a supported AI coding assistant. +// To add a new tool, add an entry to the aiTools slice below. +// NOTE: also update view/help/tiki.md which lists tool names in prose. +type AITool struct { + Key string // config identifier: "claude", "gemini", "codex", "opencode" + DisplayName string // human-readable label for UI: "Claude Code" + Command string // CLI binary name + PromptFlag string // flag preceding the prompt arg, or "" for positional + SkillDir string // relative base dir for skills: ".claude/skills" +} + +// aiTools is the single source of truth for all supported AI tools. +var aiTools = []AITool{ + { + Key: "claude", + DisplayName: "Claude Code", + Command: "claude", + PromptFlag: "--append-system-prompt", + SkillDir: ".claude/skills", + }, + { + Key: "gemini", + DisplayName: "Gemini CLI", + Command: "gemini", + PromptFlag: "-i", + SkillDir: ".gemini/skills", + }, + { + Key: "codex", + DisplayName: "OpenAI Codex", + Command: "codex", + PromptFlag: "", + SkillDir: ".codex/skills", + }, + { + Key: "opencode", + DisplayName: "OpenCode", + Command: "opencode", + PromptFlag: "--prompt", + SkillDir: ".opencode/skill", + }, +} + +// PromptArgs returns CLI arguments to pass a prompt string to this tool. +// If PromptFlag is set, returns [flag, prompt]; otherwise returns [prompt]. +func (t AITool) PromptArgs(prompt string) []string { + if t.PromptFlag != "" { + return []string{t.PromptFlag, prompt} + } + return []string{prompt} +} + +// SkillPath returns the relative file path for a skill (e.g. "tiki" → ".claude/skills/tiki/SKILL.md"). +func (t AITool) SkillPath(skill string) string { + return filepath.Join(t.SkillDir, skill, "SKILL.md") +} + +// AITools returns all supported AI tools. +func AITools() []AITool { + return aiTools +} + +// LookupAITool finds a tool by its config key. Returns false if not found. +func LookupAITool(key string) (AITool, bool) { + for _, t := range aiTools { + if t.Key == key { + return t, true + } + } + return AITool{}, false +} diff --git a/config/aitools_test.go b/config/aitools_test.go new file mode 100644 index 0000000..004645f --- /dev/null +++ b/config/aitools_test.go @@ -0,0 +1,83 @@ +package config + +import ( + "testing" +) + +func TestAITools_ReturnsAllTools(t *testing.T) { + tools := AITools() + if len(tools) != 4 { + t.Fatalf("expected 4 tools, got %d", len(tools)) + } + + keys := make(map[string]bool) + for _, tool := range tools { + keys[tool.Key] = true + } + for _, expected := range []string{"claude", "gemini", "codex", "opencode"} { + if !keys[expected] { + t.Errorf("missing tool key %q", expected) + } + } +} + +func TestLookupAITool_Found(t *testing.T) { + tool, ok := LookupAITool("claude") + if !ok { + t.Fatal("expected to find claude") + } + if tool.Command != "claude" { + t.Errorf("expected command 'claude', got %q", tool.Command) + } + if tool.DisplayName != "Claude Code" { + t.Errorf("expected display name 'Claude Code', got %q", tool.DisplayName) + } +} + +func TestLookupAITool_NotFound(t *testing.T) { + _, ok := LookupAITool("unknown") + if ok { + t.Error("expected false for unknown tool") + } +} + +func TestAITool_PromptArgs_WithFlag(t *testing.T) { + tool := AITool{PromptFlag: "--append-system-prompt"} + args := tool.PromptArgs("hello") + if len(args) != 2 { + t.Fatalf("expected 2 args, got %d", len(args)) + } + if args[0] != "--append-system-prompt" { + t.Errorf("expected flag '--append-system-prompt', got %q", args[0]) + } + if args[1] != "hello" { + t.Errorf("expected prompt 'hello', got %q", args[1]) + } +} + +func TestAITool_PromptArgs_Positional(t *testing.T) { + tool := AITool{PromptFlag: ""} + args := tool.PromptArgs("hello") + if len(args) != 1 { + t.Fatalf("expected 1 arg, got %d", len(args)) + } + if args[0] != "hello" { + t.Errorf("expected prompt 'hello', got %q", args[0]) + } +} + +func TestAITool_SkillPath(t *testing.T) { + tool := AITool{SkillDir: ".claude/skills"} + path := tool.SkillPath("tiki") + if path != ".claude/skills/tiki/SKILL.md" { + t.Errorf("expected '.claude/skills/tiki/SKILL.md', got %q", path) + } +} + +func TestAITool_SkillPath_SingularDir(t *testing.T) { + tool := AITool{SkillDir: ".opencode/skill"} + path := tool.SkillPath("doki") + if path != ".opencode/skill/doki/SKILL.md" { + t.Errorf("expected '.opencode/skill/doki/SKILL.md', got %q", path) + } +} diff --git a/config/init.go b/config/init.go index 7478d26..af6811f 100644 --- a/config/init.go +++ b/config/init.go @@ -67,16 +67,17 @@ AI skills extend your AI assistant with commands to manage tasks and documentati Select AI assistants to install (optional), then press Enter to continue. Press Esc to cancel project initialization.` + options := make([]huh.Option[string], 0, len(AITools())) + for _, t := range AITools() { + options = append(options, huh.NewOption(fmt.Sprintf("%s (%s/)", t.DisplayName, t.SkillDir), t.Key)) + } + form := huh.NewForm( huh.NewGroup( huh.NewMultiSelect[string](). Title("Initialize project"). Description(description). - Options( - huh.NewOption("Claude Code (.claude/skills/)", "claude"), - huh.NewOption("OpenAI Codex (.codex/skills/)", "codex"), - huh.NewOption("OpenCode (.opencode/skill/)", "opencode"), - ). + Options(options...). Filterable(false). Value(&selectedAITools), ), @@ -144,55 +145,32 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont return fmt.Errorf("embedded doki SKILL.md content is empty") } - // Define target paths for both tiki and doki skills - type skillPaths struct { - tiki string - doki string - } - - toolPaths := map[string]skillPaths{ - "claude": { - tiki: ".claude/skills/tiki/SKILL.md", - doki: ".claude/skills/doki/SKILL.md", - }, - "codex": { - tiki: ".codex/skills/tiki/SKILL.md", - doki: ".codex/skills/doki/SKILL.md", - }, - "opencode": { - tiki: ".opencode/skill/tiki/SKILL.md", - doki: ".opencode/skill/doki/SKILL.md", - }, - } - var errs []error - for _, tool := range selectedTools { - paths, ok := toolPaths[tool] + for _, toolKey := range selectedTools { + tool, ok := LookupAITool(toolKey) if !ok { - errs = append(errs, fmt.Errorf("unknown tool: %s", tool)) + errs = append(errs, fmt.Errorf("unknown tool: %s", toolKey)) continue } - // Install tiki skill - tikiDir := filepath.Dir(paths.tiki) - //nolint:gosec // G301: 0755 is appropriate for user-owned skill directories - if err := os.MkdirAll(tikiDir, 0755); err != nil { - errs = append(errs, fmt.Errorf("failed to create tiki directory for %s: %w", tool, err)) - } else if err := os.WriteFile(paths.tiki, []byte(tikiSkillMdContent), 0644); err != nil { - errs = append(errs, fmt.Errorf("failed to write tiki SKILL.md for %s: %w", tool, err)) - } else { - slog.Info("installed tiki AI skill", "tool", tool, "path", paths.tiki) - } - - // Install doki skill - dokiDir := filepath.Dir(paths.doki) - //nolint:gosec // G301: 0755 is appropriate for user-owned skill directories - if err := os.MkdirAll(dokiDir, 0755); err != nil { - errs = append(errs, fmt.Errorf("failed to create doki directory for %s: %w", tool, err)) - } else if err := os.WriteFile(paths.doki, []byte(dokiSkillMdContent), 0644); err != nil { - errs = append(errs, fmt.Errorf("failed to write doki SKILL.md for %s: %w", tool, err)) - } else { - slog.Info("installed doki AI skill", "tool", tool, "path", paths.doki) + // install each skill type (tiki, doki) + for _, skill := range []struct { + name string + content string + }{ + {"tiki", tikiSkillMdContent}, + {"doki", dokiSkillMdContent}, + } { + path := tool.SkillPath(skill.name) + dir := filepath.Dir(path) + //nolint:gosec // G301: 0755 is appropriate for user-owned skill directories + if err := os.MkdirAll(dir, 0755); err != nil { + errs = append(errs, fmt.Errorf("failed to create %s directory for %s: %w", skill.name, toolKey, err)) + } else if err := os.WriteFile(path, []byte(skill.content), 0644); err != nil { + errs = append(errs, fmt.Errorf("failed to write %s SKILL.md for %s: %w", skill.name, toolKey, err)) + } else { + slog.Info("installed AI skill", "tool", toolKey, "skill", skill.name, "path", path) + } } } diff --git a/config/loader.go b/config/loader.go index df136d2..2cad9a5 100644 --- a/config/loader.go +++ b/config/loader.go @@ -52,6 +52,11 @@ type Config struct { Border string `mapstructure:"border"` // hex "#6272a4" or ANSI "244" } `mapstructure:"codeBlock"` } `mapstructure:"appearance"` + + // AI agent configuration — valid keys defined in aitools.go via AITools() + AI struct { + Agent string `mapstructure:"agent"` + } `mapstructure:"ai"` } var appConfig *Config @@ -410,3 +415,8 @@ func GetCodeBlockBackground() string { func GetCodeBlockBorder() string { return viper.GetString("appearance.codeBlock.border") } + +// GetAIAgent returns the configured AI agent tool name, or empty string if not configured +func GetAIAgent() string { + return viper.GetString("ai.agent") +} diff --git a/config/loader_test.go b/config/loader_test.go index 65d0ee7..c285d66 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -345,6 +345,61 @@ header: } } +func TestLoadConfigAIAgent(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + configContent := ` +ai: + agent: claude +` + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("failed to create test config: %v", err) + } + + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(tmpDir) + + t.Setenv("XDG_CONFIG_HOME", tmpDir) + appConfig = nil + ResetPathManager() + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if cfg.AI.Agent != "claude" { + t.Errorf("expected ai.agent 'claude', got '%s'", cfg.AI.Agent) + } + if got := GetAIAgent(); got != "claude" { + t.Errorf("GetAIAgent() = '%s', want 'claude'", got) + } +} + +func TestLoadConfigAIAgentDefault(t *testing.T) { + tmpDir := t.TempDir() + + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(tmpDir) + + appConfig = nil + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if cfg.AI.Agent != "" { + t.Errorf("expected empty ai.agent by default, got '%s'", cfg.AI.Agent) + } + if got := GetAIAgent(); got != "" { + t.Errorf("GetAIAgent() = '%s', want ''", got) + } +} + func TestGetConfig(t *testing.T) { // Reset appConfig appConfig = nil diff --git a/controller/actions.go b/controller/actions.go index 20cf659..479976f 100644 --- a/controller/actions.go +++ b/controller/actions.go @@ -1,6 +1,7 @@ package controller import ( + "github.com/boolean-maybe/tiki/config" "github.com/boolean-maybe/tiki/model" "github.com/gdamore/tcell/v2" @@ -41,6 +42,7 @@ const ( ActionCloneTask ActionID = "clone_task" ActionEditDeps ActionID = "edit_deps" ActionEditTags ActionID = "edit_tags" + ActionChat ActionID = "chat" ) // ActionID values for task edit view actions. @@ -298,6 +300,10 @@ func TaskDetailViewActions() *ActionRegistry { r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true}) r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true}) + if config.GetAIAgent() != "" { + r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true}) + } + return r } diff --git a/controller/actions_test.go b/controller/actions_test.go index c48f7eb..c264826 100644 --- a/controller/actions_test.go +++ b/controller/actions_test.go @@ -6,6 +6,7 @@ import ( "github.com/boolean-maybe/tiki/model" "github.com/gdamore/tcell/v2" + "github.com/spf13/viper" ) func TestActionRegistry_Merge(t *testing.T) { @@ -507,6 +508,42 @@ func TestTaskDetailViewActions_HasEditDesc(t *testing.T) { } } +func TestTaskDetailViewActions_NoChatWithoutConfig(t *testing.T) { + // ensure ai.agent is not set + viper.Set("ai.agent", "") + defer viper.Set("ai.agent", "") + + registry := TaskDetailViewActions() + _, found := registry.LookupRune('c') + if found { + t.Error("chat action should not be registered when ai.agent is empty") + } +} + +func TestTaskDetailViewActions_ChatWithConfig(t *testing.T) { + viper.Set("ai.agent", "claude") + defer viper.Set("ai.agent", "") + + registry := TaskDetailViewActions() + + action, found := registry.LookupRune('c') + if !found { + t.Fatal("chat action should be registered when ai.agent is configured") + } + if action.ID != ActionChat { + t.Errorf("expected ActionChat, got %v", action.ID) + } + if !action.ShowInHeader { + t.Error("chat action should be shown in header") + } + + // total count should be 7 (6 base + chat) + actions := registry.GetActions() + if len(actions) != 7 { + t.Errorf("expected 7 actions with ai.agent configured, got %d", len(actions)) + } +} + func TestPluginActionID(t *testing.T) { id := pluginActionID('b') if id != "plugin_action:b" { diff --git a/controller/agent.go b/controller/agent.go new file mode 100644 index 0000000..422d269 --- /dev/null +++ b/controller/agent.go @@ -0,0 +1,31 @@ +package controller + +import ( + "fmt" + + "github.com/boolean-maybe/tiki/config" +) + +// taskContextPrompt builds the shared prompt instructing the AI agent about the current tiki task. +func taskContextPrompt(taskFilePath string) string { + return fmt.Sprintf( + "Read the tiki task at %s first. "+ + "This is the task the user is currently viewing. "+ + "When the user asks to modify something without specifying a file, "+ + "they mean this tiki task. "+ + "After reading, chat with the user about it.", + taskFilePath, + ) +} + +// resolveAgentCommand maps a logical agent name to the actual command and arguments. +// taskFilePath is the path to the current tiki task file being viewed. +func resolveAgentCommand(agent string, taskFilePath string) (name string, args []string) { + tool, ok := config.LookupAITool(agent) + if !ok { + // unknown agent: treat name as the command, no context injection + return agent, nil + } + prompt := taskContextPrompt(taskFilePath) + return tool.Command, tool.PromptArgs(prompt) +} diff --git a/controller/agent_test.go b/controller/agent_test.go new file mode 100644 index 0000000..7ca28cb --- /dev/null +++ b/controller/agent_test.go @@ -0,0 +1,79 @@ +package controller + +import ( + "strings" + "testing" +) + +const testTaskPath = "/tmp/tiki-abc123.md" + +func TestResolveAgentCommand_Claude(t *testing.T) { + name, args := resolveAgentCommand("claude", testTaskPath) + if name != "claude" { + t.Errorf("expected name 'claude', got %q", name) + } + if len(args) != 2 { + t.Fatalf("expected 2 args, got %d: %v", len(args), args) + } + if args[0] != "--append-system-prompt" { + t.Errorf("expected first arg '--append-system-prompt', got %q", args[0]) + } + if !strings.Contains(args[1], testTaskPath) { + t.Errorf("expected prompt to contain task file path, got %q", args[1]) + } +} + +func TestResolveAgentCommand_Gemini(t *testing.T) { + name, args := resolveAgentCommand("gemini", testTaskPath) + if name != "gemini" { + t.Errorf("expected name 'gemini', got %q", name) + } + if len(args) != 2 { + t.Fatalf("expected 2 args, got %d: %v", len(args), args) + } + if args[0] != "-i" { + t.Errorf("expected first arg '-i', got %q", args[0]) + } + if !strings.Contains(args[1], testTaskPath) { + t.Errorf("expected prompt to contain task file path, got %q", args[1]) + } +} + +func TestResolveAgentCommand_Codex(t *testing.T) { + name, args := resolveAgentCommand("codex", testTaskPath) + if name != "codex" { + t.Errorf("expected name 'codex', got %q", name) + } + if len(args) != 1 { + t.Fatalf("expected 1 arg, got %d: %v", len(args), args) + } + if !strings.Contains(args[0], testTaskPath) { + t.Errorf("expected prompt to contain task file path, got %q", args[0]) + } +} + +func TestResolveAgentCommand_OpenCode(t *testing.T) { + name, args := resolveAgentCommand("opencode", testTaskPath) + if name != "opencode" { + t.Errorf("expected name 'opencode', got %q", name) + } + if len(args) != 2 { + t.Fatalf("expected 2 args, got %d: %v", len(args), args) + } + if args[0] != "--prompt" { + t.Errorf("expected first arg '--prompt', got %q", args[0]) + } + if !strings.Contains(args[1], testTaskPath) { + t.Errorf("expected prompt to contain task file path, got %q", args[1]) + } +} + +func TestResolveAgentCommand_Unknown(t *testing.T) { + name, args := resolveAgentCommand("myagent", testTaskPath) + if name != "myagent" { + t.Errorf("expected name 'myagent', got %q", name) + } + if args != nil { + t.Errorf("expected nil args for unknown agent, got %v", args) + } +} diff --git a/controller/input_router.go b/controller/input_router.go index 749c9f3..ddfe05e 100644 --- a/controller/input_router.go +++ b/controller/input_router.go @@ -2,6 +2,8 @@ package controller import ( "log/slog" + "path/filepath" + "strings" "github.com/boolean-maybe/tiki/config" "github.com/boolean-maybe/tiki/model" @@ -385,6 +387,21 @@ func (ir *InputRouter) handleTaskInput(event *tcell.EventKey, params map[string] return false } return ir.openDepsEditor(taskID) + case ActionChat: + agent := config.GetAIAgent() + if agent == "" { + return false + } + taskID := ir.taskController.GetCurrentTaskID() + if taskID == "" { + return false + } + filename := strings.ToLower(taskID) + ".md" + taskFilePath := filepath.Join(config.GetTaskDir(), filename) + name, args := resolveAgentCommand(agent, taskFilePath) + ir.navController.SuspendAndRun(name, args...) + _ = ir.taskStore.ReloadTask(taskID) + return true default: return ir.taskController.HandleAction(action.ID) } diff --git a/controller/navigation.go b/controller/navigation.go index 6626ac5..06bc94b 100644 --- a/controller/navigation.go +++ b/controller/navigation.go @@ -2,6 +2,8 @@ package controller import ( "log/slog" + "os" + "os/exec" "github.com/boolean-maybe/tiki/model" "github.com/boolean-maybe/tiki/util" @@ -19,6 +21,7 @@ type NavigationController struct { activeViewGetter func() View // returns the currently displayed view from RootLayout onViewChanged func(viewID model.ViewID, params map[string]interface{}) // callback when view changes (for layoutModel sync) editorOpener func(string) error + commandRunner func(name string, args ...string) error } // NewNavigationController creates a navigation controller @@ -145,3 +148,32 @@ func (nc *NavigationController) SuspendAndEdit(filePath string) { } }) } + +// SetCommandRunner overrides the default command runner (useful for tests). +func (nc *NavigationController) SetCommandRunner(runner func(name string, args ...string) error) { + nc.commandRunner = runner +} + +// SuspendAndRun suspends the tview application and runs the specified command. +// The command runs with stdin/stdout/stderr connected to the terminal. +// After the command exits, the application resumes and redraws. +func (nc *NavigationController) SuspendAndRun(name string, args ...string) { + nc.app.Suspend(func() { + runner := nc.commandRunner + if runner == nil { + runner = defaultRunCommand + } + if err := runner(name, args...); err != nil { + slog.Error("command failed", "command", name, "error", err) + } + }) +} + +// defaultRunCommand runs a command with stdin/stdout/stderr connected to the terminal. +func defaultRunCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) //nolint:gosec // G204: args are constructed internally by resolveAgentCommand, not from user input + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/integration/ai_chat_test.go b/integration/ai_chat_test.go new file mode 100644 index 0000000..a543723 --- /dev/null +++ b/integration/ai_chat_test.go @@ -0,0 +1,121 @@ +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/boolean-maybe/tiki/model" + taskpkg "github.com/boolean-maybe/tiki/task" + "github.com/boolean-maybe/tiki/testutil" + + "github.com/gdamore/tcell/v2" + "github.com/spf13/viper" +) + +// TestTaskDetailView_ChatModifiesTask verifies the full chat action flow: +// key press → suspend → command modifies task file → resume → task reloaded. +func TestTaskDetailView_ChatModifiesTask(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + + // configure AI agent so the chat action is registered + viper.Set("ai.agent", "claude") + defer viper.Set("ai.agent", "") + + // create a task + taskID := "TIKI-CHAT01" + if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Original Title", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("failed to create test task: %v", err) + } + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("failed to reload tasks: %v", err) + } + + // mock command runner: verifies claude args and modifies the task title on disk + ta.NavController.SetCommandRunner(func(name string, args ...string) error { + if name != "claude" { + t.Errorf("expected command 'claude', got %q", name) + } + // verify --append-system-prompt is passed with task file path + if len(args) < 2 || args[0] != "--append-system-prompt" { + t.Errorf("expected --append-system-prompt arg, got %v", args) + } else if !strings.Contains(args[1], strings.ToLower(taskID)) { + t.Errorf("expected prompt to reference task ID, got %q", args[1]) + } + taskPath := filepath.Join(ta.TaskDir, strings.ToLower(taskID)+".md") + content, err := os.ReadFile(taskPath) + if err != nil { + return err + } + modified := strings.ReplaceAll(string(content), "Original Title", "AI Modified Title") + return os.WriteFile(taskPath, []byte(modified), 0644) //nolint:gosec // test file + }) + + // navigate to task detail + ta.NavController.PushView(model.TaskDetailViewID, model.EncodeTaskDetailParams(model.TaskDetailParams{ + TaskID: taskID, + })) + ta.Draw() + + // press 'c' to invoke chat + ta.SendKey(tcell.KeyRune, 'c', tcell.ModNone) + + // verify task was reloaded with the modified title + updated := ta.TaskStore.GetTask(taskID) + if updated == nil { + t.Fatal("task not found after chat") + } + if updated.Title != "AI Modified Title" { + t.Errorf("title = %q, want %q", updated.Title, "AI Modified Title") + } +} + +// TestTaskDetailView_ChatNotAvailableWithoutConfig verifies the chat action +// is not triggered when ai.agent is not configured. +func TestTaskDetailView_ChatNotAvailableWithoutConfig(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + + // ensure ai.agent is empty + viper.Set("ai.agent", "") + + // create a task + taskID := "TIKI-NOCHAT" + if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Unchanged Title", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("failed to create test task: %v", err) + } + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("failed to reload tasks: %v", err) + } + + // mock command runner: should NOT be called + called := false + ta.NavController.SetCommandRunner(func(name string, args ...string) error { + called = true + return nil + }) + + // navigate to task detail + ta.NavController.PushView(model.TaskDetailViewID, model.EncodeTaskDetailParams(model.TaskDetailParams{ + TaskID: taskID, + })) + ta.Draw() + + // press 'c' — should not trigger chat + ta.SendKey(tcell.KeyRune, 'c', tcell.ModNone) + + if called { + t.Error("command runner should not have been called when ai.agent is not configured") + } + + // verify task is unchanged + task := ta.TaskStore.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + if task.Title != "Unchanged Title" { + t.Errorf("title = %q, want %q", task.Title, "Unchanged Title") + } +} diff --git a/view/help/help.md b/view/help/help.md index d927a40..a6fabbf 100644 --- a/view/help/help.md +++ b/view/help/help.md @@ -30,7 +30,7 @@ The documentation can be organized using Markdown links and navigated in the `ti ## AI Since Markdown is an AI-native format issues and documentation can easily be created and maintained using AI tools. -tiki can optionally install skills to enable AI tools such as `claude`, `codex` or `opencode` to understand its +tiki can optionally install skills to enable AI tools such as `claude`, `gemini`, `codex` or `opencode` to understand its format. Try: >create a tiki from @my-markdown.md with title "Fix UI bug" diff --git a/view/help/tiki.md b/view/help/tiki.md index 418b482..b04688c 100644 --- a/view/help/tiki.md +++ b/view/help/tiki.md @@ -46,7 +46,7 @@ The `.doc/` directory contains two main subdirectories: Tiki files are saved in `.doc/tiki` directory and can be managed via: - `tiki` cli -- AI tools such as `claude`, `codex` or `opencode` +- AI tools such as `claude`, `gemini`, `codex` or `opencode` - manually A tiki is made of its frontmatter that includes all fields related to a tiki status and types and its description