diff --git a/README.md b/README.md index b9c7f08..843a2c0 100644 --- a/README.md +++ b/README.md @@ -399,7 +399,7 @@ Short aliases: `g:flat`, `v:stats`, `p:hooks`, `cfg:edit`. Multi-command: `view: Config file: `~/.config/ccx/config.yaml` (bootstrap with `:config:edit`) -The config file contains three sections: +The config file contains these sections: ### Keybindings @@ -430,6 +430,33 @@ preferences: editor_input: true # prefer $EDITOR for live input (ctrl+e to toggle) ``` +### Claude command template + +Configure the local Claude command used by session resume/new-session, tmux windows, +plugin commands, and config/plugin test popups: + +```yaml +claude: + command_template: "claude {{args}}" +``` + +`{{args}}` expands to the arguments supplied by ccx, such as `--resume ` +or `plugin install `. If `{{args}}` is omitted, ccx appends its arguments at +the end. The template is parsed into argv and is not shell-evaluated for normal +process launches; tmux/script launches shell-quote the rendered argv. + +Examples: + +```yaml +claude: + command_template: "ccproxy -- claude {{args}}" +``` + +```yaml +claude: + command_template: "claude --model opus {{args}}" +``` + ### Number Key Shortcuts Number keys `1-9` trigger commands based on the active view and split focus side. diff --git a/internal/claudecmd/claudecmd.go b/internal/claudecmd/claudecmd.go new file mode 100644 index 0000000..e432393 --- /dev/null +++ b/internal/claudecmd/claudecmd.go @@ -0,0 +1,159 @@ +package claudecmd + +import ( + "errors" + "fmt" + "os/exec" + "strings" + "unicode" +) + +const ( + DefaultTemplate = "claude {{args}}" + argsPlaceholder = "{{args}}" +) + +type Config struct { + CommandTemplate string `yaml:"command_template,omitempty"` +} + +func RenderArgv(cfg Config, args ...string) ([]string, error) { + template := strings.TrimSpace(cfg.CommandTemplate) + if template == "" { + template = DefaultTemplate + } + + parts, err := splitTemplate(template) + if err != nil { + return nil, err + } + if len(parts) == 0 { + return nil, errors.New("claude command template is empty") + } + + insertedArgs := false + argv := make([]string, 0, len(parts)+len(args)) + for _, part := range parts { + if strings.Contains(part, argsPlaceholder) && part != argsPlaceholder { + return nil, fmt.Errorf("%s must be its own argument", argsPlaceholder) + } + if part == argsPlaceholder { + argv = append(argv, args...) + insertedArgs = true + continue + } + argv = append(argv, part) + } + if !insertedArgs { + argv = append(argv, args...) + } + if len(argv) == 0 || argv[0] == "" { + return nil, errors.New("claude command template has no executable") + } + return argv, nil +} + +func Command(cfg Config, dir string, args ...string) (*exec.Cmd, error) { + argv, err := RenderArgv(cfg, args...) + if err != nil { + return nil, err + } + cmd := exec.Command(argv[0], argv[1:]...) + if dir != "" { + cmd.Dir = dir + } + return cmd, nil +} + +func ShellCommand(cfg Config, dir string, args ...string) (string, error) { + argv, err := RenderArgv(cfg, args...) + if err != nil { + return "", err + } + quoted := make([]string, 0, len(argv)) + for _, arg := range argv { + quoted = append(quoted, ShellQuote(arg)) + } + cmd := strings.Join(quoted, " ") + if dir != "" { + cmd = "cd " + ShellQuote(dir) + " && " + cmd + } + return cmd, nil +} + +func ShellQuote(s string) string { + if s == "" { + return "''" + } + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + +func splitTemplate(input string) ([]string, error) { + var tokens []string + var b strings.Builder + inSingle := false + inDouble := false + escaped := false + hadQuotedOrEscaped := false + + flush := func() { + if b.Len() == 0 && !hadQuotedOrEscaped { + return + } + tokens = append(tokens, b.String()) + b.Reset() + hadQuotedOrEscaped = false + } + + for _, r := range input { + if escaped { + b.WriteRune(r) + escaped = false + hadQuotedOrEscaped = true + continue + } + if r == '\\' { + escaped = true + continue + } + if inSingle { + if r == '\'' { + inSingle = false + hadQuotedOrEscaped = true + continue + } + b.WriteRune(r) + continue + } + if inDouble { + if r == '"' { + inDouble = false + hadQuotedOrEscaped = true + continue + } + b.WriteRune(r) + continue + } + + switch { + case unicode.IsSpace(r): + flush() + case r == '\'': + inSingle = true + hadQuotedOrEscaped = true + case r == '"': + inDouble = true + hadQuotedOrEscaped = true + default: + b.WriteRune(r) + } + } + if escaped { + return nil, errors.New("claude command template ends with an unfinished escape") + } + if inSingle || inDouble { + return nil, errors.New("claude command template has an unterminated quote") + } + flush() + return tokens, nil +} diff --git a/internal/claudecmd/claudecmd_test.go b/internal/claudecmd/claudecmd_test.go new file mode 100644 index 0000000..3c3a231 --- /dev/null +++ b/internal/claudecmd/claudecmd_test.go @@ -0,0 +1,96 @@ +package claudecmd + +import ( + "reflect" + "strings" + "testing" +) + +func TestRenderArgvDefault(t *testing.T) { + got, err := RenderArgv(Config{}, "--resume", "abc") + if err != nil { + t.Fatalf("RenderArgv failed: %v", err) + } + want := []string{"claude", "--resume", "abc"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestRenderArgvWrapperTemplate(t *testing.T) { + got, err := RenderArgv(Config{CommandTemplate: "ccproxy -- claude {{args}}"}, "--resume", "abc") + if err != nil { + t.Fatalf("RenderArgv failed: %v", err) + } + want := []string{"ccproxy", "--", "claude", "--resume", "abc"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestRenderArgvAppendsArgsWhenPlaceholderMissing(t *testing.T) { + got, err := RenderArgv(Config{CommandTemplate: "claude --model opus"}, "--resume", "abc") + if err != nil { + t.Fatalf("RenderArgv failed: %v", err) + } + want := []string{"claude", "--model", "opus", "--resume", "abc"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestRenderArgvQuotedExecutable(t *testing.T) { + got, err := RenderArgv(Config{CommandTemplate: "'/opt/my wrapper/claude' --flag {{args}}"}, "plugin", "install", "foo/bar") + if err != nil { + t.Fatalf("RenderArgv failed: %v", err) + } + want := []string{"/opt/my wrapper/claude", "--flag", "plugin", "install", "foo/bar"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestRenderArgvRejectsEmbeddedArgsPlaceholder(t *testing.T) { + _, err := RenderArgv(Config{CommandTemplate: "claude --wrapped={{args}}"}, "--resume", "abc") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "own argument") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRenderArgvRejectsUnterminatedQuote(t *testing.T) { + _, err := RenderArgv(Config{CommandTemplate: "claude 'unterminated"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestShellCommandQuotesArguments(t *testing.T) { + got, err := ShellCommand(Config{CommandTemplate: "ccproxy -- claude {{args}}"}, "/tmp/a dir", "--resume", "abc; touch /tmp/pwned") + if err != nil { + t.Fatalf("ShellCommand failed: %v", err) + } + want := "cd '/tmp/a dir' && 'ccproxy' '--' 'claude' '--resume' 'abc; touch /tmp/pwned'" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestCommandSetsDirAndArgs(t *testing.T) { + cmd, err := Command(Config{CommandTemplate: "ccproxy -- claude {{args}}"}, "/tmp", "--resume", "abc") + if err != nil { + t.Fatalf("Command failed: %v", err) + } + if cmd.Args[0] != "ccproxy" { + t.Fatalf("Args[0] = %q", cmd.Args[0]) + } + want := []string{"ccproxy", "--", "claude", "--resume", "abc"} + if !reflect.DeepEqual(cmd.Args, want) { + t.Fatalf("Args = %#v, want %#v", cmd.Args, want) + } + if cmd.Dir != "/tmp" { + t.Fatalf("Dir = %q", cmd.Dir) + } +} diff --git a/internal/cli/run_pick.go b/internal/cli/run_pick.go index 727ba4a..6262aab 100644 --- a/internal/cli/run_pick.go +++ b/internal/cli/run_pick.go @@ -48,7 +48,7 @@ func RunPickSessionTUI(claudeDir, search string, multi bool) PickSessionExitCode } configPath := filepath.Join(os.Getenv("HOME"), ".config", "ccx", "config.yaml") - km, _, _, _ := tui.LoadCCXConfig(configPath) + km, _, _, _, cc := tui.LoadCCXConfig(configPath) app := tui.NewApp(sessions, tui.Config{ ClaudeDir: claudeDir, @@ -57,6 +57,7 @@ func RunPickSessionTUI(claudeDir, search string, multi bool) PickSessionExitCode SearchQuery: search, Keymap: km, PickMode: true, + Claude: cc, }) p := tea.NewProgram(app, diff --git a/internal/session/config.go b/internal/session/config.go index cf4fbf1..873695b 100644 --- a/internal/session/config.go +++ b/internal/session/config.go @@ -24,9 +24,9 @@ const ( ConfigCommand ConfigMCP ConfigHook - ConfigEnterprise // managed enterprise settings - ConfigKeymap // keybindings from config.yaml - ConfigShortcut // number key shortcuts + ConfigEnterprise // managed enterprise settings + ConfigKeymap // keybindings from config.yaml + ConfigShortcut // number key shortcuts configCategoryCount // must be last ) @@ -101,6 +101,7 @@ func ScanConfig(claudeDir, projectPath string) (*ConfigTree, error) { claudeMdPath := filepath.Join(claudeDir, "CLAUDE.md") addFileIfExists(tree, ConfigGlobal, claudeDir, "CLAUDE.md") visited := map[string]bool{claudeMdPath: true} + addAgentsRefs(tree, ConfigGlobal, claudeDir, claudeMdPath, visited, 1) walkReferences(tree, ConfigGlobal, claudeDir, claudeMdPath, visited, 1) // --- PROJECT --- @@ -111,6 +112,7 @@ func ScanConfig(claudeDir, projectPath string) (*ConfigTree, error) { addFileIfExists(tree, ConfigProject, projDir, "CLAUDE.md") // Walk @references from project CLAUDE.md projVisited := map[string]bool{projClaude: true} + addAgentsRefs(tree, ConfigProject, projDir, projClaude, projVisited, 1) // Also merge global visited to avoid duplicating global refs for k := range visited { projVisited[k] = true @@ -149,6 +151,7 @@ func ScanConfig(claudeDir, projectPath string) (*ConfigTree, error) { Size: info.Size(), }) localVisited[claudePath] = true + addAgentsRefs(tree, ConfigLocal, dir, claudePath, localVisited, 1) walkReferences(tree, ConfigLocal, claudeDir, claudePath, localVisited, 1) } } @@ -244,6 +247,74 @@ func addFileIfExists(tree *ConfigTree, cat ConfigCategory, dir, name string) { }) } +// reAgentsMarkdownLink matches Markdown links that point to AGENTS.md. +var reAgentsMarkdownLink = regexp.MustCompile(`\[[^\]]*AGENTS\.md[^\]]*\]\(([^)]*AGENTS\.md)\)`) + +func addAgentsRefs(tree *ConfigTree, cat ConfigCategory, baseDir, claudePath string, visited map[string]bool, depth int) { + if _, err := os.Stat(claudePath); err != nil { + return + } + addAgentsRef(tree, cat, filepath.Join(baseDir, "AGENTS.md"), claudePath, visited, depth) + for _, ref := range extractAgentsRefs(claudePath) { + addAgentsRef(tree, cat, ref, claudePath, visited, depth) + } +} + +func addAgentsRef(tree *ConfigTree, cat ConfigCategory, path, refBy string, visited map[string]bool, depth int) { + path = filepath.Clean(path) + if visited[path] { + return + } + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return + } + visited[path] = true + name := filepath.Base(path) + tree.Items = append(tree.Items, ConfigItem{ + Category: cat, + Name: name, + Path: path, + Description: extractDescription(path), + ModTime: info.ModTime(), + Size: info.Size(), + RefBy: refBy, + RefDepth: depth, + Group: "AGENTS.md", + }) +} + +func extractAgentsRefs(claudePath string) []string { + data, err := os.ReadFile(claudePath) + if err != nil { + return nil + } + baseDir := filepath.Dir(claudePath) + seen := map[string]bool{} + var refs []string + for _, match := range reAgentsMarkdownLink.FindAllStringSubmatch(string(data), -1) { + if len(match) < 2 { + continue + } + target := strings.TrimSpace(match[1]) + if target == "" || strings.Contains(target, "://") { + continue + } + if idx := strings.IndexAny(target, "#?"); idx >= 0 { + target = target[:idx] + } + if !filepath.IsAbs(target) { + target = filepath.Join(baseDir, target) + } + target = filepath.Clean(target) + if !seen[target] { + seen[target] = true + refs = append(refs, target) + } + } + return refs +} + func scanDirFiles(tree *ConfigTree, cat ConfigCategory, dir, ext string) { scanDirFilesExcept(tree, cat, dir, ext, nil) } diff --git a/internal/session/config_test.go b/internal/session/config_test.go index 14545dd..2f32ed5 100644 --- a/internal/session/config_test.go +++ b/internal/session/config_test.go @@ -50,6 +50,30 @@ func TestExtractFrontmatterMissing(t *testing.T) { } } +func TestScanConfigWithAgentsMarkdownLink(t *testing.T) { + dir := t.TempDir() + projectPath := filepath.Join(dir, "repo") + os.MkdirAll(projectPath, 0755) + os.WriteFile(filepath.Join(projectPath, "CLAUDE.md"), []byte("# Project\n\nRead [AGENTS.md](AGENTS.md).\n"), 0644) + os.WriteFile(filepath.Join(projectPath, "AGENTS.md"), []byte("# Agent Rules"), 0644) + + tree, err := ScanConfig(dir, projectPath) + if err != nil { + t.Fatal(err) + } + + found := false + for _, item := range tree.Items { + if item.Category == ConfigLocal && item.Name == "AGENTS.md" && item.RefBy == filepath.Join(projectPath, "CLAUDE.md") { + found = true + break + } + } + if !found { + t.Fatalf("expected local AGENTS.md referenced by CLAUDE.md, got %#v", tree.Items) + } +} + func TestScanConfig(t *testing.T) { dir := t.TempDir() diff --git a/internal/session/context.go b/internal/session/context.go new file mode 100644 index 0000000..ced7401 --- /dev/null +++ b/internal/session/context.go @@ -0,0 +1,497 @@ +package session + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +type ContextNodeKind string + +const ( + ContextRoot ContextNodeKind = "root" + ContextSessionState ContextNodeKind = "session_state" + ContextMemory ContextNodeKind = "memory" + ContextPlan ContextNodeKind = "plan" + ContextTask ContextNodeKind = "task" + ContextSkill ContextNodeKind = "skill" + ContextHook ContextNodeKind = "hook" + ContextMCP ContextNodeKind = "mcp" + ContextCommand ContextNodeKind = "command" + ContextAgent ContextNodeKind = "agent" + ContextFile ContextNodeKind = "file" +) + +type ContextNode struct { + Kind ContextNodeKind + Label string + Detail string + Path string + Status string + Count int + Used bool + RelatedView string + RelatedPath string + RelatedPluginID string + RelatedPluginComponentPath string + RelatedPluginComponentType string + Children []ContextNode +} + +type SessionContextTree struct { + SessionID string + ProjectPath string + Roots []ContextNode + Warnings []string +} + +func BuildSessionContextTree(claudeDir string, sess Session) (*SessionContextTree, error) { + if claudeDir == "" { + home, _ := os.UserHomeDir() + claudeDir = filepath.Join(home, ".claude") + } + tree := &SessionContextTree{ + SessionID: sess.ID, + ProjectPath: sess.ProjectPath, + } + + entries, err := loadContextEntries(sess) + if err != nil { + tree.Warnings = append(tree.Warnings, err.Error()) + } + + cfg, cfgErr := ScanConfig(claudeDir, sess.ProjectPath) + if cfgErr != nil { + tree.Warnings = append(tree.Warnings, cfgErr.Error()) + } + plugins, _ := ScanPlugins(claudeDir) + + tree.Roots = append(tree.Roots, + buildRuntimeContextRoot(claudeDir, sess, entries), + buildInstructionContextRoot(cfg, plugins), + buildSkillsContextRoot(cfg, entries, plugins), + buildHooksContextRoot(cfg, entries, plugins), + buildMCPContextRoot(cfg, entries, plugins), + buildCommandsAgentsContextRoot(cfg, sess, entries, plugins), + ) + return tree, nil +} + +func loadContextEntries(sess Session) ([]Entry, error) { + if sess.FilePath == "" { + return nil, nil + } + entries, err := LoadMessages(sess.FilePath) + if err != nil { + return nil, fmt.Errorf("load session messages: %w", err) + } + return entries, nil +} + +func buildRuntimeContextRoot(claudeDir string, sess Session, entries []Entry) ContextNode { + root := ContextNode{Kind: ContextSessionState, Label: "Internal session state"} + + if len(sess.Todos) > 0 { + completed := 0 + for _, todo := range sess.Todos { + if todo.Status == "completed" { + completed++ + } + } + root.Children = append(root.Children, ContextNode{Kind: ContextTask, Label: "Todos", Detail: fmt.Sprintf("%d/%d completed", completed, len(sess.Todos)), Count: len(sess.Todos)}) + } + + tasks := sess.Tasks + if len(tasks) == 0 && sess.HasTasks && len(entries) > 0 { + tasks = LoadTasksFromEntries(entries) + } + if len(tasks) > 0 { + root.Children = append(root.Children, taskContextNode(tasks)) + } + + crons := sess.Crons + if len(crons) == 0 && sess.HasCrons && len(entries) > 0 { + crons = LoadCronsFromEntries(entries) + } + if len(crons) > 0 { + root.Children = append(root.Children, cronContextNode(crons)) + } + + if len(sess.PlanSlugs) > 0 { + root.Children = append(root.Children, planContextNode(sess.PlanSlugs)) + } + + if sess.HasMemory && sess.ProjectPath != "" { + root.Children = append(root.Children, projectMemoryContextNode(claudeDir, sess.ProjectPath)) + } + + shells := sess.ShellJobs + if len(shells) == 0 && sess.HasShellJobs && len(entries) > 0 { + shells = LoadShellJobsFromEntries(entries) + } + if len(shells) > 0 { + root.Children = append(root.Children, shellContextNode(shells)) + } + + if len(root.Children) == 0 { + root.Detail = "no runtime context found" + } + root.Count = len(root.Children) + return root +} + +func taskContextNode(tasks []TaskItem) ContextNode { + completed := 0 + children := make([]ContextNode, 0, len(tasks)) + for _, task := range tasks { + if task.Status == "completed" { + completed++ + } + label := task.Subject + if label == "" { + label = task.ID + } + children = append(children, ContextNode{Kind: ContextTask, Label: label, Detail: task.Description, Status: task.Status, Used: true}) + } + return ContextNode{Kind: ContextTask, Label: "Task board", Detail: fmt.Sprintf("%d/%d completed", completed, len(tasks)), Count: len(tasks), Children: children} +} + +func cronContextNode(crons []CronItem) ContextNode { + active := 0 + children := make([]ContextNode, 0, len(crons)) + for _, cron := range crons { + status := cron.Status + if status == "" { + status = "active" + } + if status != "deleted" { + active++ + } + label := cron.ID + if label == "" { + label = cron.Cron + } + children = append(children, ContextNode{Kind: ContextTask, Label: label, Detail: cron.Prompt, Status: status, Used: true}) + } + return ContextNode{Kind: ContextTask, Label: "Scheduled tasks", Detail: fmt.Sprintf("%d/%d active", active, len(crons)), Count: len(crons), Children: children} +} + +func planContextNode(slugs []string) ContextNode { + home, _ := os.UserHomeDir() + children := make([]ContextNode, 0, len(slugs)) + for _, slug := range slugs { + path := filepath.Join(home, ".claude", "plans", slug+".md") + children = append(children, ContextNode{Kind: ContextPlan, Label: slug, Path: path, Status: fileStatus(path), Used: true}) + } + return ContextNode{Kind: ContextPlan, Label: "Plans", Count: len(slugs), Children: children} +} + +func projectMemoryContextNode(claudeDir, projectPath string) ContextNode { + encoded := EncodeProjectPath(projectPath) + memDir := filepath.Join(claudeDir, "projects", encoded, "memory") + entries, err := os.ReadDir(memDir) + if err != nil { + return ContextNode{Kind: ContextMemory, Label: "Project auto-memory", Path: memDir, Status: "missing"} + } + children := make([]ContextNode, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(memDir, entry.Name()) + children = append(children, ContextNode{Kind: ContextMemory, Label: entry.Name(), Path: path, Status: fileStatus(path), Used: true}) + } + return ContextNode{Kind: ContextMemory, Label: "Project auto-memory", Path: memDir, Count: len(children), Children: children} +} + +func shellContextNode(jobs []ShellJob) ContextNode { + children := make([]ContextNode, 0, len(jobs)) + for _, job := range jobs { + label := job.ToolName + if job.Description != "" { + label += ": " + job.Description + } + children = append(children, ContextNode{Kind: ContextSessionState, Label: label, Detail: job.Command, Status: job.Status, Used: true}) + } + return ContextNode{Kind: ContextSessionState, Label: "Background shells", Count: len(jobs), Children: children} +} + +func subagentContextNode(agents []Subagent) ContextNode { + children := make([]ContextNode, 0, len(agents)) + for _, agent := range agents { + label := agent.AgentType + if label == "" { + label = "agent" + } + if agent.ShortID != "" { + label += " " + agent.ShortID + } + children = append(children, ContextNode{Kind: ContextAgent, Label: label, Detail: agent.FirstPrompt, Path: agent.FilePath, Used: true}) + } + return ContextNode{Kind: ContextAgent, Label: "Subagents", Count: len(agents), Children: children} +} + +func buildInstructionContextRoot(cfg *ConfigTree, plugins *PluginTree) ContextNode { + root := ContextNode{Kind: ContextMemory, Label: "Instructions and memory"} + if cfg == nil { + root.Detail = "configuration scan unavailable" + return root + } + for _, section := range []struct { + label string + cat ConfigCategory + }{ + {"Global", ConfigGlobal}, + {"Project", ConfigProject}, + {"Local", ConfigLocal}, + } { + node := configCategoryNode(section.label, ContextFile, cfg.Items, section.cat, plugins) + if len(node.Children) > 0 { + root.Children = append(root.Children, node) + } + } + if len(root.Children) == 0 { + root.Detail = "no instruction files found" + } + root.Count = len(root.Children) + return root +} + +func buildSkillsContextRoot(cfg *ConfigTree, entries []Entry, plugins *PluginTree) ContextNode { + used := usedSkillNodes(entries) + configured := configNodes(cfg, ConfigSkill, ContextSkill, plugins) + return usedConfiguredRoot(ContextSkill, "Skills", used, configured) +} + +func buildHooksContextRoot(cfg *ConfigTree, entries []Entry, plugins *PluginTree) ContextNode { + used := usedHookNodes(entries) + configured := configNodes(cfg, ConfigHook, ContextHook, plugins) + return usedConfiguredRoot(ContextHook, "Hooks", used, configured) +} + +func buildMCPContextRoot(cfg *ConfigTree, entries []Entry, plugins *PluginTree) ContextNode { + used := usedMCPNodes(entries) + configured := configNodes(cfg, ConfigMCP, ContextMCP, plugins) + return usedConfiguredRoot(ContextMCP, "MCP", used, configured) +} + +func buildCommandsAgentsContextRoot(cfg *ConfigTree, sess Session, entries []Entry, plugins *PluginTree) ContextNode { + root := ContextNode{Kind: ContextCommand, Label: "Commands and agents"} + commands := usedCommandNodes(entries) + if len(commands) > 0 { + root.Children = append(root.Children, ContextNode{Kind: ContextCommand, Label: "Slash commands used", Count: len(commands), Children: commands}) + } + configuredCommands := configNodes(cfg, ConfigCommand, ContextCommand, plugins) + if len(configuredCommands) > 0 { + root.Children = append(root.Children, ContextNode{Kind: ContextCommand, Label: "Configured commands", Count: len(configuredCommands), Children: configuredCommands}) + } + if sess.HasAgents { + agents, err := FindSubagents(sess.FilePath) + if err == nil && len(agents) > 0 { + root.Children = append(root.Children, subagentContextNode(agents)) + } + } + configuredAgents := configNodes(cfg, ConfigAgent, ContextAgent, plugins) + if len(configuredAgents) > 0 { + root.Children = append(root.Children, ContextNode{Kind: ContextAgent, Label: "Configured agents", Count: len(configuredAgents), Children: configuredAgents}) + } + if len(root.Children) == 0 { + root.Detail = "no commands or agents found" + } + root.Count = len(root.Children) + return root +} + +func usedConfiguredRoot(kind ContextNodeKind, label string, used, configured []ContextNode) ContextNode { + root := ContextNode{Kind: kind, Label: label} + if len(used) > 0 { + root.Children = append(root.Children, ContextNode{Kind: kind, Label: "Used in session", Count: len(used), Children: used}) + } + if len(configured) > 0 { + root.Children = append(root.Children, ContextNode{Kind: kind, Label: "Available/configured", Count: len(configured), Children: configured}) + } + if len(root.Children) == 0 { + root.Detail = "none found" + } + root.Count = len(root.Children) + return root +} + +func configCategoryNode(label string, kind ContextNodeKind, items []ConfigItem, cat ConfigCategory, plugins *PluginTree) ContextNode { + root := ContextNode{Kind: kind, Label: label} + for _, item := range items { + if item.Category != cat { + continue + } + root.Children = append(root.Children, configItemNode(item, kind, plugins)) + } + root.Count = len(root.Children) + return root +} + +func configNodes(cfg *ConfigTree, cat ConfigCategory, kind ContextNodeKind, plugins *PluginTree) []ContextNode { + if cfg == nil { + return nil + } + var nodes []ContextNode + for _, item := range cfg.Items { + if item.Category == cat { + nodes = append(nodes, configItemNode(item, kind, plugins)) + } + } + return nodes +} + +func configItemNode(item ConfigItem, kind ContextNodeKind, plugins *PluginTree) ContextNode { + detail := item.Description + if item.Group != "" { + detail = strings.TrimSpace(strings.Join([]string{item.Group, detail}, " — ")) + } + node := ContextNode{ + Kind: kind, + Label: item.Name, + Detail: detail, + Path: item.Path, + Status: refStatus(item), + Used: item.RefBy != "", + RelatedView: "config", + RelatedPath: item.Path, + } + if pid, componentPath, componentType := pluginTargetForPath(item.Path, plugins); pid != "" { + node.RelatedPluginID = pid + node.RelatedPluginComponentPath = componentPath + node.RelatedPluginComponentType = componentType + } + return node +} + +func pluginTargetForPath(path string, plugins *PluginTree) (pluginID, componentPath, componentType string) { + if path == "" || plugins == nil { + return "", "", "" + } + clean := filepath.Clean(path) + for _, plugin := range plugins.Plugins { + installPath := filepath.Clean(plugin.Install.InstallPath) + if installPath != "" && (clean == installPath || strings.HasPrefix(clean, installPath+string(filepath.Separator))) { + for _, component := range plugin.Components { + cp := filepath.Clean(component.Path) + if cp == clean { + return plugin.ID, cp, component.Type + } + } + return plugin.ID, "", "" + } + } + return "", "", "" +} + +func refStatus(item ConfigItem) string { + if item.RefBy != "" { + return "referenced" + } + return "configured" +} + +func usedSkillNodes(entries []Entry) []ContextNode { + seen := map[string]bool{} + var nodes []ContextNode + for _, entry := range entries { + for _, block := range entry.Content { + if block.Type != "tool_use" || block.ToolName != "Skill" { + continue + } + var input struct { + Skill string `json:"skill"` + Args string `json:"args"` + } + if json.Unmarshal([]byte(block.ToolInput), &input) != nil || input.Skill == "" || seen[input.Skill] { + continue + } + seen[input.Skill] = true + nodes = append(nodes, ContextNode{Kind: ContextSkill, Label: input.Skill, Detail: input.Args, Used: true}) + } + } + return nodes +} + +func usedMCPNodes(entries []Entry) []ContextNode { + seen := map[string]bool{} + var nodes []ContextNode + for _, entry := range entries { + for _, block := range entry.Content { + if block.Type != "tool_use" || !strings.HasPrefix(block.ToolName, "mcp__") || seen[block.ToolName] { + continue + } + seen[block.ToolName] = true + nodes = append(nodes, ContextNode{Kind: ContextMCP, Label: block.ToolName, Detail: mcpServerLabel(block.ToolName), Used: true}) + } + } + return nodes +} + +func mcpServerLabel(toolName string) string { + trimmed := strings.TrimPrefix(toolName, "mcp__") + parts := strings.Split(trimmed, "__") + if len(parts) == 0 { + return "" + } + return strings.ReplaceAll(parts[0], "_", " ") +} + +func usedHookNodes(entries []Entry) []ContextNode { + seen := map[string]ContextNode{} + for _, entry := range entries { + for _, block := range entry.Content { + for _, hook := range block.Hooks { + key := strings.Join([]string{hook.Event, hook.Name, hook.Command}, "\x00") + if _, ok := seen[key]; ok { + continue + } + label := hook.Name + if label == "" { + label = hook.Event + } + seen[key] = ContextNode{Kind: ContextHook, Label: label, Detail: hook.Command, Status: hook.Event, Used: true} + } + } + } + keys := make([]string, 0, len(seen)) + for key := range seen { + keys = append(keys, key) + } + sort.Strings(keys) + nodes := make([]ContextNode, 0, len(keys)) + for _, key := range keys { + nodes = append(nodes, seen[key]) + } + return nodes +} + +func usedCommandNodes(entries []Entry) []ContextNode { + seen := map[string]bool{} + var nodes []ContextNode + for _, entry := range entries { + for _, block := range entry.Content { + if block.Type != "system_tag" || block.TagName != "command-name" { + continue + } + name := strings.TrimSpace(block.Text) + if name == "" || seen[name] { + continue + } + seen[name] = true + nodes = append(nodes, ContextNode{Kind: ContextCommand, Label: name, Used: true}) + } + } + return nodes +} + +func fileStatus(path string) string { + if _, err := os.Stat(path); err == nil { + return "present" + } + return "missing" +} diff --git a/internal/session/context_test.go b/internal/session/context_test.go new file mode 100644 index 0000000..dd98300 --- /dev/null +++ b/internal/session/context_test.go @@ -0,0 +1,95 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestBuildSessionContextTreeSeparatesUsedAndConfigured(t *testing.T) { + claudeDir := t.TempDir() + projectPath := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(projectPath, 0755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(projectPath, "CLAUDE.md"), []byte("# Project"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectPath, "AGENTS.md"), []byte("# Agents"), 0644); err != nil { + t.Fatal(err) + } + + skillDir := filepath.Join(claudeDir, "skills", "demo-skill") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: Demo skill\n---\n"), 0644); err != nil { + t.Fatal(err) + } + + hookPath := filepath.Join(claudeDir, "hooks", "post.sh") + if err := os.MkdirAll(filepath.Dir(hookPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hookPath, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + settings := `{"hooks":{"PostToolUse":[{"matcher":"Read","hooks":[{"command":"bash ` + hookPath + `"}]}]}}` + if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte(settings), 0644); err != nil { + t.Fatal(err) + } + + sessionFile := filepath.Join(t.TempDir(), "session.jsonl") + jsonl := `{"type":"user","timestamp":"2026-05-14T00:00:00Z","message":{"role":"user","content":"/clear"}}` + "\n" + + `{"type":"assistant","timestamp":"2026-05-14T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"skill-1","name":"Skill","input":{"skill":"demo-skill","args":"x"}},{"type":"tool_use","id":"mcp-1","name":"mcp__claude_ai_Atlassian__search","input":{"query":"demo"}},{"type":"tool_use","id":"task-1","name":"TaskCreate","input":{"id":"1","subject":"Check context","status":"pending"}}]}}` + "\n" + + `{"type":"progress","timestamp":"2026-05-14T00:00:02Z","toolUseID":"skill-1","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Skill","command":"bash ` + hookPath + `"}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(jsonl), 0644); err != nil { + t.Fatal(err) + } + + tree, err := BuildSessionContextTree(claudeDir, Session{ + ID: "session-1", + FilePath: sessionFile, + ProjectPath: projectPath, + ModTime: time.Now(), + HasTasks: true, + HasSkills: true, + HasMCP: true, + }) + if err != nil { + t.Fatal(err) + } + + if !hasContextPath(tree.Roots, "Skills", "Used in session", "demo-skill") { + t.Fatalf("expected used skill in context tree: %#v", tree.Roots) + } + if !hasContextPath(tree.Roots, "Skills", "Available/configured", "demo-skill") { + t.Fatalf("expected configured skill in context tree: %#v", tree.Roots) + } + if !hasContextPath(tree.Roots, "MCP", "Used in session", "mcp__claude_ai_Atlassian__search") { + t.Fatalf("expected used MCP tool in context tree: %#v", tree.Roots) + } + if !hasContextPath(tree.Roots, "Hooks", "Used in session", "PostToolUse:Skill") { + t.Fatalf("expected executed hook in context tree: %#v", tree.Roots) + } + if !hasContextPath(tree.Roots, "Commands and agents", "Slash commands used", "/clear") { + t.Fatalf("expected slash command in context tree: %#v", tree.Roots) + } + if !hasContextPath(tree.Roots, "Internal session state", "Task board", "Check context") { + t.Fatalf("expected task board item in context tree: %#v", tree.Roots) + } +} + +func hasContextPath(nodes []ContextNode, labels ...string) bool { + if len(labels) == 0 { + return true + } + for _, node := range nodes { + if node.Label == labels[0] && hasContextPath(node.Children, labels[1:]...) { + return true + } + } + return false +} diff --git a/internal/session/scanner_load.go b/internal/session/scanner_load.go index a81e1e8..3e74a11 100644 --- a/internal/session/scanner_load.go +++ b/internal/session/scanner_load.go @@ -183,11 +183,18 @@ func LoadTasksFromEntries(entries []Entry) []TaskItem { // Parse task data from tool input JSON var input struct { ID string `json:"id"` + TaskID string `json:"taskId"` Subject string `json:"subject"` Status string `json:"status"` Description string `json:"description"` } - if json.Unmarshal([]byte(b.ToolInput), &input) != nil || input.Subject == "" { + if json.Unmarshal([]byte(b.ToolInput), &input) != nil { + continue + } + if input.ID == "" { + input.ID = input.TaskID + } + if input.ID == "" && input.Subject == "" { continue } if existing, ok := tasks[input.ID]; ok { diff --git a/internal/tmux/claudecmd_test.go b/internal/tmux/claudecmd_test.go new file mode 100644 index 0000000..ad29a21 --- /dev/null +++ b/internal/tmux/claudecmd_test.go @@ -0,0 +1,18 @@ +package tmux + +import ( + "testing" + + "github.com/sendbird/ccx/internal/claudecmd" +) + +func TestClaudeWindowShellCommandUsesConfiguredTemplate(t *testing.T) { + got, err := ClaudeWindowShellCommand("/tmp/a dir", claudecmd.Config{CommandTemplate: "ccproxy -- claude {{args}}"}, "--resume", "abc; touch /tmp/pwned") + if err != nil { + t.Fatalf("ClaudeWindowShellCommand failed: %v", err) + } + want := "cd '/tmp/a dir' && 'ccproxy' '--' 'claude' '--resume' 'abc; touch /tmp/pwned'" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/internal/tmux/isolated.go b/internal/tmux/isolated.go index 64ad819..a3b684c 100644 --- a/internal/tmux/isolated.go +++ b/internal/tmux/isolated.go @@ -6,7 +6,8 @@ import ( "os" "os/exec" "path/filepath" - "strings" + + "github.com/sendbird/ccx/internal/claudecmd" ) // --- Isolated test environment --- @@ -59,11 +60,16 @@ func (e *IsolatedEnv) WriteSettings(data []byte) error { // Script builds a shell script that runs claude in this isolated env. // Extra args are appended to the claude command. func (e *IsolatedEnv) Script(extraArgs ...string) string { - args := strings.Join(extraArgs, " ") - mcpArgs := fmt.Sprintf("--mcp-config %s --strict-mcp-config", ShellQuote(e.MCPConfigPath())) - claudeCmd := "claude " + mcpArgs - if args != "" { - claudeCmd += " " + args + script, _ := e.ScriptWithConfig(claudecmd.Config{}, extraArgs...) + return script +} + +func (e *IsolatedEnv) ScriptWithConfig(cfg claudecmd.Config, extraArgs ...string) (string, error) { + args := []string{"--mcp-config", e.MCPConfigPath(), "--strict-mcp-config"} + args = append(args, extraArgs...) + claudeCmd, err := claudecmd.ShellCommand(cfg, "", args...) + if err != nil { + return "", err } // Create an editor wrapper that restores real HOME so vim/nvim // can find its config, then reverts HOME for Claude. @@ -88,7 +94,7 @@ func (e *IsolatedEnv) Script(extraArgs ...string) string { `unset CLAUDECODE; %sexport REAL_HOME=%s; export EDITOR=%s; export HOME=%s; cd %s; %s; `+ `rc=$?; if [ $rc -ne 0 ]; then echo ""; echo "[claude exited: $rc] press any key"; read -n1; fi`, OAuthTokenEnv(), ShellQuote(realHome), ShellQuote(wrapperPath), ShellQuote(resolvedHome), ShellQuote(resolvedHome), claudeCmd, - ) + ), nil } // RunPopup launches the script in a tmux display-popup with a nested tmux diff --git a/internal/tmux/isolated_config_test.go b/internal/tmux/isolated_config_test.go new file mode 100644 index 0000000..099bcee --- /dev/null +++ b/internal/tmux/isolated_config_test.go @@ -0,0 +1,27 @@ +package tmux + +import ( + "strings" + "testing" + + "github.com/sendbird/ccx/internal/claudecmd" +) + +func TestIsolatedEnvScriptWithConfigUsesClaudeTemplate(t *testing.T) { + env, err := NewIsolatedEnv("ccx-isolated-test-") + if err != nil { + t.Fatal(err) + } + defer env.Cleanup() + + script, err := env.ScriptWithConfig(claudecmd.Config{CommandTemplate: "ccproxy -- claude {{args}}"}, "--print") + if err != nil { + t.Fatalf("ScriptWithConfig failed: %v", err) + } + if !strings.Contains(script, "'ccproxy' '--' 'claude'") { + t.Fatalf("script does not use wrapper: %s", script) + } + if !strings.Contains(script, "'--mcp-config'") || !strings.Contains(script, "'--strict-mcp-config'") || !strings.Contains(script, "'--print'") { + t.Fatalf("script missing expected Claude args: %s", script) + } +} diff --git a/internal/tmux/pane.go b/internal/tmux/pane.go index daa73a5..8c5401b 100644 --- a/internal/tmux/pane.go +++ b/internal/tmux/pane.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/sendbird/ccx/internal/claudecmd" ) // Pane represents a tmux pane with its metadata. @@ -263,21 +265,39 @@ func CapturePane(p Pane) (string, error) { } // NewWindowClaude creates a new tmux window with the given name, -// cd's to dir, and runs "claude --resume ". +// cd's to dir, and resumes the session with the default Claude command. func NewWindowClaude(windowName, dir, sessionID string) error { - cmd := "cd " + ShellQuote(dir) + " && claude --resume " + sessionID + return NewWindowClaudeWithConfig(windowName, dir, sessionID, claudecmd.Config{}) +} + +func NewWindowClaudeWithConfig(windowName, dir, sessionID string, cfg claudecmd.Config) error { + cmd, err := ClaudeWindowShellCommand(dir, cfg, "--resume", sessionID) + if err != nil { + return err + } return exec.Command("tmux", "new-window", "-d", "-n", windowName, cmd).Run() } // NewWindowClaudeNew creates a new tmux window with the given name, -// cd's to dir, and runs "claude" (without --resume, starting a fresh session). +// cd's to dir, and starts a fresh session with the default Claude command. func NewWindowClaudeNew(windowName, dir string) error { - cmd := "cd " + ShellQuote(dir) + " && claude" + return NewWindowClaudeNewWithConfig(windowName, dir, claudecmd.Config{}) +} + +func NewWindowClaudeNewWithConfig(windowName, dir string, cfg claudecmd.Config) error { + cmd, err := ClaudeWindowShellCommand(dir, cfg) + if err != nil { + return err + } return exec.Command("tmux", "new-window", "-d", "-n", windowName, cmd).Run() } +func ClaudeWindowShellCommand(dir string, cfg claudecmd.Config, args ...string) (string, error) { + return claudecmd.ShellCommand(cfg, dir, args...) +} + // SpawnHiddenWindow creates a hidden tmux window running the given command // and returns the Pane reference for capture-pane. The window is created // with -d (detached) so it doesn't steal focus. @@ -418,5 +438,5 @@ func PromptAndSend(p Pane, promptText string) error { // ShellQuote wraps a string in single quotes for safe shell embedding. func ShellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" + return claudecmd.ShellQuote(s) } diff --git a/internal/tui/app.go b/internal/tui/app.go index b629242..b2f8c06 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/sendbird/ccx/internal/claudecmd" "github.com/sendbird/ccx/internal/extract" "github.com/sendbird/ccx/internal/kitty" "github.com/sendbird/ccx/internal/remote" @@ -173,14 +174,20 @@ const ( convPageImages convPageChanges convPageFiles + convPageContexts ) type convPageItem struct { extract.Item - timestamp time.Time - turnPreview string - userPrompt string - imagePasteID int + timestamp time.Time + turnPreview string + userPrompt string + imagePasteID int + relatedView string + relatedPath string + relatedPluginID string + relatedPluginComponentPath string + relatedPluginComponentType string } type App struct { @@ -255,6 +262,7 @@ type App struct { statsDetailVP viewport.Model statsPageMenu bool // "p" page jump popup convPageMenu bool // conversation page jump popup + sessPageMenu bool // sessions preview page jump popup convPageActive bool convPageFocus bool // true = right pane focused, false = left list focused convPageKitty bool // true = show kitty image preview in Images page @@ -273,17 +281,19 @@ type App struct { convPageActionsMenu bool // Session preview mode - sessPreviewMode sessPreview - sessStatsCache *session.SessionStats - sessStatsCacheKey string - sessMemoryCache string // rendered memory content - sessMemoryCacheKey string - sessTasksCache string - sessTasksCacheKey string - sessShellsCache string - sessShellsCacheKey string - sessPreviewAgents []session.Subagent // agents shown in Tasks/Plan preview - sessAgentCursor int // cursor within agents list + sessPreviewMode sessPreview + sessStatsCache *session.SessionStats + sessStatsCacheKey string + sessMemoryCache string // rendered memory content + sessMemoryCacheKey string + sessTasksCache string + sessTasksCacheKey string + sessShellsCache string + sessShellsCacheKey string + sessContextsCache string + sessContextsCacheKey string + sessPreviewAgents []session.Subagent // agents shown in Tasks/Plan preview + sessAgentCursor int // cursor within agents list // Conversation preview state sessConvEntries []mergedMsg // merged conversation messages @@ -555,25 +565,27 @@ const ( sessPreviewTasksPlan sessPreviewAgents sessPreviewShells + sessPreviewContexts sessPreviewLive // tmux pane capture sessPreviewRemote // remote session status/stream - numSessPreviewModes = 8 + numSessPreviewModes = 9 ) // Config holds application configuration from CLI flags. type Config struct { - ClaudeDir string // path to Claude data directory (empty = ~/.claude) - TmuxEnabled bool // enable tmux integration (I, J, live modal) - TmuxAutoLive bool // auto-enter live session in same tmux window on startup - WorktreeDir string // subdirectory name for worktrees (default ".worktree") - SearchQuery string // initial search filter for session list - Keymap *Keymap // nil = use defaults - GroupMode string // initial group mode (flat|proj|tree|chain|fork) - PreviewMode string // initial preview mode (conv|stats|mem|tasks) - ViewMode string // initial view (sessions|config|plugins|stats) - JumpSession string // session ID to open and navigate to on launch - JumpUUID string // entry UUID to navigate to within the session - PickMode bool // true = running under `ccx pick session`: show "Pick" action, skip prefs save + ClaudeDir string // path to Claude data directory (empty = ~/.claude) + TmuxEnabled bool // enable tmux integration (I, J, live modal) + TmuxAutoLive bool // auto-enter live session in same tmux window on startup + WorktreeDir string // subdirectory name for worktrees (default ".worktree") + SearchQuery string // initial search filter for session list + Keymap *Keymap // nil = use defaults + GroupMode string // initial group mode (flat|proj|tree|chain|fork) + PreviewMode string // initial preview mode (conv|stats|mem|tasks|agents|shells|contexts|live) + ViewMode string // initial view (sessions|config|plugins|stats) + JumpSession string // session ID to open and navigate to on launch + JumpUUID string // entry UUID to navigate to within the session + PickMode bool // true = running under `ccx pick session`: show "Pick" action, skip prefs save + Claude claudecmd.Config // command template for local Claude launches } func NewApp(sessions []session.Session, cfg Config) *App { @@ -604,7 +616,10 @@ func NewApp(sessions []session.Session, cfg Config) *App { } // Restore persisted view state (CLI flags override in the apply block below) - _, prefs, sc, rc := LoadCCXConfig(configPath()) + _, prefs, sc, rc, cc := LoadCCXConfig(configPath()) + if a.config.Claude.CommandTemplate == "" { + a.config.Claude = cc + } a.applyPreferences(prefs) a.shortcuts = sc a.remoteDefaults = rc @@ -635,7 +650,7 @@ func NewApp(sessions []session.Session, cfg Config) *App { } } if a.config.PreviewMode != "" { - modeMap := map[string]sessPreview{"conv": sessPreviewConversation, "stats": sessPreviewStats, "mem": sessPreviewMemory, "tasks": sessPreviewTasksPlan, "agents": sessPreviewAgents, "shells": sessPreviewShells, "live": sessPreviewLive} + modeMap := map[string]sessPreview{"conv": sessPreviewConversation, "stats": sessPreviewStats, "mem": sessPreviewMemory, "tasks": sessPreviewTasksPlan, "agents": sessPreviewAgents, "shells": sessPreviewShells, "contexts": sessPreviewContexts, "ctx": sessPreviewContexts, "live": sessPreviewLive} if m, ok := modeMap[a.config.PreviewMode]; ok { a.sessPreviewMode = m a.sessSplit.Show = true @@ -1184,6 +1199,12 @@ func (a *App) View() string { help = formatHelp("x:actions — pick an action") } + if a.sessPageMenu && a.state == viewSessions { + hintBox := a.renderSessPageHintBox() + content = placeHintBox(content, hintBox, a.activeDividerCol()) + help = formatHelp("p:page — pick a preview") + } + // Tag menu floating modal if a.tagMenu { modal := a.renderTagMenu() @@ -1668,6 +1689,13 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Focused preview: custom conversation nav or simple scroll if sp.Focus && sp.Show { + if a.sessPageMenu { + return a.handleSessPageMenu(key) + } + if key == "p" { + a.sessPageMenu = true + return a, nil + } if m, cmd, handled := a.handleFocusedPreviewKeys(sp, key); handled { return m, cmd } @@ -2124,6 +2152,48 @@ func (a *App) handleStatsPageMenu(key string) (tea.Model, tea.Cmd) { return a, nil } +func (a *App) handleSessPageMenu(key string) (tea.Model, tea.Cmd) { + a.sessPageMenu = false + switch key { + case "v": + a.setSessPreviewMode(sessPreviewConversation) + case "s": + a.setSessPreviewMode(sessPreviewStats) + case "m": + a.setSessPreviewMode(sessPreviewMemory) + case "t": + a.setSessPreviewMode(sessPreviewTasksPlan) + case "a": + a.setSessPreviewMode(sessPreviewAgents) + case "c": + a.setSessPreviewMode(sessPreviewContexts) + case "l": + if sess, ok := a.selectedSession(); ok { + if sess.IsRemote { + return a.openRemoteLivePreview(sess) + } + return a.openLivePreview(sess) + } + } + return a, nil +} + +func (a *App) renderSessPageHintBox() string { + hl := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + d := dimStyle + sp := " " + line1 := hl.Render("v") + d.Render(":conv") + sp + hl.Render("s") + d.Render(":stats") + line2 := hl.Render("m") + d.Render(":mem") + sp + hl.Render("t") + d.Render(":tasks") + line3 := hl.Render("a") + d.Render(":agents") + sp + hl.Render("l") + d.Render(":live") + line4 := hl.Render("c") + d.Render(":contexts") + body := strings.Join([]string{line1, line2, line3, line4, d.Render("esc:cancel")}, "\n") + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorDim). + Padding(0, 1) + return boxStyle.Render(body) +} + func (a *App) renderStatsPageHintBox() string { hl := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) d := dimStyle @@ -2151,6 +2221,8 @@ func (a *App) handleConvPageMenu(key string) (tea.Model, tea.Cmd) { return a.openConvChangesPage() case "f": return a.openConvFilesPage() + case "c": + return a.openConvContextsPage() } return a, nil } @@ -2162,8 +2234,9 @@ func (a *App) renderConvPageHintBox() string { line1 := hl.Render("u") + d.Render(":urls") + sp + hl.Render("i") + d.Render(":images") line2 := hl.Render("g") + d.Render(":changes") + sp + hl.Render("f") + d.Render(":files") + line3 := hl.Render("c") + d.Render(":contexts") - body := strings.Join([]string{line1, line2, d.Render("esc:cancel")}, "\n") + body := strings.Join([]string{line1, line2, line3, d.Render("esc:cancel")}, "\n") boxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorDim). @@ -2278,6 +2351,91 @@ func (a *App) closePaneProxy() { a.paneProxy = nil } +func (a *App) claudeCmd(dir string, args ...string) (*exec.Cmd, error) { + return claudecmd.Command(a.config.Claude, dir, args...) +} + +func (a *App) openConfigExplorerAtPath(path string) (tea.Model, tea.Cmd) { + m, cmd := a.openConfigExplorer() + if path == "" { + return m, cmd + } + clean := filepath.Clean(path) + for i, item := range a.cfgList.Items() { + ci, ok := item.(cfgItem) + if !ok || ci.isHeader { + continue + } + if filepath.Clean(ci.item.Path) == clean { + a.cfgList.Select(i) + a.updateConfigPreview() + return m, cmd + } + } + a.copiedMsg = "Related config item not found" + return m, cmd +} + +func (a *App) openPluginExplorerAt(pluginID string) (tea.Model, tea.Cmd) { + m, cmd := a.openPluginExplorer() + if pluginID == "" { + return m, cmd + } + for i, item := range a.plgList.Items() { + pi, ok := item.(plgItem) + if !ok || pi.isHeader { + continue + } + if pi.plugin.ID == pluginID || filepath.Clean(pi.plugin.Install.InstallPath) == filepath.Clean(pluginID) { + a.plgList.Select(i) + a.updatePluginPreview() + return m, cmd + } + } + a.copiedMsg = "Related plugin not found" + return m, cmd +} + +func (a *App) openPluginComponentAt(pluginID, componentPath, componentType string) (tea.Model, tea.Cmd) { + m, cmd := a.openPluginExplorerAt(pluginID) + if componentPath == "" { + return m, cmd + } + selected, ok := a.plgList.SelectedItem().(plgItem) + if !ok || selected.isHeader { + return m, cmd + } + m, cmd = a.openPluginDetail(selected.plugin) + clean := filepath.Clean(componentPath) + for i, item := range a.plgDetailList.Items() { + ci, ok := item.(plgCompItem) + if !ok || ci.isHeader { + continue + } + if filepath.Clean(ci.comp.Path) == clean && (componentType == "" || ci.comp.Type == componentType) { + a.plgDetailList.Select(i) + a.updatePluginDetailPreview() + return m, cmd + } + } + a.copiedMsg = "Related plugin component not found" + return m, cmd +} + +func (a *App) openRelatedContextNode(node session.ContextNode) (tea.Model, tea.Cmd) { + switch node.RelatedView { + case "config": + return a.openConfigExplorerAtPath(node.RelatedPath) + case "plugin-component": + return a.openPluginComponentAt(node.RelatedPluginID, node.RelatedPluginComponentPath, node.RelatedPluginComponentType) + case "plugin": + return a.openPluginExplorerAt(node.RelatedPluginID) + default: + a.copiedMsg = "No related destination" + return a, nil + } +} + func (a *App) resumeSession(sess session.Session) (tea.Model, tea.Cmd) { dir := sess.ProjectPath if dir == "" { @@ -2294,9 +2452,12 @@ func (a *App) resumeSession(sess session.Session) (tea.Model, tea.Cmd) { return a, nil } // Fallback: take over CSB - c := exec.Command("claude", "--resume", sess.ID) - c.Dir = dir - return a, tea.ExecProcess(c, func(err error) tea.Msg { + cmd, err := a.claudeCmd(dir, "--resume", sess.ID) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } + return a, tea.ExecProcess(cmd, func(err error) tea.Msg { return tea.QuitMsg{} }) } @@ -2307,7 +2468,7 @@ func (a *App) resumeSession(sess session.Session) (tea.Model, tea.Cmd) { if windowName == "" { windowName = "claude" } - if err := tmux.NewWindowClaude(windowName, dir, sess.ID); err != nil { + if err := tmux.NewWindowClaudeWithConfig(windowName, dir, sess.ID, a.config.Claude); err != nil { a.copiedMsg = "Spawn failed" } else { a.copiedMsg = "Resumed in new window" @@ -2316,9 +2477,12 @@ func (a *App) resumeSession(sess session.Session) (tea.Model, tea.Cmd) { } // Non-tmux: take over CSB - c := exec.Command("claude", "--resume", sess.ID) - c.Dir = dir - return a, tea.ExecProcess(c, func(err error) tea.Msg { + cmd, err := a.claudeCmd(dir, "--resume", sess.ID) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } + return a, tea.ExecProcess(cmd, func(err error) tea.Msg { return tea.QuitMsg{} }) } @@ -2560,6 +2724,19 @@ func (a *App) sessionPreviewActionsActive() bool { func (a *App) handleActionsMenu(key string) (tea.Model, tea.Cmd) { a.actionsMenu = false a.copiedMsg = "" + if a.sessionPreviewActionsActive() { + switch key { + case "c": + a.setSessPreviewMode(sessPreviewContexts) + return a, nil + case "f": + visible := a.convVisibleEntries() + return a.openSessionPreviewFullText(visible), nil + case "y", a.keymap.Actions.Copy: + a.copySessionPreviewSelection() + return a, nil + } + } if !a.sessionPreviewActionsActive() && a.hasMultiSelection() { return a.handleBulkActionsMenu(key) } @@ -2698,7 +2875,7 @@ func (a *App) forkSession(sess session.Session) (tea.Model, tea.Cmd) { windowName = "claude" } windowName += "-fork" - if err := tmux.NewWindowClaude(windowName, dir, sess.ID); err != nil { + if err := tmux.NewWindowClaudeWithConfig(windowName, dir, sess.ID, a.config.Claude); err != nil { a.copiedMsg = "Fork failed: " + err.Error() } else { a.copiedMsg = "Forked → " + windowName @@ -2707,9 +2884,12 @@ func (a *App) forkSession(sess session.Session) (tea.Model, tea.Cmd) { } // Non-tmux: take over terminal - c := exec.Command("claude", "--resume", sess.ID) - c.Dir = dir - return a, tea.ExecProcess(c, func(err error) tea.Msg { + cmd, err := a.claudeCmd(dir, "--resume", sess.ID) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } + return a, tea.ExecProcess(cmd, func(err error) tea.Msg { return editorDoneMsg{} }) } @@ -2861,7 +3041,7 @@ func (a *App) bulkResume(selected []session.Session) (tea.Model, tea.Cmd) { if name == "" { name = s.ShortID } - if err := tmux.NewWindowClaude(name, dir, s.ID); err == nil { + if err := tmux.NewWindowClaudeWithConfig(name, dir, s.ID, a.config.Claude); err == nil { count++ } } @@ -3220,16 +3400,19 @@ func (a *App) newSessionInDir(dir, name string) (tea.Model, tea.Cmd) { name = filepath.Base(dir) } if tmux.InTmux() { - if err := tmux.NewWindowClaudeNew(name, dir); err != nil { + if err := tmux.NewWindowClaudeNewWithConfig(name, dir, a.config.Claude); err != nil { a.copiedMsg = "Spawn failed" return a, nil } a.copiedMsg = "New session → " + name return a, a.delayedRefreshCmd() } - c := exec.Command("claude") - c.Dir = dir - return a, tea.ExecProcess(c, func(err error) tea.Msg { + cmd, err := a.claudeCmd(dir) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } + return a, tea.ExecProcess(cmd, func(err error) tea.Msg { return tea.QuitMsg{} }) } @@ -3266,16 +3449,19 @@ func (a *App) executeNewWorktreeSession(sess session.Session, branch string) (te // Spawn new Claude session in the worktree name := filepath.Base(repoRoot) + "/" + branch if tmux.InTmux() { - if err := tmux.NewWindowClaudeNew(name, wtPath); err != nil { + if err := tmux.NewWindowClaudeNewWithConfig(name, wtPath, a.config.Claude); err != nil { a.copiedMsg = "Spawn failed" return a, nil } a.copiedMsg = fmt.Sprintf("New session → %s/%s", a.config.WorktreeDir, branch) return a, a.delayedRefreshCmd() } - c := exec.Command("claude") - c.Dir = wtPath - return a, tea.ExecProcess(c, func(err error) tea.Msg { + cmd, err := a.claudeCmd(wtPath) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } + return a, tea.ExecProcess(cmd, func(err error) tea.Msg { return tea.QuitMsg{} }) } @@ -3353,7 +3539,7 @@ func (a *App) newSession() (tea.Model, tea.Cmd) { } if tmux.InTmux() { - if err := tmux.NewWindowClaudeNew(windowName, dir); err != nil { + if err := tmux.NewWindowClaudeNewWithConfig(windowName, dir, a.config.Claude); err != nil { a.copiedMsg = "Spawn failed" } else { a.copiedMsg = "New session in new window" @@ -3362,9 +3548,12 @@ func (a *App) newSession() (tea.Model, tea.Cmd) { } // Non-tmux: take over terminal - c := exec.Command("claude") - c.Dir = dir - return a, tea.ExecProcess(c, func(err error) tea.Msg { + cmd, err := a.claudeCmd(dir) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } + return a, tea.ExecProcess(cmd, func(err error) tea.Msg { return tea.QuitMsg{} }) } @@ -3388,16 +3577,19 @@ func (a *App) executeCmdNewSession(input string) (tea.Model, tea.Cmd) { } windowName := filepath.Base(dir) if tmux.InTmux() { - if err := tmux.NewWindowClaudeNew(windowName, dir); err != nil { + if err := tmux.NewWindowClaudeNewWithConfig(windowName, dir, a.config.Claude); err != nil { a.copiedMsg = "Spawn failed" } else { a.copiedMsg = "New session → " + windowName } return a, nil } - c := exec.Command("claude") - c.Dir = dir - return a, tea.ExecProcess(c, func(err error) tea.Msg { + cmd, err := a.claudeCmd(dir) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } + return a, tea.ExecProcess(cmd, func(err error) tea.Msg { return tea.QuitMsg{} }) } @@ -3439,7 +3631,7 @@ func (a *App) executeCmdWorktreeNew(input string) (tea.Model, tea.Cmd) { // Open new Claude session in the worktree windowName := branch if tmux.InTmux() { - if err := tmux.NewWindowClaudeNew(windowName, wtPath); err != nil { + if err := tmux.NewWindowClaudeNewWithConfig(windowName, wtPath, a.config.Claude); err != nil { a.copiedMsg = "Worktree created but spawn failed" } else { a.copiedMsg = fmt.Sprintf("Worktree + session → %s/%s", a.config.WorktreeDir, branch) @@ -3448,10 +3640,13 @@ func (a *App) executeCmdWorktreeNew(input string) (tea.Model, tea.Cmd) { } // Non-tmux: take over terminal - c := exec.Command("claude") - c.Dir = wtPath + cmd, err := a.claudeCmd(wtPath) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } a.copiedMsg = fmt.Sprintf("Worktree created → %s/%s", a.config.WorktreeDir, branch) - return a, tea.ExecProcess(c, func(err error) tea.Msg { + return a, tea.ExecProcess(cmd, func(err error) tea.Msg { return tea.QuitMsg{} }) } @@ -3995,6 +4190,8 @@ func (a *App) updateSessionPreview() tea.Cmd { a.updateSessionAgentsPreview(sess) case sessPreviewShells: a.updateSessionShellsPreview(sess) + case sessPreviewContexts: + a.updateSessionContextsPreview(sess) case sessPreviewLive: if sess.IsLive { a.sessSplit.Preview.SetContent(dimStyle.Render("(connecting…)")) @@ -4511,6 +4708,106 @@ func (a *App) updateSessionShellsPreview(sess session.Session) { a.sessSplit.Preview.SetContent(a.sessShellsCache) } +func (a *App) updateSessionContextsPreview(sess session.Session) { + previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) + contentH := max(a.height-3, 1) + cacheKey := fmt.Sprintf("%s:%d:%d", sess.ID, sess.ModTime.UnixNano(), previewW) + if a.sessContextsCacheKey != cacheKey { + tree, err := session.BuildSessionContextTree(a.config.ClaudeDir, sess) + if err != nil { + a.sessContextsCache = dimStyle.Render("Failed to build context tree: " + err.Error()) + } else { + a.sessContextsCache = renderSessionContextTree(tree, previewW) + } + a.sessContextsCacheKey = cacheKey + } + a.sessSplit.Preview = viewport.New(previewW, contentH) + a.sessSplit.Preview.SetContent(a.sessContextsCache) +} + +func renderSessionContextTree(tree *session.SessionContextTree, width int) string { + if tree == nil { + return dimStyle.Render("No context tree available.") + } + var sb strings.Builder + header := "── Session Context" + if tree.SessionID != "" { + header += ": " + tree.SessionID[:min(8, len(tree.SessionID))] + } + header += " ──" + sb.WriteString(dimStyle.Render(header) + "\n\n") + if tree.ProjectPath != "" { + home, _ := os.UserHomeDir() + sb.WriteString(dimStyle.Render("project: "+session.ShortenPath(tree.ProjectPath, home)) + "\n\n") + } + for i, node := range tree.Roots { + renderContextNode(&sb, node, "", i == len(tree.Roots)-1, width) + } + if len(tree.Warnings) > 0 { + sb.WriteString("\n" + dimStyle.Render("Warnings") + "\n") + for _, warning := range tree.Warnings { + sb.WriteString(dimStyle.Render(" - "+truncateContextText(warning, width-4)) + "\n") + } + } + return strings.TrimRight(sb.String(), "\n") +} + +func renderContextNode(sb *strings.Builder, node session.ContextNode, prefix string, last bool, width int) { + connector := "├─ " + nextPrefix := prefix + "│ " + if last { + connector = "└─ " + nextPrefix = prefix + " " + } + line := contextNodeLine(node) + if width > 0 { + line = truncateContextText(line, width-len(prefix)-3) + } + style := dimStyle + if node.Used { + style = lipgloss.NewStyle().Foreground(colorAccent) + } else if node.Status == "missing" { + style = lipgloss.NewStyle().Foreground(colorDim) + } + sb.WriteString(dimStyle.Render(prefix+connector) + style.Render(line) + "\n") + for i, child := range node.Children { + renderContextNode(sb, child, nextPrefix, i == len(node.Children)-1, width) + } +} + +func contextNodeLine(node session.ContextNode) string { + line := node.Label + if node.Count > 0 { + line += fmt.Sprintf(" [%d]", node.Count) + } + if node.Status != "" { + line += " (" + node.Status + ")" + } + if node.Detail != "" { + line += " — " + oneLine(node.Detail) + } + if node.Path != "" { + home, _ := os.UserHomeDir() + line += " " + session.ShortenPath(node.Path, home) + } + return line +} + +func truncateContextText(s string, maxLen int) string { + if maxLen <= 3 || len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +func oneLine(s string) string { + s = strings.TrimSpace(s) + if idx := strings.Index(s, "\n"); idx >= 0 { + s = s[:idx] + } + return s +} + func (a *App) buildShellsPreviewContent(sess session.Session) string { if !sess.HasShellJobs { return dimStyle.Render("No background shells or monitors found for this session.") @@ -5019,7 +5316,11 @@ func (a *App) renderActionsHintBox() string { lines = append(lines, hl.Render(displayKey(akm.URLs))+d.Render(":urls")+sp+hl.Render(displayKey(akm.Files))+d.Render(":files")+sp+hl.Render(displayKey(akm.Changes))+d.Render(":changes")+sp+hl.Render(displayKey(akm.Tags))+d.Render(":tags")) } else { sess := a.actionsSess - lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Move))+d.Render(":move")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.Copy))+d.Render(":copy")+sp+hl.Render(displayKey(akm.CopyPath))+d.Render(":copy-path")) + if a.sessionPreviewActionsActive() { + lines = append(lines, hl.Render("c")+d.Render(":contexts")+sp+hl.Render("f")+d.Render(":full")+sp+hl.Render("y")+d.Render(":copy")) + } else { + lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Move))+d.Render(":move")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.Copy))+d.Render(":copy")+sp+hl.Render(displayKey(akm.CopyPath))+d.Render(":copy-path")) + } line2 := hl.Render(displayKey(akm.Worktree)) + d.Render(":worktree") + sp + hl.Render(displayKey(akm.URLs)) + d.Render(":urls") + sp + hl.Render(displayKey(akm.Files)) + d.Render(":files") + sp + hl.Render(displayKey(akm.Changes)) + d.Render(":changes") + sp + hl.Render(displayKey(akm.Tags)) + d.Render(":tags") if sess.HasMemory { line2 += sp + hl.Render(displayKey(akm.RemoveMem)) + d.Render(":rm-mem") diff --git a/internal/tui/cmdmode.go b/internal/tui/cmdmode.go index 447356e..614cff3 100644 --- a/internal/tui/cmdmode.go +++ b/internal/tui/cmdmode.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/sendbird/ccx/internal/claudecmd" "github.com/sendbird/ccx/internal/session" "gopkg.in/yaml.v3" ) @@ -87,6 +88,8 @@ func buildCmdRegistry() []cmdEntry { action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewTasksPlan); return a, nil }}, {name: "preview:agents", aliases: []string{"p:agents"}, desc: "agents preview", views: cmdSessions, action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewAgents); return a, nil }}, + {name: "preview:contexts", aliases: []string{"p:contexts", "p:ctx", "contexts", "page:contexts"}, desc: "context tree preview", views: cmdSessions, + action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewContexts); return a, nil }}, {name: "preview:live", aliases: []string{"p:live"}, desc: "live preview", views: cmdSessions, action: func(a *App) (tea.Model, tea.Cmd) { sess, ok := a.selectedSession() @@ -784,13 +787,16 @@ func (a *App) bootstrapAndEditConfig() (tea.Model, tea.Cmd) { Navigation: km.Navigation, }, Preferences: a.capturePreferences(), + Claude: claudecmd.Config{ + CommandTemplate: claudecmd.DefaultTemplate, + }, } data, err := yaml.Marshal(cfg) if err != nil { a.copiedMsg = "marshal failed: " + err.Error() return a, nil } - header := "# ccx configuration\n# Keybindings: session, actions, views, navigation\n# Preferences: preferences section (auto-saved on quit)\n# Restart ccx after editing keybindings.\n\n" + header := "# ccx configuration\n# Keybindings: session, actions, views, navigation\n# Preferences: preferences section (auto-saved on quit)\n# Claude: command_template controls local Claude launches; {{args}} expands to ccx-provided args.\n# Restart ccx after editing keybindings.\n\n" if err := os.WriteFile(path, []byte(header+string(data)), 0644); err != nil { a.copiedMsg = "write failed: " + err.Error() return a, nil diff --git a/internal/tui/cmdmode_test.go b/internal/tui/cmdmode_test.go index 956adf5..bb3c95f 100644 --- a/internal/tui/cmdmode_test.go +++ b/internal/tui/cmdmode_test.go @@ -85,9 +85,9 @@ func TestSuggestionFiltering_PreviewPrefix(t *testing.T) { matches = append(matches, entry) } } - // Should match all preview commands (p:conv, p:stats, p:mem, p:tasks, p:live) - if len(matches) < 5 { - t.Errorf("expected at least 5 preview matches for 'p:', got %d", len(matches)) + // Should match all preview commands (p:conv, p:stats, p:mem, p:tasks, p:agents, p:contexts, p:live) + if len(matches) < 7 { + t.Errorf("expected at least 7 preview matches for 'p:', got %d", len(matches)) } } @@ -135,7 +135,7 @@ func TestRegistryCompleteness(t *testing.T) { "group:chain": false, "group:fork": false, "pane:flat": false, "pane:tree": false, "preview:conv": false, "preview:stats": false, "preview:mem": false, - "preview:tasks": false, "preview:live": false, + "preview:tasks": false, "preview:agents": false, "preview:contexts": false, "preview:live": false, "view:sessions": false, "view:stats": false, "view:config": false, "view:config:hooks": false, "view:plugins": false, "view:stats:tools": false, "view:stats:mcp": false, diff --git a/internal/tui/config.go b/internal/tui/config.go index b1827db..f156029 100644 --- a/internal/tui/config.go +++ b/internal/tui/config.go @@ -550,7 +550,6 @@ func cfgScopeGroups(tree *session.ConfigTree) []cfgScopeGroup { } } - func buildConfigItems(tree *session.ConfigTree) []list.Item { var items []list.Item @@ -1064,7 +1063,11 @@ func (a *App) launchConfigTest() (tea.Model, tea.Cmd) { return a, nil } - script := env.Script() + script, err := env.ScriptWithConfig(a.config.Claude) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } a.copiedMsg = fmt.Sprintf("Testing %d configs…", len(items)) @@ -1076,7 +1079,6 @@ func (a *App) launchConfigTest() (tea.Model, tea.Cmd) { type configTestDoneMsg struct{ tmpDir string } - // --- Category filter --- const cfgFilterAll = -1 // show everything @@ -1127,13 +1129,13 @@ func (a *App) rebuildCfgList() { // cfgSearchTags maps config categories to searchable "is:" tags. var cfgSearchTags = map[session.ConfigCategory][]string{ - session.ConfigGlobal: {"is:user", "is:memory"}, - session.ConfigProject: {"is:project"}, - session.ConfigLocal: {"is:local"}, - session.ConfigSkill: {"is:user", "is:skill"}, - session.ConfigAgent: {"is:user", "is:agent"}, - session.ConfigCommand: {"is:user", "is:command", "is:cmd"}, - session.ConfigHook: {"is:user", "is:hook"}, + session.ConfigGlobal: {"is:user", "is:memory"}, + session.ConfigProject: {"is:project"}, + session.ConfigLocal: {"is:local"}, + session.ConfigSkill: {"is:user", "is:skill"}, + session.ConfigAgent: {"is:user", "is:agent"}, + session.ConfigCommand: {"is:user", "is:command", "is:cmd"}, + session.ConfigHook: {"is:user", "is:hook"}, session.ConfigMCP: {"is:user", "is:mcp"}, session.ConfigEnterprise: {"is:enterprise"}, } diff --git a/internal/tui/context_jump_test.go b/internal/tui/context_jump_test.go new file mode 100644 index 0000000..a3f7591 --- /dev/null +++ b/internal/tui/context_jump_test.go @@ -0,0 +1,38 @@ +package tui + +import ( + "path/filepath" + "testing" + "time" + + "github.com/sendbird/ccx/internal/extract" + "github.com/sendbird/ccx/internal/session" +) + +func TestOpenRelatedContextNodeNoTarget(t *testing.T) { + app := newTestApp(fakeSessions()) + m, cmd := app.openRelatedContextNode(session.ContextNode{}) + if m != app || cmd != nil { + t.Fatalf("expected no-op openRelatedContextNode") + } + if app.copiedMsg != "No related destination" { + t.Fatalf("unexpected message: %q", app.copiedMsg) + } +} + +func TestConvPageCopySelectedUsesRelatedPathForContexts(t *testing.T) { + app := newTestApp(fakeSessions()) + app.convPage = convPageContexts + app.convPageItems = []convPageItem{{ + Item: extract.Item{Label: "context", URL: "ignored", Category: "context"}, + timestamp: time.Time{}, + relatedPath: filepath.Clean("/tmp/context-file.md"), + relatedView: "config", + turnPreview: "preview", + }} + app.convPageCursor = 0 + _, _ = app.convPageCopySelected() + if app.copiedMsg != "Copied path" { + t.Fatalf("expected copy confirmation, got %q", app.copiedMsg) + } +} diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index 3844641..b63dc03 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -145,6 +145,16 @@ func (a *App) convPageOpenSelected() (tea.Model, tea.Cmd) { if item == nil { return a, nil } + if a.convPage == convPageContexts { + node := session.ContextNode{ + RelatedView: item.relatedView, + RelatedPath: item.relatedPath, + RelatedPluginID: item.relatedPluginID, + RelatedPluginComponentPath: item.relatedPluginComponentPath, + RelatedPluginComponentType: item.relatedPluginComponentType, + } + return a.openRelatedContextNode(node) + } switch a.convPage { case convPageImages: if item.imagePasteID > 0 { @@ -185,6 +195,9 @@ func (a *App) convPageCopySelected() (tea.Model, tea.Cmd) { return a, nil } target := item.URL + if a.convPage == convPageContexts && item.relatedPath != "" { + target = item.relatedPath + } if a.convPage == convPageFiles || a.convPage == convPageChanges || a.convPage == convPageImages { target = a.convPageItemResolvedTarget(*item) } @@ -909,11 +922,11 @@ func entryContentHash(blocks []session.ContentBlock) uint64 { // uniformly so there's no per-kind special casing downstream. // // - Header : optional descriptor (task subject, agent id, bg command). -// Prepended as a single text block in every mode so the user -// always sees the context. +// Prepended as a single text block in every mode so the user +// always sees the context. // - Sources : per-turn raw entries; compact and standard summarise each. // - Fallback : pre-flattened entry used for verbose mode (and as the -// cache-key carrier even when Sources is empty). +// cache-key carrier even when Sources is empty). type previewBuild struct { Header string Sources []session.Entry @@ -1074,12 +1087,20 @@ func renderPreviewHeader(entry session.Entry, textW int) string { } func makeConvPageItem(item extract.Item, ts time.Time, turnPreview, userPrompt string, imagePasteID int) convPageItem { + return makeConvPageItemWithTarget(item, ts, turnPreview, userPrompt, imagePasteID, "", "", "", "") +} + +func makeConvPageItemWithTarget(item extract.Item, ts time.Time, turnPreview, userPrompt string, imagePasteID int, relatedView, relatedPath, relatedPluginID, relatedPluginComponentPath string) convPageItem { return convPageItem{ - Item: item, - timestamp: ts, - turnPreview: turnPreview, - userPrompt: userPrompt, - imagePasteID: imagePasteID, + Item: item, + timestamp: ts, + turnPreview: turnPreview, + userPrompt: userPrompt, + imagePasteID: imagePasteID, + relatedView: relatedView, + relatedPath: relatedPath, + relatedPluginID: relatedPluginID, + relatedPluginComponentPath: relatedPluginComponentPath, } } @@ -1110,6 +1131,9 @@ func convPageItemContext(item convPageItem, width int) string { if item.userPrompt != "" { sections = append(sections, dimStyle.Render("Related user prompt")+"\n"+wrapText(item.userPrompt, width)) } + if item.relatedView != "" { + sections = append(sections, dimStyle.Render("Related view")+"\n"+wrapText(item.relatedView, width)) + } return strings.Join(sections, "\n\n") } @@ -1192,6 +1216,8 @@ func convPageTitle(kind convPageKind) string { return "Changes" case convPageFiles: return "Files" + case convPageContexts: + return "Contexts" default: return "Conversation" } @@ -1287,6 +1313,8 @@ func (a *App) renderConvPageBrowser() string { } } detail = wrapText(item.URL, pw) + case convPageContexts: + detail = wrapText(item.URL, pw) case convPageImages: id := strings.TrimPrefix(item.URL, "paste:") var pasteID int @@ -1430,6 +1458,46 @@ func (a *App) openConvFilesPage() (tea.Model, tea.Cmd) { return a, nil } +func flattenContextNodes(nodes []session.ContextNode, prefix string, items *[]convPageItem) { + for _, node := range nodes { + label := prefix + node.Label + if node.Count > 0 { + label += fmt.Sprintf(" [%d]", node.Count) + } + meta := []string{} + if node.Status != "" { + meta = append(meta, node.Status) + } + if node.Detail != "" { + meta = append(meta, node.Detail) + } + url := node.Path + if url == "" { + url = strings.Join(meta, "\n") + } + item := extract.Item{URL: url, Label: label, Category: "context"} + pageItem := makeConvPageItemWithTarget(item, time.Time{}, strings.Join(meta, "\n"), "", 0, node.RelatedView, node.RelatedPath, node.RelatedPluginID, node.RelatedPluginComponentPath) + pageItem.relatedPluginComponentType = node.RelatedPluginComponentType + *items = append(*items, pageItem) + if len(node.Children) > 0 { + flattenContextNodes(node.Children, prefix+" ", items) + } + } +} + +func (a *App) openConvContextsPage() (tea.Model, tea.Cmd) { + a.convPageActive = true + a.convPageFocus = false + a.convPageLastCursor = -1 + a.convPage = convPageContexts + a.convPageItems = nil + if tree, err := session.BuildSessionContextTree(a.config.ClaudeDir, a.currentSess); err == nil { + flattenContextNodes(tree.Roots, "", &a.convPageItems) + } + a.convPageCursor = 0 + return a, nil +} + func (a *App) openConvChangesPage() (tea.Model, tea.Cmd) { a.convPageActive = true a.convPageFocus = false diff --git a/internal/tui/help.go b/internal/tui/help.go index 6ea6bc1..6b02557 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -41,6 +41,9 @@ func (a *App) sessHelpLine() string { if a.tagMenu { return "" // Tag menu has its own help } + if a.sessPageMenu { + return formatHelp("p:page — pick a preview") + } if a.moveMode { return " " + a.moveInput.View() + helpStyle.Render(" enter:move esc:cancel") } diff --git a/internal/tui/interactions_test.go b/internal/tui/interactions_test.go index ade0709..aae1911 100644 --- a/internal/tui/interactions_test.go +++ b/internal/tui/interactions_test.go @@ -194,7 +194,7 @@ func TestHandleSessionPreviewActionsMenuCopyCopiesPreviewMessage(t *testing.T) { app.sessConvEntries = filterConversation(mergeConversationTurns(entries)) app.sessConvCursor = 0 app.keymap.Session.Actions = "x" - app.keymap.Actions.Copy = "c" + app.keymap.Actions.Copy = "z" m, _, _ := app.handleConvPreviewKeys(&app.sessSplit, "x") app = m.(*App) @@ -202,7 +202,7 @@ func TestHandleSessionPreviewActionsMenuCopyCopiesPreviewMessage(t *testing.T) { t.Fatal("expected session preview actions menu to open") } - m, _ = app.handleActionsMenu("c") + m, _ = app.handleActionsMenu("z") app = m.(*App) if app.actionsMenu { t.Fatal("expected actions menu to close after copy") @@ -233,8 +233,8 @@ func TestHandleSessionPreviewActionsMenuIgnoresExistingMultiSelection(t *testing if strings.Contains(hint, "selected") { t.Fatalf("expected preview actions menu, got bulk hint %q", hint) } - if !strings.Contains(hint, "copy-path") { - t.Fatalf("expected single-session action hint, got %q", hint) + if !strings.Contains(hint, "contexts") { + t.Fatalf("expected contexts action hint, got %q", hint) } } diff --git a/internal/tui/live_preview_test.go b/internal/tui/live_preview_test.go index 10cc96d..39ef251 100644 --- a/internal/tui/live_preview_test.go +++ b/internal/tui/live_preview_test.go @@ -52,7 +52,7 @@ func TestCyclePreviewModeSkipsLive(t *testing.T) { } // Verify we visit all other modes - for _, mode := range []sessPreview{sessPreviewConversation, sessPreviewStats, sessPreviewMemory, sessPreviewTasksPlan} { + for _, mode := range []sessPreview{sessPreviewConversation, sessPreviewStats, sessPreviewMemory, sessPreviewTasksPlan, sessPreviewAgents, sessPreviewShells, sessPreviewContexts} { if !visited[mode] { t.Errorf("cycleSessionPreviewMode should visit mode %d", mode) } @@ -360,6 +360,7 @@ func TestPreviewModeConstants(t *testing.T) { sessPreviewTasksPlan, sessPreviewAgents, sessPreviewShells, + sessPreviewContexts, sessPreviewLive, sessPreviewRemote, } diff --git a/internal/tui/plugins.go b/internal/tui/plugins.go index 30b55ca..bdbb4fc 100644 --- a/internal/tui/plugins.go +++ b/internal/tui/plugins.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/sendbird/ccx/internal/claudecmd" "github.com/sendbird/ccx/internal/session" "github.com/sendbird/ccx/internal/tmux" ) @@ -548,14 +549,14 @@ func renderPluginDetail(p session.Plugin, width int) string { } typeLabels := map[string]string{ - "agent": "Agents", - "skill": "Skills", - "command": "Commands", - "hook": "Hooks", - "mcp": "MCP Servers", - "lsp": "LSP Servers", - "script": "Scripts", - "setting": "Settings", + "agent": "Agents", + "skill": "Skills", + "command": "Commands", + "hook": "Hooks", + "mcp": "MCP Servers", + "lsp": "LSP Servers", + "script": "Scripts", + "setting": "Settings", "memory": "Memory", "reference": "References", } @@ -801,14 +802,14 @@ func (a *App) openPluginDetail(p session.Plugin) (tea.Model, tea.Cmd) { func buildComponentItems(p session.Plugin) []list.Item { typeLabels := map[string]string{ - "agent": "Agents", - "skill": "Skills", - "command": "Commands", - "hook": "Hooks", - "mcp": "MCP Servers", - "lsp": "LSP Servers", - "script": "Scripts", - "setting": "Settings", + "agent": "Agents", + "skill": "Skills", + "command": "Commands", + "hook": "Hooks", + "mcp": "MCP Servers", + "lsp": "LSP Servers", + "script": "Scripts", + "setting": "Settings", "memory": "Memory", "reference": "References", } @@ -1319,7 +1320,7 @@ func (a *App) plgSearchNext(dir int) { } start := a.plgList.Index() + dir for i := 0; i < n; i++ { - idx := ((start + i*dir) % n + n) % n + idx := ((start+i*dir)%n + n) % n pi, ok := items[idx].(plgItem) if !ok || pi.isHeader { continue @@ -1558,7 +1559,11 @@ func (a *App) runPluginCmd(action string) (tea.Model, tea.Cmd) { return a, func() tea.Msg { var lastErr error for _, id := range ids { - cmd := exec.Command("claude", "plugin", action, id) + cmd, err := claudecmd.Command(a.config.Claude, "", "plugin", action, id) + if err != nil { + lastErr = fmt.Errorf("%s %s: %w", action, id, err) + continue + } if err := cmd.Run(); err != nil { lastErr = fmt.Errorf("%s %s: %w", action, id, err) } @@ -1590,7 +1595,11 @@ func (a *App) runPluginInstall() (tea.Model, tea.Cmd) { return a, func() tea.Msg { var lastErr error for _, id := range ids { - cmd := exec.Command("claude", "plugin", "install", id) + cmd, err := claudecmd.Command(a.config.Claude, "", "plugin", "install", id) + if err != nil { + lastErr = fmt.Errorf("install %s: %w", id, err) + continue + } if err := cmd.Run(); err != nil { lastErr = fmt.Errorf("install %s: %w", id, err) } @@ -1645,7 +1654,11 @@ func (a *App) launchPluginTest() (tea.Model, tea.Cmd) { return a, nil } - script := env.Script() + script, err := env.ScriptWithConfig(a.config.Claude) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return a, nil + } a.copiedMsg = fmt.Sprintf("Testing %d plugins…", len(plugins)) return a, func() tea.Msg { diff --git a/internal/tui/sess_page_menu_test.go b/internal/tui/sess_page_menu_test.go new file mode 100644 index 0000000..761545f --- /dev/null +++ b/internal/tui/sess_page_menu_test.go @@ -0,0 +1,16 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestRenderSessPageHintBoxShowsContexts(t *testing.T) { + app := newTestApp(fakeSessions()) + hint := stripANSI(app.renderSessPageHintBox()) + for _, expected := range []string{"v:conv", "s:stats", "m:mem", "t:tasks", "a:agents", "l:live", "c:contexts"} { + if !strings.Contains(hint, expected) { + t.Fatalf("session page hint missing %q in %q", expected, hint) + } + } +} diff --git a/internal/tui/shortcuts.go b/internal/tui/shortcuts.go index 3aae108..934092e 100644 --- a/internal/tui/shortcuts.go +++ b/internal/tui/shortcuts.go @@ -25,6 +25,7 @@ func DefaultShortcuts() Shortcuts { "4": "preview:tasks", "5": "preview:agents", "6": "preview:live", + "7": "preview:contexts", }, }, "conversation": { @@ -98,8 +99,11 @@ func migrateShortcuts(sc Shortcuts) { if _, exists := sess.Left["6"]; !exists { sess.Left["6"] = "preview:live" } - sc["sessions"] = sess } + if _, exists := sess.Left["7"]; !exists { + sess.Left["7"] = "preview:contexts" + } + sc["sessions"] = sess } // handleShortcutKey checks if a key press matches a shortcut for the current diff --git a/internal/tui/shortcuts_test.go b/internal/tui/shortcuts_test.go index 7aaa1ce..82e3f40 100644 --- a/internal/tui/shortcuts_test.go +++ b/internal/tui/shortcuts_test.go @@ -21,6 +21,9 @@ func TestDefaultShortcuts(t *testing.T) { if sess.Left["6"] != "preview:live" { t.Errorf("sessions left 6 = %q, want preview:live", sess.Left["6"]) } + if sess.Left["7"] != "preview:contexts" { + t.Errorf("sessions left 7 = %q, want preview:contexts", sess.Left["7"]) + } // Conversation view conv, ok := sc["conversation"] @@ -120,6 +123,9 @@ func TestShortcutHint(t *testing.T) { if !containsSubstring(hint, "6:live") { t.Errorf("hint %q should contain 6:live", hint) } + if !containsSubstring(hint, "7:contexts") { + t.Errorf("hint %q should contain 7:contexts", hint) + } } func TestShortcutHintEmpty(t *testing.T) { diff --git a/internal/tui/state.go b/internal/tui/state.go index 6e1e753..c02bd75 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/claudecmd" "github.com/sendbird/ccx/internal/remote" "gopkg.in/yaml.v3" ) @@ -13,7 +14,7 @@ import ( // Preferences holds persisted view preferences that survive restarts. type Preferences struct { GroupMode string `yaml:"group_mode,omitempty"` // flat|proj|tree|chain|fork - PreviewMode string `yaml:"preview_mode,omitempty"` // conv|stats|mem|tasks|live + PreviewMode string `yaml:"preview_mode,omitempty"` // conv|stats|mem|tasks|agents|shells|contexts|live ViewMode string `yaml:"view_mode,omitempty"` // sessions|config|plugins|stats ConvDetailLevel int `yaml:"conv_detail_level,omitempty"` // 0=compact,1=standard,2=verbose SplitRatio int `yaml:"split_ratio,omitempty"` // 15-85 @@ -34,10 +35,11 @@ type KeymapsConfig struct { // CCXConfig is the unified config file containing keybindings + preferences. // Stored at ~/.config/ccx/config.yaml. type CCXConfig struct { - Keymaps KeymapsConfig `yaml:"keymaps,omitempty"` - Preferences Preferences `yaml:"preferences,omitempty"` - Shortcuts Shortcuts `yaml:"shortcuts,omitempty"` - Remote remote.Config `yaml:"remote,omitempty"` + Keymaps KeymapsConfig `yaml:"keymaps,omitempty"` + Preferences Preferences `yaml:"preferences,omitempty"` + Shortcuts Shortcuts `yaml:"shortcuts,omitempty"` + Remote remote.Config `yaml:"remote,omitempty"` + Claude claudecmd.Config `yaml:"claude,omitempty"` } // configPath returns the path to the unified config file. @@ -47,21 +49,22 @@ func configPath() string { } // LoadCCXConfig reads the unified config file. -// Returns keymap, preferences, and shortcuts separately. -func LoadCCXConfig(path string) (*Keymap, Preferences, Shortcuts, remote.Config) { +// Returns keymap, preferences, shortcuts, remote config, and Claude command config. +func LoadCCXConfig(path string) (*Keymap, Preferences, Shortcuts, remote.Config, claudecmd.Config) { km := DefaultKeymap() var prefs Preferences sc := DefaultShortcuts() var rc remote.Config + var cc claudecmd.Config data, err := os.ReadFile(path) if err != nil { - return &km, prefs, sc, rc + return &km, prefs, sc, rc, cc } var cfg CCXConfig if err := yaml.Unmarshal(data, &cfg); err != nil { - return &km, prefs, sc, rc + return &km, prefs, sc, rc, cc } // Merge keymap overrides from keymaps section @@ -77,7 +80,7 @@ func LoadCCXConfig(path string) (*Keymap, Preferences, Shortcuts, remote.Config) mergeShortcuts(sc, cfg.Shortcuts) cfg.Shortcuts = sc - return &km, cfg.Preferences, sc, cfg.Remote + return &km, cfg.Preferences, sc, cfg.Remote, cfg.Claude } // SavePreferences updates the preferences section in the config file, @@ -103,7 +106,7 @@ func SavePreferences(prefs Preferences) { return } - header := "# ccx configuration\n# Keybindings: session, actions, views, navigation\n# Preferences: preferences section (auto-saved on quit)\n\n" + header := "# ccx configuration\n# Keybindings: session, actions, views, navigation\n# Preferences: preferences section (auto-saved on quit)\n# Claude: command_template controls local Claude launches; {{args}} expands to ccx-provided args.\n\n" os.WriteFile(path, []byte(header+string(data)), 0644) } @@ -265,6 +268,10 @@ func sessPreviewString(mode sessPreview) string { return "tasks" case sessPreviewAgents: return "agents" + case sessPreviewShells: + return "shells" + case sessPreviewContexts: + return "contexts" case sessPreviewLive: return "live" } diff --git a/internal/tui/state_config_test.go b/internal/tui/state_config_test.go new file mode 100644 index 0000000..6abc914 --- /dev/null +++ b/internal/tui/state_config_test.go @@ -0,0 +1,20 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadCCXConfigLoadsClaudeCommandTemplate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte("claude:\n command_template: \"ccproxy -- claude {{args}}\"\n"), 0o644); err != nil { + t.Fatal(err) + } + + _, _, _, _, cc := LoadCCXConfig(path) + if cc.CommandTemplate != "ccproxy -- claude {{args}}" { + t.Fatalf("CommandTemplate = %q", cc.CommandTemplate) + } +} diff --git a/main.go b/main.go index 30c3bb9..36cba75 100644 --- a/main.go +++ b/main.go @@ -115,7 +115,7 @@ func main() { } configPath := filepath.Join(os.Getenv("HOME"), ".config", "ccx", "config.yaml") - km, _, _, _ := tui.LoadCCXConfig(configPath) + km, _, _, _, cc := tui.LoadCCXConfig(configPath) initialSessions := session.LoadCachedSessions(claudeDir) if len(initialSessions) == 0 { @@ -155,6 +155,7 @@ func main() { ViewMode: viewMode, JumpSession: jumpSession, JumpUUID: jumpUUID, + Claude: cc, }) p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil {