theniceboy/agent-tracker/cmd/agent/palette.go
2026-04-02 14:20:00 -07:00

259 lines
5.9 KiB
Go

package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
type paletteMode int
const (
paletteModeList paletteMode = iota
paletteModePrompt
paletteModeConfirmDestroy
paletteModeSnippets
paletteModeSnippetVars
paletteModeTodos
paletteModeActivity
paletteModeDevices
paletteModeStatusRight
paletteModeTracker
)
type palettePromptField int
const (
palettePromptFieldName palettePromptField = iota
palettePromptFieldDevice
palettePromptFieldWorktree
)
type palettePromptKind int
const (
palettePromptStartAgent palettePromptKind = iota
palettePromptSnippetVar
)
type paletteActionKind int
const (
paletteActionPromptStartAgent paletteActionKind = iota
paletteActionOpenActivityMonitor
paletteActionConfirmDestroy
paletteActionReloadTmuxConfig
paletteActionOpenStatusRight
paletteActionOpenSnippets
paletteActionOpenTodos
paletteActionOpenDevices
paletteActionOpenTracker
)
type paletteAction struct {
Section string
Title string
Subtitle string
Keywords []string
Kind paletteActionKind
RepoRoot string
}
type paletteResultKind int
const (
paletteResultClose paletteResultKind = iota
paletteResultRunAction
paletteResultOpenActivityMonitor
paletteResultOpenSnippets
paletteResultOpenTodos
)
type paletteResult struct {
Kind paletteResultKind
Action paletteAction
Input string
Device string
KeepWorktree bool
State paletteUIState
}
type paletteUIState struct {
Filter []rune
FilterCursor int
Selected int
ActionOffset int
SnippetOffset int
Mode paletteMode
PromptText []rune
PromptCursor int
PromptKind palettePromptKind
PromptField palettePromptField
PromptRepoRoot string
PromptDevices []string
PromptDeviceIndex int
PromptKeepWorktree bool
ShowAltHints bool
Message string
ConfirmRequiresText bool
SnippetName string
SnippetContent string
SnippetVars []string
SnippetVarIndex int
SnippetVarValues map[string]string
SnippetVarPrompts []string
SnippetVarPromptIdx int
}
type snippet struct {
Name string
Description string
Content string
Vars []string
}
var snippetVarRegex = regexp.MustCompile(`\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}`)
func extractSnippetVars(content string) []string {
seen := make(map[string]bool)
var vars []string
for _, match := range snippetVarRegex.FindAllStringSubmatch(content, -1) {
if len(match) > 1 && !seen[match[1]] {
seen[match[1]] = true
vars = append(vars, match[1])
}
}
return vars
}
func renderSnippet(content string, values map[string]string) string {
result := content
for name, value := range values {
result = strings.ReplaceAll(result, "{{"+name+"}}", value)
}
return result
}
func loadSnippets() []snippet {
snippetsDir := filepath.Join(os.Getenv("HOME"), ".config", "snippets")
entries, err := os.ReadDir(snippetsDir)
if err != nil {
return nil
}
var snippets []snippet
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
continue
}
path := filepath.Join(snippetsDir, name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
content := string(data)
lines := strings.SplitN(content, "\n", 2)
description := ""
body := content
if len(lines) > 0 && strings.HasPrefix(lines[0], "#") {
description = strings.TrimSpace(strings.TrimPrefix(lines[0], "#"))
if len(lines) > 1 {
body = strings.TrimPrefix(content, lines[0]+"\n")
} else {
body = ""
}
}
snippets = append(snippets, snippet{
Name: name,
Description: description,
Content: strings.TrimRight(body, "\n"),
Vars: extractSnippetVars(body),
})
}
return snippets
}
func pasteToTmuxPane(text string) error {
return runTmux("send-keys", "-l", text)
}
func detectPaletteMainRepoRoot(currentPath string, record *agentRecord) string {
if record != nil && strings.TrimSpace(record.RepoRoot) != "" {
return strings.TrimSpace(record.RepoRoot)
}
currentPath = strings.TrimSpace(currentPath)
if currentPath == "" {
return ""
}
clean := filepath.Clean(currentPath)
needle := string(filepath.Separator) + ".agents" + string(filepath.Separator)
if idx := strings.Index(clean, needle); idx >= 0 {
return clean[:idx]
}
if fileExists(filepath.Join(clean, ".agent.yaml")) {
return clean
}
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = clean
out, err := cmd.Output()
if err != nil {
return ""
}
repoRoot := strings.TrimSpace(string(out))
if repoRoot == "" {
return ""
}
if fileExists(filepath.Join(repoRoot, ".agent.yaml")) {
return repoRoot
}
if idx := strings.Index(repoRoot, needle); idx >= 0 {
return repoRoot[:idx]
}
return ""
}
func detectPaletteAgentIDFromPath(currentPath string) string {
clean := filepath.Clean(strings.TrimSpace(currentPath))
if clean == "" {
return ""
}
needle := string(filepath.Separator) + ".agents" + string(filepath.Separator)
idx := strings.Index(clean, needle)
if idx < 0 {
return ""
}
rest := clean[idx+len(needle):]
if rest == "" {
return ""
}
parts := strings.Split(rest, string(filepath.Separator))
if len(parts) == 0 {
return ""
}
return sanitizeFeatureName(parts[0])
}
func looksLikeTmuxFormatLiteral(value string) bool {
value = strings.TrimSpace(value)
return strings.Contains(value, "#{") && strings.Contains(value, "}")
}
func startAgentSubtitle(mainRepoRoot, currentPath string) string {
if strings.TrimSpace(mainRepoRoot) == "" {
return "No agent-enabled repo detected for this pane"
}
if filepath.Clean(strings.TrimSpace(mainRepoRoot)) == filepath.Clean(strings.TrimSpace(currentPath)) {
return "Open a small prompt here to start a new agent"
}
return fmt.Sprintf("Open a small prompt in %s", mainRepoRoot)
}
func runPalette(args []string) error {
return runBubbleTeaPalette(args)
}