theniceboy/agent-tracker/cmd/agent/todos.go
2026-04-17 11:18:20 -07:00

445 lines
12 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type todoScope int
const (
todoScopeGlobal todoScope = iota
todoScopeSession
todoScopeWindow
)
const tmuxTodoStoreVersion = 1
type tmuxTodoItem struct {
Title string `json:"title" yaml:"title"`
Done bool `json:"done" yaml:"done"`
Priority int `json:"priority,omitempty" yaml:"priority,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" yaml:"created_at,omitempty"`
}
type tmuxTodoStore struct {
Version int `json:"version"`
Global []tmuxTodoItem `json:"global,omitempty"`
Sessions map[string][]tmuxTodoItem `json:"sessions,omitempty"`
Windows map[string][]tmuxTodoItem `json:"windows,omitempty"`
}
type tmuxTodoListFile struct {
Todos []tmuxTodoItem `json:"todos" yaml:"todos"`
}
type tmuxTodoEntry struct {
Title string
Done bool
Priority int
Scope todoScope
ScopeID string
ScopeName string
IsCurrent bool
ItemIndex int
PanelPane todoPanelPane
Detail string
}
func tmuxTodoStorePath() string {
return filepath.Join(os.Getenv("HOME"), ".cache", "agent", "todos.json")
}
func legacyTmuxTodosDir() string {
return filepath.Join(os.Getenv("HOME"), ".tmux-todos")
}
func normalizeTodoPriority(priority int) int {
if priority < 1 || priority > 3 {
return 2
}
return priority
}
func normalizeTmuxTodoStore(store *tmuxTodoStore) *tmuxTodoStore {
if store == nil {
store = &tmuxTodoStore{}
}
if store.Version == 0 {
store.Version = tmuxTodoStoreVersion
}
if store.Global == nil {
store.Global = []tmuxTodoItem{}
}
if store.Sessions == nil {
store.Sessions = map[string][]tmuxTodoItem{}
}
if store.Windows == nil {
store.Windows = map[string][]tmuxTodoItem{}
}
for i := range store.Global {
store.Global[i].Priority = normalizeTodoPriority(store.Global[i].Priority)
}
for key := range store.Sessions {
for i := range store.Sessions[key] {
store.Sessions[key][i].Priority = normalizeTodoPriority(store.Sessions[key][i].Priority)
}
}
for key := range store.Windows {
for i := range store.Windows[key] {
store.Windows[key][i].Priority = normalizeTodoPriority(store.Windows[key][i].Priority)
}
}
return store
}
func saveTmuxTodoStore(store *tmuxTodoStore) error {
store = normalizeTmuxTodoStore(store)
path := tmuxTodoStorePath()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(store, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
func loadTmuxTodoStore() (*tmuxTodoStore, error) {
path := tmuxTodoStorePath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return bootstrapTmuxTodoStore()
}
return nil, err
}
var store tmuxTodoStore
if err := json.Unmarshal(data, &store); err != nil {
return nil, err
}
return normalizeTmuxTodoStore(&store), nil
}
func bootstrapTmuxTodoStore() (*tmuxTodoStore, error) {
store := normalizeTmuxTodoStore(&tmuxTodoStore{Version: tmuxTodoStoreVersion})
if err := importLegacyYamlTodos(store); err != nil {
return nil, err
}
if err := saveTmuxTodoStore(store); err != nil {
return nil, err
}
return store, nil
}
func todoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string) []tmuxTodoItem {
store = normalizeTmuxTodoStore(store)
switch scope {
case todoScopeSession:
return store.Sessions[scopeID]
case todoScopeWindow:
return store.Windows[scopeID]
default:
return store.Global
}
}
func setTodoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string, items []tmuxTodoItem) {
store = normalizeTmuxTodoStore(store)
switch scope {
case todoScopeSession:
store.Sessions[scopeID] = items
case todoScopeWindow:
store.Windows[scopeID] = items
default:
store.Global = items
}
}
func appendUniqueTodo(store *tmuxTodoStore, scope todoScope, scopeID string, item tmuxTodoItem) bool {
item.Title = strings.TrimSpace(item.Title)
if item.Title == "" {
return false
}
item.Priority = normalizeTodoPriority(item.Priority)
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
for _, existing := range items {
if strings.TrimSpace(existing.Title) == item.Title {
return false
}
}
items = append(items, item)
setTodoItemsForScope(store, scope, scopeID, items)
return true
}
func importLegacyYamlTodos(store *tmuxTodoStore) error {
entries, err := os.ReadDir(legacyTmuxTodosDir())
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
path := filepath.Join(legacyTmuxTodosDir(), name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
var list tmuxTodoListFile
if err := yaml.Unmarshal(data, &list); err != nil {
continue
}
scope := todoScopeGlobal
scopeID := "global"
switch {
case name == "global.yaml":
scope = todoScopeGlobal
case strings.HasPrefix(name, "session_") && strings.HasSuffix(name, ".yaml"):
scope = todoScopeSession
id := strings.TrimSuffix(strings.TrimPrefix(name, "session_"), ".yaml")
if strings.HasPrefix(id, "_") {
scopeID = "$" + strings.TrimPrefix(id, "_")
} else {
scopeID = id
}
case strings.HasPrefix(name, "window_") && strings.HasSuffix(name, ".yaml"):
scope = todoScopeWindow
id := strings.TrimSuffix(strings.TrimPrefix(name, "window_"), ".yaml")
if strings.HasPrefix(id, "_") {
scopeID = "@" + strings.TrimPrefix(id, "_")
} else {
scopeID = id
}
default:
continue
}
for _, item := range list.Todos {
appendUniqueTodo(store, scope, scopeID, item)
}
}
return nil
}
func collectAllTmuxTodos(currentSessionID, currentWindowID string) []tmuxTodoEntry {
store, err := loadTmuxTodoStore()
if err != nil {
return nil
}
entries := make([]tmuxTodoEntry, 0, len(store.Global))
for idx, item := range store.Global {
entries = append(entries, tmuxTodoEntry{
Title: item.Title,
Done: item.Done,
Priority: item.Priority,
Scope: todoScopeGlobal,
ScopeID: "global",
ScopeName: "Global",
IsCurrent: true,
ItemIndex: idx,
})
}
sessionIDs := make([]string, 0, len(store.Sessions))
for id := range store.Sessions {
sessionIDs = append(sessionIDs, id)
}
sort.Strings(sessionIDs)
for _, id := range sessionIDs {
for idx, item := range store.Sessions[id] {
entries = append(entries, tmuxTodoEntry{
Title: item.Title,
Done: item.Done,
Priority: item.Priority,
Scope: todoScopeSession,
ScopeID: id,
ScopeName: "Session",
IsCurrent: strings.TrimSpace(id) == strings.TrimSpace(currentSessionID),
ItemIndex: idx,
})
}
}
windowIDs := make([]string, 0, len(store.Windows))
for id := range store.Windows {
windowIDs = append(windowIDs, id)
}
sort.Strings(windowIDs)
for _, id := range windowIDs {
for idx, item := range store.Windows[id] {
entries = append(entries, tmuxTodoEntry{
Title: item.Title,
Done: item.Done,
Priority: item.Priority,
Scope: todoScopeWindow,
ScopeID: id,
ScopeName: "Window",
IsCurrent: strings.TrimSpace(id) == strings.TrimSpace(currentWindowID),
ItemIndex: idx,
})
}
}
return entries
}
func sortTmuxTodosByScope(entries []tmuxTodoEntry, scopePriority todoScope) {
sort.SliceStable(entries, func(i, j int) bool {
ei, ej := entries[i], entries[j]
if ei.IsCurrent != ej.IsCurrent {
return ei.IsCurrent
}
if ei.Scope != ej.Scope {
if ei.Scope == scopePriority {
return true
}
if ej.Scope == scopePriority {
return false
}
return ei.Scope < ej.Scope
}
return false
})
}
func addTmuxTodo(scope todoScope, scopeID, title string) error {
store, err := loadTmuxTodoStore()
if err != nil {
return err
}
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
items = append(items, tmuxTodoItem{Title: strings.TrimSpace(title), Done: false, Priority: 2, CreatedAt: time.Now()})
setTodoItemsForScope(store, scope, scopeID, items)
return saveTmuxTodoStore(store)
}
func setTmuxTodoPriorityByIndex(scope todoScope, scopeID string, index int, priority int) error {
store, err := loadTmuxTodoStore()
if err != nil {
return err
}
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
if index < 0 || index >= len(items) {
return fmt.Errorf("index out of range")
}
items[index].Priority = normalizeTodoPriority(priority)
setTodoItemsForScope(store, scope, scopeID, items)
return saveTmuxTodoStore(store)
}
func updateTmuxTodoTitleByIndex(scope todoScope, scopeID string, index int, title string) error {
store, err := loadTmuxTodoStore()
if err != nil {
return err
}
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
if index < 0 || index >= len(items) {
return fmt.Errorf("index out of range")
}
title = strings.TrimSpace(title)
if title == "" {
return fmt.Errorf("todo title is required")
}
items[index].Title = title
setTodoItemsForScope(store, scope, scopeID, items)
return saveTmuxTodoStore(store)
}
func moveTmuxTodoByIndex(scope todoScope, scopeID string, fromIndex, toIndex int) error {
store, err := loadTmuxTodoStore()
if err != nil {
return err
}
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
if fromIndex < 0 || fromIndex >= len(items) || toIndex < 0 || toIndex >= len(items) {
return fmt.Errorf("index out of range")
}
if fromIndex == toIndex {
return nil
}
item := items[fromIndex]
if fromIndex < toIndex {
copy(items[fromIndex:toIndex], items[fromIndex+1:toIndex+1])
} else {
copy(items[toIndex+1:fromIndex+1], items[toIndex:fromIndex])
}
items[toIndex] = item
setTodoItemsForScope(store, scope, scopeID, items)
return saveTmuxTodoStore(store)
}
func moveTmuxTodoToScopeByIndex(scope todoScope, scopeID string, index int, targetScope todoScope, targetScopeID string) error {
store, err := loadTmuxTodoStore()
if err != nil {
return err
}
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
if index < 0 || index >= len(items) {
return fmt.Errorf("index out of range")
}
item := items[index]
items = append(items[:index], items[index+1:]...)
targetItems := append([]tmuxTodoItem(nil), todoItemsForScope(store, targetScope, targetScopeID)...)
targetItems = append(targetItems, item)
setTodoItemsForScope(store, scope, scopeID, items)
setTodoItemsForScope(store, targetScope, targetScopeID, targetItems)
return saveTmuxTodoStore(store)
}
func countOpenTmuxTodos(scope todoScope, scopeID string) (int, error) {
store, err := loadTmuxTodoStore()
if err != nil {
return 0, err
}
count := 0
for _, item := range todoItemsForScope(store, scope, scopeID) {
if !item.Done {
count++
}
}
return count, nil
}
func getCurrentTmuxScopeInfo() (sessionID, windowID string) {
sessionID, _ = runTmuxOutput("display-message", "-p", "#{session_id}")
windowID, _ = runTmuxOutput("display-message", "-p", "#{window_id}")
return strings.TrimSpace(sessionID), strings.TrimSpace(windowID)
}
func toggleTmuxTodoByIndex(scope todoScope, scopeID string, index int) error {
store, err := loadTmuxTodoStore()
if err != nil {
return err
}
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
if index < 0 || index >= len(items) {
return fmt.Errorf("index out of range")
}
items[index].Done = !items[index].Done
setTodoItemsForScope(store, scope, scopeID, items)
return saveTmuxTodoStore(store)
}
func deleteTmuxTodoByIndex(scope todoScope, scopeID string, index int) error {
store, err := loadTmuxTodoStore()
if err != nil {
return err
}
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
if index < 0 || index >= len(items) {
return fmt.Errorf("index out of range")
}
items = append(items[:index], items[index+1:]...)
setTodoItemsForScope(store, scope, scopeID, items)
return saveTmuxTodoStore(store)
}