chat with AI agent

This commit is contained in:
booleanmaybe 2026-03-31 21:28:06 -04:00
parent 39a2239125
commit 91638fbcd2
18 changed files with 595 additions and 57 deletions

View file

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

View file

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

View file

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

View file

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

75
config/aitools.go Normal file
View 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
View 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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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