mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
chat with AI agent
This commit is contained in:
parent
39a2239125
commit
91638fbcd2
18 changed files with 595 additions and 57 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -4,6 +4,18 @@ Follow me on X: [ 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)
|
||||
|
||||

|
||||
|
|
@ -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`
|
||||
|
|
|
|||
75
config/aitools.go
Normal file
75
config/aitools.go
Normal file
|
|
@ -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
|
||||
}
|
||||
83
config/aitools_test.go
Normal file
83
config/aitools_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
31
controller/agent.go
Normal file
31
controller/agent.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
79
controller/agent_test.go
Normal file
79
controller/agent_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
121
integration/ai_chat_test.go
Normal file
121
integration/ai_chat_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue