mirror of
https://github.com/theniceboy/.config.git
synced 2026-07-16 22:01:21 +08:00
agent tracker update
This commit is contained in:
parent
816bf84824
commit
f667ba7225
39 changed files with 8408 additions and 104 deletions
468
agent-tracker/cmd/agent/goal_cli.go
Normal file
468
agent-tracker/cmd/agent/goal_cli.go
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runGoal(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent goal <add-goal|delete-goal|add-thread|rename|rename-current|set-goal|set-blocker|done|delete-thread|bind|unbind|merge|promote|revive|cleanup-window|current-thread|list>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "add-goal":
|
||||
return runGoalAddGoal(args[1:])
|
||||
case "delete-goal":
|
||||
return runGoalDeleteGoal(args[1:])
|
||||
case "add-thread":
|
||||
return runGoalAddThread(args[1:])
|
||||
case "rename":
|
||||
return runGoalRename(args[1:])
|
||||
case "rename-current":
|
||||
return runGoalRenameCurrent(args[1:])
|
||||
case "set-goal":
|
||||
return runGoalSetGoal(args[1:])
|
||||
case "set-blocker":
|
||||
return runGoalSetBlocker(args[1:])
|
||||
case "done":
|
||||
return runGoalDone(args[1:])
|
||||
case "delete-thread":
|
||||
return runGoalDeleteThread(args[1:])
|
||||
case "bind":
|
||||
return runGoalBind(args[1:])
|
||||
case "unbind":
|
||||
return runGoalUnbind(args[1:])
|
||||
case "merge":
|
||||
return runGoalMerge(args[1:])
|
||||
case "promote":
|
||||
return runGoalPromote(args[1:])
|
||||
case "revive":
|
||||
return runGoalRevive(args[1:])
|
||||
case "cleanup-window":
|
||||
return runGoalCleanupWindow(args[1:])
|
||||
case "current-thread":
|
||||
return runGoalCurrentThread(args[1:])
|
||||
case "set-goal-current":
|
||||
return runGoalSetGoalCurrent(args[1:])
|
||||
case "set-blocker-current":
|
||||
return runGoalSetBlockerCurrent(args[1:])
|
||||
case "pick":
|
||||
return runGoalPick(args[1:])
|
||||
case "reap":
|
||||
reapOrphanedThreads()
|
||||
return nil
|
||||
case "list":
|
||||
return runGoalList(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown goal subcommand: %s", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runGoalAddGoal(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal add-goal", flag.ExitOnError)
|
||||
var title, parent string
|
||||
fs.StringVar(&title, "title", "", "goal title")
|
||||
fs.StringVar(&parent, "parent", "", "parent goal id")
|
||||
fs.Parse(args)
|
||||
g, err := addGoal(title, parent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(g.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGoalDeleteGoal(args []string) error {
|
||||
confirm := ""
|
||||
var goalID string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--confirm" {
|
||||
if i+1 < len(args) {
|
||||
confirm = args[i+1]
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(a, "--confirm=") {
|
||||
confirm = strings.TrimPrefix(a, "--confirm=")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(a, "-") {
|
||||
continue
|
||||
}
|
||||
if goalID == "" {
|
||||
goalID = a
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(confirm) != "yes" {
|
||||
return fmt.Errorf("pass --confirm yes to delete a goal (cascades)")
|
||||
}
|
||||
if goalID == "" {
|
||||
return fmt.Errorf("goal id required")
|
||||
}
|
||||
return deleteGoalCascade(goalID)
|
||||
}
|
||||
|
||||
func runGoalAddThread(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal add-thread", flag.ExitOnError)
|
||||
var name, goal string
|
||||
var blocks stringSliceFlag
|
||||
fs.StringVar(&name, "name", "", "thread name")
|
||||
fs.StringVar(&goal, "goal", "", "goal id")
|
||||
fs.Var(&blocks, "blocks", "thread id this depends on (repeatable)")
|
||||
fs.Parse(args)
|
||||
t, err := addManualThread(name, goal, blocks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(t.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGoalRename(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal rename", flag.ExitOnError)
|
||||
var thread, name string
|
||||
fs.StringVar(&thread, "thread", "", "thread id")
|
||||
fs.StringVar(&name, "name", "", "new name")
|
||||
fs.Parse(args)
|
||||
if strings.TrimSpace(thread) == "" {
|
||||
return fmt.Errorf("--thread is required")
|
||||
}
|
||||
return renameThread(thread, name)
|
||||
}
|
||||
|
||||
func runGoalRenameCurrent(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal rename-current", flag.ExitOnError)
|
||||
var window, name string
|
||||
fs.StringVar(&window, "window", "", "tmux window id (defaults to focused)")
|
||||
fs.StringVar(&name, "name", "", "new name")
|
||||
fs.Parse(args)
|
||||
window = resolveGoalWindowID(window)
|
||||
threadID, err := ensureThreadForWindow(window, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renameThread(threadID, name)
|
||||
}
|
||||
|
||||
func runGoalSetGoal(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal set-goal", flag.ExitOnError)
|
||||
var thread, goal string
|
||||
fs.StringVar(&thread, "thread", "", "thread id")
|
||||
fs.StringVar(&goal, "goal", "", "goal id (empty to unassign)")
|
||||
fs.Parse(args)
|
||||
if strings.TrimSpace(thread) == "" {
|
||||
return fmt.Errorf("--thread is required")
|
||||
}
|
||||
return setThreadGoal(thread, goal)
|
||||
}
|
||||
|
||||
func runGoalSetBlocker(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal set-blocker", flag.ExitOnError)
|
||||
var thread string
|
||||
var by stringSliceFlag
|
||||
fs.StringVar(&thread, "thread", "", "thread id")
|
||||
fs.Var(&by, "by", "thread id that blocks this one (repeatable)")
|
||||
fs.Parse(args)
|
||||
if strings.TrimSpace(thread) == "" {
|
||||
return fmt.Errorf("--thread is required")
|
||||
}
|
||||
return setThreadBlocker(thread, by)
|
||||
}
|
||||
|
||||
func runGoalDone(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal done", flag.ExitOnError)
|
||||
var thread string
|
||||
var undo bool
|
||||
fs.StringVar(&thread, "thread", "", "thread id")
|
||||
fs.BoolVar(&undo, "undo", false, "mark not-done")
|
||||
fs.Parse(args)
|
||||
if strings.TrimSpace(thread) == "" {
|
||||
return fmt.Errorf("--thread is required")
|
||||
}
|
||||
return setThreadDone(thread, !undo)
|
||||
}
|
||||
|
||||
func runGoalDeleteThread(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal delete-thread", flag.ExitOnError)
|
||||
fs.Parse(args)
|
||||
if fs.NArg() < 1 {
|
||||
return fmt.Errorf("thread id required")
|
||||
}
|
||||
return deleteThread(fs.Arg(0))
|
||||
}
|
||||
|
||||
func runGoalBind(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal bind", flag.ExitOnError)
|
||||
var thread, window string
|
||||
fs.StringVar(&thread, "thread", "", "thread id")
|
||||
fs.StringVar(&window, "window", "", "tmux window id")
|
||||
fs.Parse(args)
|
||||
if strings.TrimSpace(thread) == "" || strings.TrimSpace(window) == "" {
|
||||
return fmt.Errorf("--thread and --window are required")
|
||||
}
|
||||
return bindThreadWindow(thread, window)
|
||||
}
|
||||
|
||||
func runGoalUnbind(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal unbind", flag.ExitOnError)
|
||||
var thread string
|
||||
fs.StringVar(&thread, "thread", "", "thread id")
|
||||
fs.Parse(args)
|
||||
if strings.TrimSpace(thread) == "" {
|
||||
return fmt.Errorf("--thread is required")
|
||||
}
|
||||
return unbindThreadWindow(thread)
|
||||
}
|
||||
|
||||
func runGoalMerge(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal merge", flag.ExitOnError)
|
||||
var source, target string
|
||||
fs.StringVar(&source, "source", "", "thread to absorb and delete")
|
||||
fs.StringVar(&target, "target", "", "thread that survives")
|
||||
fs.Parse(args)
|
||||
if strings.TrimSpace(source) == "" || strings.TrimSpace(target) == "" {
|
||||
return fmt.Errorf("--source and --target are required")
|
||||
}
|
||||
return mergeThreads(source, target)
|
||||
}
|
||||
|
||||
func runGoalPromote(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal promote", flag.ExitOnError)
|
||||
var window, name, goal, bind string
|
||||
fs.StringVar(&window, "window", "", "tmux window id")
|
||||
fs.StringVar(&name, "name", "", "thread name")
|
||||
fs.StringVar(&goal, "goal", "", "goal id to attach to")
|
||||
fs.StringVar(&bind, "bind", "", "existing thread id to bind the window to")
|
||||
fs.Parse(args)
|
||||
window = resolveGoalWindowID(window)
|
||||
if strings.TrimSpace(window) == "" {
|
||||
return fmt.Errorf("--window is required (or run inside tmux)")
|
||||
}
|
||||
t, err := promoteDraftToThread(window, name, goal, bind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(t.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGoalRevive(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal revive", flag.ExitOnError)
|
||||
var window, name string
|
||||
fs.StringVar(&window, "window", "", "tmux window id")
|
||||
fs.StringVar(&name, "name", "", "new thread name")
|
||||
fs.Parse(args)
|
||||
window = resolveGoalWindowID(window)
|
||||
if strings.TrimSpace(window) == "" {
|
||||
return fmt.Errorf("--window is required (or run inside tmux)")
|
||||
}
|
||||
return reviveDoneThreadForWindow(window, name)
|
||||
}
|
||||
|
||||
// runGoalCleanupWindow deletes the thread (and its window todos) for a closed window.
|
||||
func runGoalCleanupWindow(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal cleanup-window", flag.ExitOnError)
|
||||
var window string
|
||||
fs.StringVar(&window, "window", "", "tmux window id")
|
||||
fs.Parse(args)
|
||||
window = strings.TrimSpace(window)
|
||||
if window == "" {
|
||||
return nil
|
||||
}
|
||||
store, err := loadGoalStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := findThreadByWindow(store, window)
|
||||
if t != nil {
|
||||
_ = deleteThread(t.ID)
|
||||
}
|
||||
removeWindowTodos(window)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGoalCurrentThread(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal current-thread", flag.ExitOnError)
|
||||
var window string
|
||||
fs.StringVar(&window, "window", "", "tmux window id (defaults to focused)")
|
||||
fs.Parse(args)
|
||||
window = resolveGoalWindowID(window)
|
||||
store, err := loadGoalStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := findThreadByWindow(store, window)
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
fmt.Println(t.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGoalSetGoalCurrent(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal set-goal-current", flag.ExitOnError)
|
||||
var window, goal, name string
|
||||
fs.StringVar(&window, "window", "", "tmux window id (defaults to focused)")
|
||||
fs.StringVar(&goal, "goal", "", "goal id (empty to unassign)")
|
||||
fs.StringVar(&name, "name", "", "thread name (creates the thread if needed)")
|
||||
fs.Parse(args)
|
||||
window = resolveGoalWindowID(window)
|
||||
if window == "" {
|
||||
return fmt.Errorf("no current tmux window")
|
||||
}
|
||||
threadID, err := ensureThreadForWindow(window, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return setThreadGoal(threadID, goal)
|
||||
}
|
||||
|
||||
func runGoalSetBlockerCurrent(args []string) error {
|
||||
fs := flag.NewFlagSet("agent goal set-blocker-current", flag.ExitOnError)
|
||||
var window, by string
|
||||
fs.StringVar(&window, "window", "", "tmux window id (defaults to focused)")
|
||||
fs.StringVar(&by, "by", "", "thread id that blocks the current thread")
|
||||
fs.Parse(args)
|
||||
window = resolveGoalWindowID(window)
|
||||
if window == "" {
|
||||
return fmt.Errorf("no current tmux window")
|
||||
}
|
||||
threadID, err := ensureThreadForWindow(window, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var blockers []string
|
||||
if strings.TrimSpace(by) != "" {
|
||||
blockers = []string{strings.TrimSpace(by)}
|
||||
}
|
||||
return setThreadBlocker(threadID, blockers)
|
||||
}
|
||||
|
||||
// runGoalPick prints pickable items for fzf-based prefix dialogs.
|
||||
// `agent goal pick goals` and `agent goal pick threads`.
|
||||
func runGoalPick(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent goal pick <goals|threads>")
|
||||
}
|
||||
store, err := loadGoalStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] {
|
||||
case "goals":
|
||||
fmt.Println("__new__\t— create new goal —")
|
||||
appendGoalPickLines(store, "", 0)
|
||||
case "threads":
|
||||
for _, id := range sortedThreadIDs(store) {
|
||||
t := store.Threads[id]
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
path := strings.Join(goalPathTitles(store, t.GoalID), " › ")
|
||||
label := threadDisplayName(t, "")
|
||||
if path != "" {
|
||||
label = path + " › " + label
|
||||
}
|
||||
fmt.Printf("%s\t%s\n", t.ID, label)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown pick kind: %s", args[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendGoalPickLines(store *GoalStore, parentID string, depth int) {
|
||||
var children []*Goal
|
||||
for _, g := range store.Goals {
|
||||
if strings.TrimSpace(g.ParentID) == strings.TrimSpace(parentID) {
|
||||
children = append(children, g)
|
||||
}
|
||||
}
|
||||
sortGoalsByOrder(children)
|
||||
for _, g := range children {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
fmt.Printf("%s\t%s%s\n", g.ID, indent, g.Title)
|
||||
appendGoalPickLines(store, g.ID, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
func sortedThreadIDs(store *GoalStore) []string {
|
||||
ids := make([]string, 0, len(store.Threads))
|
||||
for id := range store.Threads {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func runGoalList(args []string) error {
|
||||
store, err := loadGoalStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
drafts, _ := collectDrafts()
|
||||
list := buildGoalFlatList(store, drafts, nil)
|
||||
for _, n := range list.Nodes {
|
||||
indent := strings.Repeat(" ", n.Depth)
|
||||
switch n.Kind {
|
||||
case nodeGoal:
|
||||
fmt.Printf("%s# %s (%s)\n", indent, n.Goal.Title, n.Goal.ID)
|
||||
case nodeThread:
|
||||
fmt.Printf("%s- %s [%s] (%s)\n", indent, n.Thread.Name, n.Status, n.Thread.ID)
|
||||
case nodeDraft:
|
||||
fmt.Printf("%s- %s [draft] (%s)\n", indent, n.Draft.Name, n.Draft.WindowID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── helpers shared by CLI + panel ───────────────────────────
|
||||
|
||||
func resolveGoalWindowID(window string) string {
|
||||
window = strings.TrimSpace(window)
|
||||
if window != "" {
|
||||
return window
|
||||
}
|
||||
if os.Getenv("TMUX") == "" {
|
||||
return ""
|
||||
}
|
||||
out, err := runTmuxOutput("display-message", "-p", "#{window_id}")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// ensureThreadForWindow resolves (or promotes) the thread for a window, returning its id.
|
||||
func ensureThreadForWindow(window, name string) (string, error) {
|
||||
window = strings.TrimSpace(window)
|
||||
if window == "" {
|
||||
return "", fmt.Errorf("window id is required")
|
||||
}
|
||||
store, err := loadGoalStore()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if t := findThreadByWindow(store, window); t != nil {
|
||||
return t.ID, nil
|
||||
}
|
||||
t, err := promoteDraftToThread(window, name, "", "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return t.ID, nil
|
||||
}
|
||||
|
||||
// stringSliceFlag is a repeatable string flag.
|
||||
type stringSliceFlag []string
|
||||
|
||||
func (s *stringSliceFlag) String() string { return strings.Join(*s, ",") }
|
||||
func (s *stringSliceFlag) Set(v string) error {
|
||||
*s = append(*s, v)
|
||||
return nil
|
||||
}
|
||||
2566
agent-tracker/cmd/agent/goal_panel.go
Normal file
2566
agent-tracker/cmd/agent/goal_panel.go
Normal file
File diff suppressed because it is too large
Load diff
1051
agent-tracker/cmd/agent/goals.go
Normal file
1051
agent-tracker/cmd/agent/goals.go
Normal file
File diff suppressed because it is too large
Load diff
306
agent-tracker/cmd/agent/goals_display.go
Normal file
306
agent-tracker/cmd/agent/goals_display.go
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// collectDrafts returns windows that have tracker activity but no thread.
|
||||
// Only windows that still exist in tmux are included.
|
||||
func collectDrafts() (map[string]*draftInfo, error) {
|
||||
drafts := map[string]*draftInfo{}
|
||||
store, err := loadGoalStore()
|
||||
if err != nil {
|
||||
return drafts, err
|
||||
}
|
||||
env, err := trackerLoadState("")
|
||||
if err != nil || env == nil {
|
||||
return drafts, nil
|
||||
}
|
||||
existing := collectWindowInfo()
|
||||
claimed := map[string]bool{}
|
||||
for _, t := range store.Threads {
|
||||
if wid := strings.TrimSpace(t.WindowID); wid != "" {
|
||||
claimed[wid] = true
|
||||
}
|
||||
}
|
||||
for _, task := range env.Tasks {
|
||||
wid := strings.TrimSpace(task.WindowID)
|
||||
if wid == "" || claimed[wid] {
|
||||
continue
|
||||
}
|
||||
if _, ok := existing[wid]; !ok {
|
||||
continue
|
||||
}
|
||||
running := task.Status == trackerTaskStatusInProgress
|
||||
summary := firstLine(strings.TrimSpace(task.Summary))
|
||||
existingDraft := drafts[wid]
|
||||
if existingDraft == nil {
|
||||
drafts[wid] = &draftInfo{
|
||||
WindowID: wid,
|
||||
LastUpdate: summary,
|
||||
SessionName: strings.TrimSpace(task.Session),
|
||||
WindowName: strings.TrimSpace(task.Window),
|
||||
Running: running,
|
||||
}
|
||||
existingDraft = drafts[wid]
|
||||
} else {
|
||||
if running {
|
||||
existingDraft.Running = true
|
||||
}
|
||||
if summary != "" && existingDraft.LastUpdate == "" {
|
||||
existingDraft.LastUpdate = summary
|
||||
}
|
||||
}
|
||||
for _, ts := range []string{task.StartedAt, task.CompletedAt} {
|
||||
if parsed, ok := trackerParseTimestamp(ts); ok && parsed.After(existingDraft.LastActive) {
|
||||
existingDraft.LastActive = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return drafts, nil
|
||||
}
|
||||
|
||||
// reapOrphanedThreads deletes threads bound to windows that no longer exist.
|
||||
// Planned (window-less) threads are preserved. Safe to call after tmux-resurrect
|
||||
// has re-pointed threads to new window ids via the restore migration.
|
||||
func reapOrphanedThreads() {
|
||||
store, err := loadGoalStore()
|
||||
if err != nil || store == nil {
|
||||
return
|
||||
}
|
||||
existing := collectWindowInfo()
|
||||
changed := false
|
||||
for id, t := range store.Threads {
|
||||
wid := strings.TrimSpace(t.WindowID)
|
||||
if wid == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := existing[wid]; ok {
|
||||
continue
|
||||
}
|
||||
delete(store.Threads, id)
|
||||
for _, other := range store.Threads {
|
||||
if containsString(other.BlockedBy, id) {
|
||||
other.BlockedBy = removeString(other.BlockedBy, id)
|
||||
}
|
||||
}
|
||||
_ = removeWindowTodos(wid)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
_ = saveGoalStore(store)
|
||||
}
|
||||
}
|
||||
|
||||
// removeWindowTodos deletes a window's todo list from the todo store.
|
||||
func removeWindowTodos(windowID string) error {
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
if windowID == "" {
|
||||
return nil
|
||||
}
|
||||
store, err := loadTmuxTodoStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if store.Windows == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := store.Windows[windowID]; !ok {
|
||||
return nil
|
||||
}
|
||||
delete(store.Windows, windowID)
|
||||
return saveTmuxTodoStore(store)
|
||||
}
|
||||
|
||||
type taskInfo struct {
|
||||
phase string
|
||||
unread bool
|
||||
lastUpdate string
|
||||
}
|
||||
|
||||
func taskInfoByWindow() map[string]taskInfo {
|
||||
out := map[string]taskInfo{}
|
||||
env, err := trackerLoadState("")
|
||||
if err != nil || env == nil {
|
||||
return out
|
||||
}
|
||||
for _, task := range env.Tasks {
|
||||
wid := strings.TrimSpace(task.WindowID)
|
||||
if wid == "" {
|
||||
continue
|
||||
}
|
||||
info := taskInfo{
|
||||
phase: strings.TrimSpace(task.Phase),
|
||||
unread: task.Status == "completed" && !task.Acknowledged,
|
||||
lastUpdate: firstLine(strings.TrimSpace(task.Summary)),
|
||||
}
|
||||
out[wid] = info
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runningWindows returns the set of window ids with an in-progress tracker task.
|
||||
func runningWindows() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
env, err := trackerLoadState("")
|
||||
if err != nil || env == nil {
|
||||
return out
|
||||
}
|
||||
for _, task := range env.Tasks {
|
||||
if task.Status == trackerTaskStatusInProgress {
|
||||
if wid := strings.TrimSpace(task.WindowID); wid != "" {
|
||||
out[wid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type windowInfo struct {
|
||||
sessionName string
|
||||
windowName string
|
||||
}
|
||||
|
||||
// collectWindowInfo returns live session/window names for every tmux window.
|
||||
func collectWindowInfo() map[string]windowInfo {
|
||||
out := map[string]windowInfo{}
|
||||
lines, err := runTmuxOutput("list-windows", "-a", "-F",
|
||||
"#{window_id}\t#{session_name}\t#{window_name}")
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(lines, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "\t")
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
wid := strings.TrimSpace(parts[0])
|
||||
if wid == "" {
|
||||
continue
|
||||
}
|
||||
out[wid] = windowInfo{
|
||||
sessionName: strings.TrimSpace(parts[1]),
|
||||
windowName: strings.TrimSpace(parts[2]),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildDisplayList constructs the enriched flat list the panel renders.
|
||||
// expanded maps thread ids whose todos should be shown inline.
|
||||
func buildDisplayList(expanded map[string]bool) (*flatList, error) {
|
||||
store, err := loadGoalStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
drafts, _ := collectDrafts()
|
||||
running := runningWindows()
|
||||
windows := collectWindowInfo()
|
||||
todoStore, _ := loadTmuxTodoStore()
|
||||
taskByWindow := taskInfoByWindow()
|
||||
list := buildGoalFlatList(store, drafts, expanded)
|
||||
for _, n := range list.Nodes {
|
||||
switch n.Kind {
|
||||
case nodeGoal:
|
||||
case nodeThread:
|
||||
n.Status = computeThreadStatus(store, n.Thread, running)
|
||||
n.SessionName, n.WindowName = windowLabel(windows, n.Thread.WindowID)
|
||||
n.OpenTodos, n.TotalTodos = windowTodoCounts(todoStore, n.Thread.WindowID)
|
||||
n.HasTodos = n.TotalTodos > 0
|
||||
if info, ok := taskByWindow[strings.TrimSpace(n.Thread.WindowID)]; ok {
|
||||
n.Phase = info.phase
|
||||
n.Unread = info.unread
|
||||
n.LastUpdate = info.lastUpdate
|
||||
}
|
||||
case nodeDraft:
|
||||
if n.Draft.Running {
|
||||
n.Status = threadStatusRunning
|
||||
} else {
|
||||
n.Status = threadStatusReady
|
||||
}
|
||||
n.SessionName = n.Draft.SessionName
|
||||
n.WindowName = n.Draft.WindowName
|
||||
n.LastUpdate = n.Draft.LastUpdate
|
||||
n.OpenTodos, n.TotalTodos = windowTodoCounts(todoStore, n.Draft.WindowID)
|
||||
n.HasTodos = n.TotalTodos > 0
|
||||
if info, ok := taskByWindow[strings.TrimSpace(n.Draft.WindowID)]; ok {
|
||||
n.Phase = info.phase
|
||||
n.Unread = info.unread
|
||||
if info.lastUpdate != "" {
|
||||
n.LastUpdate = info.lastUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return spliceExpandedTodos(list, todoStore, expanded), nil
|
||||
}
|
||||
|
||||
// spliceExpandedTodos inserts todo nodes under expanded threads/drafts.
|
||||
func spliceExpandedTodos(list *flatList, store *tmuxTodoStore, expanded map[string]bool) *flatList {
|
||||
if store == nil {
|
||||
return list
|
||||
}
|
||||
result := &flatList{}
|
||||
for _, n := range list.Nodes {
|
||||
result.Nodes = append(result.Nodes, n)
|
||||
var windowID string
|
||||
switch n.Kind {
|
||||
case nodeThread:
|
||||
windowID = n.Thread.WindowID
|
||||
case nodeDraft:
|
||||
windowID = n.Draft.WindowID
|
||||
default:
|
||||
continue
|
||||
}
|
||||
var key string
|
||||
if n.Kind == nodeThread {
|
||||
key = n.Thread.ID
|
||||
} else {
|
||||
key = "draft:" + windowID
|
||||
}
|
||||
if !expanded[key] {
|
||||
continue
|
||||
}
|
||||
items := store.Windows[strings.TrimSpace(windowID)]
|
||||
for idx, item := range items {
|
||||
result.Nodes = append(result.Nodes, &goalNode{
|
||||
Kind: nodeTodo,
|
||||
Depth: n.Depth + 1,
|
||||
Todo: &item,
|
||||
TodoWindow: windowID,
|
||||
TodoIndex: idx,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func windowLabel(windows map[string]windowInfo, windowID string) (string, string) {
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
if windowID == "" {
|
||||
return "", ""
|
||||
}
|
||||
info, ok := windows[windowID]
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
return info.sessionName, info.windowName
|
||||
}
|
||||
|
||||
func windowTodoCounts(store *tmuxTodoStore, windowID string) (open, total int) {
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
if windowID == "" || store == nil {
|
||||
return 0, 0
|
||||
}
|
||||
for _, item := range store.Windows[windowID] {
|
||||
total++
|
||||
if !item.Done {
|
||||
open++
|
||||
}
|
||||
}
|
||||
return open, total
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ type statusRightConfig struct {
|
|||
Todos *bool `json:"todos,omitempty"`
|
||||
FlashMoe *bool `json:"flash_moe,omitempty"`
|
||||
Host *bool `json:"host,omitempty"`
|
||||
Goal *bool `json:"goal,omitempty"`
|
||||
}
|
||||
|
||||
type keyConfig struct {
|
||||
|
|
@ -105,10 +106,10 @@ type keyConfig struct {
|
|||
}
|
||||
|
||||
type repoConfig struct {
|
||||
BaseBranch string `yaml:"base_branch,omitempty"`
|
||||
DefaultDevice string `yaml:"default_device,omitempty"`
|
||||
CopyIgnore []string `yaml:"copy_ignore,omitempty"`
|
||||
AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"`
|
||||
BaseBranch string `yaml:"base_branch,omitempty"`
|
||||
DefaultDevice string `yaml:"default_device,omitempty"`
|
||||
CopyIgnore []string `yaml:"copy_ignore,omitempty"`
|
||||
AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"`
|
||||
}
|
||||
|
||||
type featureConfig struct {
|
||||
|
|
@ -159,7 +160,7 @@ func main() {
|
|||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|browser|feature>")
|
||||
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|goal|browser|feature|update-hot-reload>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "start":
|
||||
|
|
@ -182,10 +183,14 @@ func run(args []string) error {
|
|||
return runTmuxCommand(args[1:])
|
||||
case "tracker":
|
||||
return runTracker(args[1:])
|
||||
case "goal":
|
||||
return runGoal(args[1:])
|
||||
case "browser":
|
||||
return runBrowserCommand(args[1:])
|
||||
case "feature":
|
||||
return runFeatureCommand(args[1:])
|
||||
case "update-hot-reload":
|
||||
return runUpdateHotReload(args[1:])
|
||||
case "bootstrap":
|
||||
return runBootstrap(args[1:])
|
||||
default:
|
||||
|
|
@ -1052,7 +1057,7 @@ func destroyRequiresExplicitConfirm(record *agentRecord) (bool, error) {
|
|||
|
||||
func runTmuxCommand(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status>")
|
||||
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status|scratch>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "on-focus":
|
||||
|
|
@ -1063,6 +1068,8 @@ func runTmuxCommand(args []string) error {
|
|||
return runTmuxPalette(args[1:])
|
||||
case "right-status":
|
||||
return runTmuxRightStatus(args[1:])
|
||||
case "scratch":
|
||||
return runTmuxScratch(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown tmux subcommand: %s", args[0])
|
||||
}
|
||||
|
|
@ -1366,6 +1373,298 @@ func runTmuxFocus(args []string) error {
|
|||
return runTmux("select-pane", "-t", target)
|
||||
}
|
||||
|
||||
func runTmuxScratch(args []string) error {
|
||||
fs := flag.NewFlagSet("agent tmux scratch", flag.ContinueOnError)
|
||||
var path string
|
||||
var client string
|
||||
var warm bool
|
||||
fs.StringVar(&path, "path", "", "directory for creating the scratch terminal")
|
||||
fs.StringVar(&client, "client", "", "tmux client tty")
|
||||
fs.BoolVar(&warm, "warm", false, "prepare the scratch terminal without opening it")
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if warm {
|
||||
return warmScratchSession(path)
|
||||
}
|
||||
current, err := currentTmuxLocation()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg, err := loadRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record := reg.Agents["scratch"]
|
||||
if scratchRecordAlive(record) {
|
||||
_ = runTmux("rename-session", "-t", record.TmuxSessionID, "scratch")
|
||||
_ = configureScratchSession(record.TmuxSessionID, record.TmuxWindowID)
|
||||
if current.SessionID == record.TmuxSessionID {
|
||||
return closeScratchPopup(client)
|
||||
}
|
||||
setScratchOrigin(record.TmuxWindowID, current)
|
||||
if current.WindowID != "" {
|
||||
record.LaunchWindowID = current.WindowID
|
||||
}
|
||||
now := time.Now()
|
||||
record.LastFocusedAt = &now
|
||||
record.UpdatedAt = now
|
||||
reg.FocusedAgentID = record.ID
|
||||
if err := saveRegistry(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
return openScratchPopup(record)
|
||||
}
|
||||
if record == nil {
|
||||
record = &agentRecord{ID: "scratch", Name: "scratch", CreatedAt: time.Now()}
|
||||
}
|
||||
startPath := firstNonEmpty(path, os.Getenv("HOME"))
|
||||
if startPath == "" || !dirExists(startPath) {
|
||||
startPath = os.Getenv("HOME")
|
||||
}
|
||||
windowID, sessionID, sessionName, paneID, err := createScratchTmuxSession(startPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
record.ID = "scratch"
|
||||
record.Name = "scratch"
|
||||
record.RepoRoot = startPath
|
||||
record.WorkspaceRoot = startPath
|
||||
record.RepoCopyPath = startPath
|
||||
record.TmuxSessionID = sessionID
|
||||
record.TmuxSessionName = sessionName
|
||||
record.TmuxWindowID = windowID
|
||||
record.Panes = agentPanes{AI: paneID}
|
||||
record.LaunchWindowID = current.WindowID
|
||||
record.LastFocusedAt = &now
|
||||
record.UpdatedAt = now
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = now
|
||||
}
|
||||
reg.Agents[record.ID] = record
|
||||
reg.FocusedAgentID = record.ID
|
||||
if err := saveRegistry(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
setScratchOrigin(windowID, current)
|
||||
return openScratchPopup(record)
|
||||
}
|
||||
|
||||
func warmScratchSession(path string) error {
|
||||
reg, err := loadRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record := reg.Agents["scratch"]
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
if scratchRecordAlive(record) {
|
||||
_ = runTmux("rename-session", "-t", record.TmuxSessionID, "scratch")
|
||||
return configureScratchSession(record.TmuxSessionID, record.TmuxWindowID)
|
||||
}
|
||||
startPath := firstNonEmpty(path, record.RepoCopyPath, record.WorkspaceRoot, record.RepoRoot, os.Getenv("HOME"))
|
||||
if startPath == "" || !dirExists(startPath) {
|
||||
startPath = os.Getenv("HOME")
|
||||
}
|
||||
windowID, sessionID, sessionName, paneID, err := createScratchTmuxSession(startPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
record.TmuxSessionID = sessionID
|
||||
record.TmuxSessionName = sessionName
|
||||
record.TmuxWindowID = windowID
|
||||
record.Panes = agentPanes{AI: paneID}
|
||||
record.RepoRoot = startPath
|
||||
record.WorkspaceRoot = startPath
|
||||
record.RepoCopyPath = startPath
|
||||
record.UpdatedAt = now
|
||||
reg.Agents[record.ID] = record
|
||||
return saveRegistry(reg)
|
||||
}
|
||||
|
||||
type tmuxLocation struct {
|
||||
SessionID string
|
||||
WindowID string
|
||||
PaneID string
|
||||
Path string
|
||||
}
|
||||
|
||||
func currentTmuxLocation() (tmuxLocation, error) {
|
||||
out, err := runTmuxOutput("display-message", "-p", "#{session_id}\n#{window_id}\n#{pane_id}\n#{pane_current_path}")
|
||||
if err != nil {
|
||||
return tmuxLocation{}, err
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 4)
|
||||
for len(parts) < 4 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
return tmuxLocation{
|
||||
SessionID: strings.TrimSpace(parts[0]),
|
||||
WindowID: strings.TrimSpace(parts[1]),
|
||||
PaneID: strings.TrimSpace(parts[2]),
|
||||
Path: strings.TrimSpace(parts[3]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scratchRecordAlive(record *agentRecord) bool {
|
||||
return record != nil && windowAlive(record.TmuxSessionID, record.TmuxWindowID)
|
||||
}
|
||||
|
||||
func createScratchTmuxSession(path string) (windowID, sessionID, sessionName, paneID string, err error) {
|
||||
sessionName = "Scratch"
|
||||
if existingSessionID, existingSessionName, ok := findTmuxSessionByLabel(sessionName); ok {
|
||||
sessionID = existingSessionID
|
||||
sessionName = existingSessionName
|
||||
windowID, err = runTmuxOutput("new-window", "-P", "-F", "#{window_id}", "-t", sessionID, "-c", path)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
} else {
|
||||
if err := runTmux("new-session", "-d", "-s", sessionName, "-c", path); err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
sessionID, _ = runTmuxOutput("display-message", "-p", "-t", sessionName+":1", "#{session_id}")
|
||||
windowID, _ = runTmuxOutput("display-message", "-p", "-t", sessionName+":1", "#{window_id}")
|
||||
}
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
sessionName = strings.TrimSpace(sessionName)
|
||||
if windowID == "" || sessionID == "" {
|
||||
return "", "", "", "", fmt.Errorf("unable to create scratch terminal")
|
||||
}
|
||||
paneID, err = currentPane(windowID)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
_ = runTmux("rename-session", "-t", sessionID, "Scratch")
|
||||
_ = runTmux("set-option", "-w", "-t", windowID, "@agent_id", "scratch")
|
||||
_ = runTmux("set-option", "-w", "-t", windowID, "@scratch_window", "1")
|
||||
_ = runTmux("set-option", "-p", "-t", paneID, "@agent_role", "scratch")
|
||||
_ = configureScratchSession(sessionID, windowID)
|
||||
return windowID, sessionID, "Scratch", strings.TrimSpace(paneID), nil
|
||||
}
|
||||
|
||||
func configureScratchSession(sessionID, windowID string) error {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
if sessionID != "" {
|
||||
_ = runTmux("set-option", "-t", sessionID, "status", "on")
|
||||
_ = runTmux("set-option", "-t", sessionID, "status-left", "")
|
||||
_ = runTmux("set-option", "-t", sessionID, "status-right", "")
|
||||
_ = runTmux("set-option", "-t", sessionID, "status-left-length", "0")
|
||||
_ = runTmux("set-option", "-t", sessionID, "status-right-length", "0")
|
||||
_ = runTmux("set-option", "-t", sessionID, "prefix", "None")
|
||||
_ = runTmux("set-option", "-t", sessionID, "detach-on-destroy", "on")
|
||||
}
|
||||
if windowID != "" {
|
||||
_ = runTmux("set-option", "-w", "-t", windowID, "@agent_id", "scratch")
|
||||
_ = runTmux("set-option", "-w", "-t", windowID, "@scratch_window", "1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openScratchPopup(record *agentRecord) error {
|
||||
if record == nil {
|
||||
return fmt.Errorf("scratch terminal is not available")
|
||||
}
|
||||
target := strings.TrimSpace(record.TmuxSessionID)
|
||||
if target == "" {
|
||||
target = strings.TrimSpace(record.TmuxWindowID)
|
||||
}
|
||||
if target == "" {
|
||||
return fmt.Errorf("scratch terminal is not available")
|
||||
}
|
||||
cmd := fmt.Sprintf("tmux attach-session -t %s", shellQuote(target))
|
||||
return runTmux("display-popup", "-E", "-w", "85%", "-h", "80%", "-T", "Scratch", cmd)
|
||||
}
|
||||
|
||||
func closeScratchPopup(client string) error {
|
||||
client = strings.TrimSpace(client)
|
||||
if client == "" {
|
||||
client = strings.TrimSpace(os.Getenv("TMUX_CLIENT"))
|
||||
}
|
||||
args := []string{"detach-client"}
|
||||
if client != "" {
|
||||
args = append(args, "-t", client)
|
||||
}
|
||||
return runTmux(args...)
|
||||
}
|
||||
|
||||
func setScratchOrigin(scratchWindowID string, origin tmuxLocation) {
|
||||
scratchWindowID = strings.TrimSpace(scratchWindowID)
|
||||
if scratchWindowID == "" {
|
||||
return
|
||||
}
|
||||
if origin.SessionID != "" {
|
||||
_ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_session", origin.SessionID)
|
||||
}
|
||||
if origin.WindowID != "" {
|
||||
_ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_window", origin.WindowID)
|
||||
}
|
||||
if origin.PaneID != "" {
|
||||
_ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_pane", origin.PaneID)
|
||||
}
|
||||
}
|
||||
|
||||
func focusScratchOrigin(reg *registry) error {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
record := reg.Agents["scratch"]
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
originSessionID := ""
|
||||
originWindowID := ""
|
||||
originPaneID := ""
|
||||
if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_session"); err == nil {
|
||||
originSessionID = strings.TrimSpace(out)
|
||||
}
|
||||
if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_window"); err == nil {
|
||||
originWindowID = strings.TrimSpace(out)
|
||||
}
|
||||
if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_pane"); err == nil {
|
||||
originPaneID = strings.TrimSpace(out)
|
||||
}
|
||||
if !windowAlive(originSessionID, originWindowID) {
|
||||
originSessionID = ""
|
||||
originWindowID = firstLiveNonScratchWindow(record.TmuxWindowID)
|
||||
}
|
||||
if originWindowID == "" {
|
||||
return nil
|
||||
}
|
||||
reg.FocusedAgentID = ""
|
||||
_ = saveRegistry(reg)
|
||||
if originSessionID != "" {
|
||||
_ = runTmux("switch-client", "-t", originSessionID)
|
||||
}
|
||||
if err := selectTmuxWindow(originWindowID); err != nil {
|
||||
return err
|
||||
}
|
||||
if originPaneID != "" {
|
||||
_ = runTmux("select-pane", "-t", originPaneID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstLiveNonScratchWindow(scratchWindowID string) string {
|
||||
out, err := runTmuxOutput("list-windows", "-a", "-F", "#{window_id}")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
windowID := strings.TrimSpace(line)
|
||||
if windowID != "" && windowID != strings.TrimSpace(scratchWindowID) {
|
||||
return windowID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runTmuxPalette(args []string) error {
|
||||
fs := flag.NewFlagSet("agent tmux palette", flag.ContinueOnError)
|
||||
var windowID string
|
||||
|
|
@ -2526,6 +2825,19 @@ exec script -q "$logfile" bash -lc "cd \"$DIR/repo\" && exec flutter run -d \"$d
|
|||
if err := os.WriteFile(ensurePath, []byte(ensureServer), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeHotReloadScript(repoCopyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(filepath.Join(workspaceRoot, "hot-reload.sh"))
|
||||
for _, obsolete := range []string{"open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} {
|
||||
_ = os.Remove(filepath.Join(workspaceRoot, obsolete))
|
||||
}
|
||||
_ = url
|
||||
_ = device
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeHotReloadScript(repoCopyPath string) error {
|
||||
hotReload := `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -2584,6 +2896,7 @@ find_flutter_pane() {
|
|||
while IFS= read -r child; do
|
||||
[[ -z "$child" ]] && continue
|
||||
if ps -p "$child" -o command= 2>/dev/null | grep -q 'flutter_tools\.snapshot.*run'; then
|
||||
FLUTTER_RUN_PID="$child"
|
||||
return 0
|
||||
fi
|
||||
if has_flutter_run "$child" $((depth + 1)); then
|
||||
|
|
@ -2593,15 +2906,24 @@ find_flutter_pane() {
|
|||
return 1
|
||||
}
|
||||
|
||||
local pane_id pane_pid pane_path
|
||||
while read -r pane_id pane_pid pane_path; do
|
||||
local pane_id pane_pid fpid fcwd
|
||||
local repo_dir="${REPO_DIR%/}"
|
||||
local workspace_dir="${WORKSPACE_DIR%/}"
|
||||
while read -r pane_id pane_pid; do
|
||||
[[ -z "$pane_id" || -z "$pane_pid" ]] && continue
|
||||
[[ "$pane_path" != "$WORKSPACE_DIR" && "$pane_path" != "$REPO_DIR" ]] && continue
|
||||
if has_flutter_run "$pane_pid"; then
|
||||
FLUTTER_RUN_PID=""
|
||||
if ! has_flutter_run "$pane_pid"; then
|
||||
continue
|
||||
fi
|
||||
fpid="$FLUTTER_RUN_PID"
|
||||
[[ -z "$fpid" ]] && continue
|
||||
fcwd="$(lsof -a -d cwd -p "$fpid" 2>/dev/null | tail -n +2 | awk '{print $NF}')"
|
||||
fcwd="${fcwd%/}"
|
||||
if [[ "$fcwd" == "$repo_dir" || "$fcwd" == "$workspace_dir" ]]; then
|
||||
printf "%s\n" "$pane_id"
|
||||
return 0
|
||||
fi
|
||||
done < <(tmux list-panes -a -F '#{pane_id} #{pane_pid} #{pane_current_path}' 2>/dev/null)
|
||||
done < <(tmux list-panes -a -F '#{pane_id} #{pane_pid}' 2>/dev/null)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
|
@ -2655,24 +2977,107 @@ PY
|
|||
exit 0
|
||||
) >/dev/null 2>&1 &
|
||||
|
||||
echo "Hot reload triggered"
|
||||
echo "Reloaded the application"
|
||||
`
|
||||
if err := ensureGeneratedRepoPathIgnored(repoCopyPath, "hot-reload.sh"); err != nil {
|
||||
return err
|
||||
}
|
||||
hotReloadPath := filepath.Join(repoCopyPath, "hot-reload.sh")
|
||||
if err := os.WriteFile(hotReloadPath, []byte(hotReload), 0o755); err != nil {
|
||||
return os.WriteFile(hotReloadPath, []byte(hotReload), 0o755)
|
||||
}
|
||||
|
||||
func runUpdateHotReload(args []string) error {
|
||||
fs := flag.NewFlagSet("agent update-hot-reload", flag.ContinueOnError)
|
||||
var allRepos bool
|
||||
fs.BoolVar(&allRepos, "all", false, "update workspaces across every repo in the registry")
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(filepath.Join(workspaceRoot, "hot-reload.sh"))
|
||||
for _, obsolete := range []string{"open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} {
|
||||
_ = os.Remove(filepath.Join(workspaceRoot, obsolete))
|
||||
|
||||
var repoRoots []string
|
||||
if allRepos {
|
||||
reg, err := loadRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, record := range reg.Agents {
|
||||
root := filepath.Clean(record.RepoRoot)
|
||||
if root == "" || seen[root] {
|
||||
continue
|
||||
}
|
||||
seen[root] = true
|
||||
repoRoots = append(repoRoots, root)
|
||||
}
|
||||
sort.Strings(repoRoots)
|
||||
} else {
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w; run inside a git repo or use --all", err)
|
||||
}
|
||||
repoRoots = []string{root}
|
||||
}
|
||||
_ = url
|
||||
_ = device
|
||||
|
||||
totalUpdated := 0
|
||||
for _, root := range repoRoots {
|
||||
updated, err := updateHotReloadInRepo(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", root, err)
|
||||
}
|
||||
if len(updated) == 0 {
|
||||
continue
|
||||
}
|
||||
totalUpdated += len(updated)
|
||||
fmt.Printf("%s:\n", root)
|
||||
for _, name := range updated {
|
||||
fmt.Printf(" %s\n", name)
|
||||
}
|
||||
}
|
||||
if totalUpdated == 0 {
|
||||
fmt.Println("No Flutter workspaces found.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Updated hot-reload.sh in %d workspace(s).\n", totalUpdated)
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateHotReloadInRepo(repoRoot string) ([]string, error) {
|
||||
agentsRoot := filepath.Join(repoRoot, ".agents")
|
||||
entries, err := os.ReadDir(agentsRoot)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updated []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
workspaceRoot := filepath.Join(agentsRoot, entry.Name())
|
||||
featurePath := filepath.Join(workspaceRoot, "agent.json")
|
||||
repoCopyPath := filepath.Join(workspaceRoot, "repo")
|
||||
isFlutter := false
|
||||
if cfg, err := loadFeatureConfig(featurePath); err == nil {
|
||||
isFlutter = cfg.IsFlutter
|
||||
}
|
||||
hotReloadPath := filepath.Join(repoCopyPath, "hot-reload.sh")
|
||||
if !isFlutter && !fileExists(hotReloadPath) {
|
||||
continue
|
||||
}
|
||||
if !pathExists(repoCopyPath) {
|
||||
continue
|
||||
}
|
||||
if err := writeHotReloadScript(repoCopyPath); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
updated = append(updated, entry.Name())
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func detectDefaultBaseBranch(repoRoot string) string {
|
||||
for _, candidate := range []string{"develop", "main", "master"} {
|
||||
if remoteExists(repoRoot, "origin/"+candidate) || localExists(repoRoot, candidate) {
|
||||
|
|
@ -2768,10 +3173,6 @@ func loadRegistry() (*registry, error) {
|
|||
if decodeErr := dec.Decode(fallback); decodeErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
trailing := strings.TrimSpace(string(data[int(dec.InputOffset()):]))
|
||||
if trailing == "" || strings.Trim(trailing, "}") != "" {
|
||||
return nil, err
|
||||
}
|
||||
reg = fallback
|
||||
_ = saveRegistry(reg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const (
|
|||
paletteModeDevices
|
||||
paletteModeStatusRight
|
||||
paletteModeTracker
|
||||
paletteModeGoals
|
||||
)
|
||||
|
||||
type palettePromptField int
|
||||
|
|
@ -51,6 +52,8 @@ const (
|
|||
paletteActionOpenTodos
|
||||
paletteActionOpenDevices
|
||||
paletteActionOpenTracker
|
||||
paletteActionOpenGoals
|
||||
paletteActionOpenScratch
|
||||
)
|
||||
|
||||
type paletteAction struct {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ type paletteRuntime struct {
|
|||
currentSessionName string
|
||||
currentWindowName string
|
||||
mainRepoRoot string
|
||||
startMode paletteMode
|
||||
}
|
||||
|
||||
type paletteModel struct {
|
||||
|
|
@ -50,6 +51,7 @@ type paletteModel struct {
|
|||
actions []paletteAction
|
||||
openedAt time.Time
|
||||
quickSecondaryEscCloses bool
|
||||
singlePanelMode bool
|
||||
width int
|
||||
height int
|
||||
result paletteResult
|
||||
|
|
@ -58,6 +60,7 @@ type paletteModel struct {
|
|||
devices *devicePanelModel
|
||||
status *statusRightPanelModel
|
||||
tracker *trackerPanelModel
|
||||
goals *goalPanelModel
|
||||
}
|
||||
|
||||
type paletteStyles struct {
|
||||
|
|
@ -110,7 +113,7 @@ func runBubbleTeaPalette(args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state := paletteUIState{Mode: paletteModeList, Message: runtime.startupMessage}
|
||||
state := paletteUIState{Mode: runtime.startMode, Message: runtime.startupMessage}
|
||||
for {
|
||||
model := newPaletteModel(runtime, state)
|
||||
finalModel, err := tea.NewProgram(model).Run()
|
||||
|
|
@ -169,11 +172,13 @@ func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
|
|||
var currentPath string
|
||||
var currentSessionName string
|
||||
var currentWindowName string
|
||||
var modeFlag string
|
||||
fs.StringVar(&windowID, "window", "", "window id")
|
||||
fs.StringVar(&agentID, "agent-id", "", "agent id")
|
||||
fs.StringVar(¤tPath, "path", "", "current pane path")
|
||||
fs.StringVar(¤tSessionName, "session-name", "", "current session name")
|
||||
fs.StringVar(¤tWindowName, "window-name", "", "current window name")
|
||||
fs.StringVar(&modeFlag, "mode", "", "initial panel mode (goals, tracker, todos, activity)")
|
||||
fs.SetOutput(nil)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -186,6 +191,18 @@ func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
|
|||
currentSessionName: firstNonEmpty(currentSessionName, os.Getenv("AGENT_PALETTE_SESSION_NAME")),
|
||||
currentWindowName: firstNonEmpty(currentWindowName, os.Getenv("AGENT_PALETTE_WINDOW_NAME")),
|
||||
}
|
||||
switch strings.ToLower(modeFlag) {
|
||||
case "goals":
|
||||
runtime.startMode = paletteModeGoals
|
||||
case "tracker":
|
||||
runtime.startMode = paletteModeTracker
|
||||
case "todos":
|
||||
runtime.startMode = paletteModeTodos
|
||||
case "activity":
|
||||
runtime.startMode = paletteModeActivity
|
||||
default:
|
||||
runtime.startMode = paletteModeList
|
||||
}
|
||||
logPaletteLaunchIfMalformed(runtime)
|
||||
if looksLikeTmuxFormatLiteral(runtime.agentID) {
|
||||
runtime.agentID = ""
|
||||
|
|
@ -351,6 +368,20 @@ func (r *paletteRuntime) buildActions() []paletteAction {
|
|||
})
|
||||
}
|
||||
actions = append(actions,
|
||||
paletteAction{
|
||||
Section: "System",
|
||||
Title: "Scratch terminal",
|
||||
Subtitle: "Open the persistent scratch shell",
|
||||
Keywords: []string{"scratch", "terminal", "shell", "popup", "tmux"},
|
||||
Kind: paletteActionOpenScratch,
|
||||
},
|
||||
paletteAction{
|
||||
Section: "System",
|
||||
Title: "Goals",
|
||||
Subtitle: "Goals, threads and todos",
|
||||
Keywords: []string{"goal", "goals", "thread", "threads", "tracker", "tasks", "status"},
|
||||
Kind: paletteActionOpenGoals,
|
||||
},
|
||||
paletteAction{
|
||||
Section: "System",
|
||||
Title: "Tracker",
|
||||
|
|
@ -582,11 +613,22 @@ func (r *paletteRuntime) execute(result paletteResult) (bool, string, error) {
|
|||
return false, "", nil
|
||||
case paletteActionReloadTmuxConfig:
|
||||
return false, "", paletteTmuxRunner("source-file", os.Getenv("HOME")+"/.config/.tmux.conf")
|
||||
case paletteActionOpenScratch:
|
||||
return false, "", launchScratchTerminalFromPalette(r.currentPath)
|
||||
default:
|
||||
return false, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func launchScratchTerminalFromPalette(currentPath string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := fmt.Sprintf("sleep 0.1; %s tmux scratch --path %s", shellQuote(exe), shellQuote(currentPath))
|
||||
return runTmux("run-shell", "-b", cmd)
|
||||
}
|
||||
|
||||
func statusRightModuleLabel(module string) string {
|
||||
switch module {
|
||||
case statusRightModuleCPU:
|
||||
|
|
@ -653,7 +695,7 @@ func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteMode
|
|||
if len(state.PromptDevices) > 0 {
|
||||
state.PromptDeviceIndex = clampInt(state.PromptDeviceIndex, 0, len(state.PromptDevices)-1)
|
||||
}
|
||||
model := &paletteModel{runtime: runtime, state: state, actions: runtime.buildActions(), openedAt: time.Now()}
|
||||
model := &paletteModel{runtime: runtime, state: state, actions: runtime.buildActions(), openedAt: time.Now(), singlePanelMode: runtime.startMode != paletteModeList}
|
||||
if state.Mode == paletteModeTodos {
|
||||
_ = model.openTodosPanel()
|
||||
}
|
||||
|
|
@ -669,10 +711,22 @@ func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteMode
|
|||
if state.Mode == paletteModeTracker {
|
||||
_, _ = model.openTrackerPanel()
|
||||
}
|
||||
if state.Mode == paletteModeGoals {
|
||||
_, _ = model.openGoalsPanel()
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func (m *paletteModel) Init() tea.Cmd {
|
||||
if m.goals != nil {
|
||||
return goalPanelTickCmd()
|
||||
}
|
||||
if m.tracker != nil {
|
||||
return trackerPanelTickCmd()
|
||||
}
|
||||
if m.activity != nil {
|
||||
return activityTickCmd()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -791,6 +845,24 @@ func (m *paletteModel) openTrackerPanel() (tea.Cmd, error) {
|
|||
return m.tracker.activate(), nil
|
||||
}
|
||||
|
||||
func (m *paletteModel) openGoalsPanel() (tea.Cmd, error) {
|
||||
m.noteSecondaryPageOpen()
|
||||
if m.goals == nil {
|
||||
m.goals = newGoalPanelModel(m.runtime)
|
||||
} else {
|
||||
m.goals.runtime = m.runtime
|
||||
m.goals.requestBack = false
|
||||
m.goals.requestClose = false
|
||||
}
|
||||
m.goals.width = m.width
|
||||
m.goals.height = m.height
|
||||
m.goals.showAltHints = false
|
||||
m.state.Mode = paletteModeGoals
|
||||
m.state.Message = ""
|
||||
m.state.ShowAltHints = false
|
||||
return m.goals.activate(), nil
|
||||
}
|
||||
|
||||
func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
|
|
@ -808,12 +880,16 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.tracker.width = msg.Width
|
||||
m.tracker.height = msg.Height
|
||||
}
|
||||
if m.goals != nil {
|
||||
m.goals.width = msg.Width
|
||||
m.goals.height = msg.Height
|
||||
}
|
||||
if m.status != nil {
|
||||
m.status.width = msg.Width
|
||||
m.status.height = msg.Height
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeStatusRight && m.state.Mode != paletteModeTracker {
|
||||
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeStatusRight && m.state.Mode != paletteModeTracker && m.state.Mode != paletteModeGoals {
|
||||
if isAltFooterToggleKey(msg) {
|
||||
m.state.ShowAltHints = !m.state.ShowAltHints
|
||||
return m, nil
|
||||
|
|
@ -843,6 +919,10 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m.closePalette()
|
||||
case paletteModeTracker:
|
||||
return m.closePalette()
|
||||
case paletteModeGoals:
|
||||
if m.goals != nil && m.goals.mode == goalModeList {
|
||||
return m.closePalette()
|
||||
}
|
||||
case paletteModeSnippets:
|
||||
return m.closePalette()
|
||||
}
|
||||
|
|
@ -943,6 +1023,10 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, tea.Quit
|
||||
}
|
||||
if m.tracker.requestBack {
|
||||
if m.singlePanelMode {
|
||||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.tracker.requestBack = false
|
||||
m.state.Mode = paletteModeList
|
||||
m.state.Message = m.tracker.currentStatus()
|
||||
|
|
@ -950,6 +1034,36 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, cmd
|
||||
}
|
||||
if m.state.Mode == paletteModeGoals {
|
||||
if m.goals == nil {
|
||||
cmd, err := m.openGoalsPanel()
|
||||
if err != nil {
|
||||
m.state.Mode = paletteModeList
|
||||
m.state.Message = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
model, cmd := m.goals.Update(msg)
|
||||
if updated, ok := model.(*goalPanelModel); ok {
|
||||
m.goals = updated
|
||||
}
|
||||
if m.goals.requestClose {
|
||||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.goals.requestBack {
|
||||
if m.singlePanelMode {
|
||||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.goals.requestBack = false
|
||||
m.state.Mode = paletteModeList
|
||||
m.state.Message = m.goals.currentStatus()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
switch m.state.Mode {
|
||||
case paletteModePrompt:
|
||||
return m.updatePrompt(key)
|
||||
|
|
@ -1030,6 +1144,23 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, cmd
|
||||
}
|
||||
if m.state.Mode == paletteModeGoals && m.goals != nil {
|
||||
model, cmd := m.goals.Update(msg)
|
||||
if updated, ok := model.(*goalPanelModel); ok {
|
||||
m.goals = updated
|
||||
}
|
||||
if m.goals.requestClose {
|
||||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.goals.requestBack {
|
||||
m.goals.requestBack = false
|
||||
m.state.Mode = paletteModeList
|
||||
m.state.Message = m.goals.currentStatus()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
|
@ -1051,6 +1182,14 @@ func (m *paletteModel) updateList(key string) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
if key == "alt+r" {
|
||||
cmd, err := m.openGoalsPanel()
|
||||
if err != nil {
|
||||
m.state.Message = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
if key == "alt+d" {
|
||||
cmd, err := m.openTrackerPanel()
|
||||
if err != nil {
|
||||
m.state.Message = err.Error()
|
||||
|
|
@ -1145,6 +1284,13 @@ func (m *paletteModel) selectAction(action paletteAction) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
case paletteActionOpenGoals:
|
||||
cmd, err := m.openGoalsPanel()
|
||||
if err != nil {
|
||||
m.state.Message = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
case paletteActionOpenTodos:
|
||||
if err := m.openTodosPanel(); err != nil {
|
||||
m.state.Message = err.Error()
|
||||
|
|
@ -1496,6 +1642,14 @@ func (m *paletteModel) View() string {
|
|||
}
|
||||
return styles.muted.Render("Tracker unavailable")
|
||||
}
|
||||
if m.state.Mode == paletteModeGoals {
|
||||
if m.goals != nil {
|
||||
m.goals.width = width
|
||||
m.goals.height = height
|
||||
return m.goals.View()
|
||||
}
|
||||
return styles.muted.Render("Goals unavailable")
|
||||
}
|
||||
return m.renderListView(styles, width, height)
|
||||
}
|
||||
|
||||
|
|
@ -2199,9 +2353,9 @@ func renderPaletteFooter(styles paletteStyles, width int, message string, showAl
|
|||
{{"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
|
||||
},
|
||||
[][][2]string{
|
||||
{{"Alt-U/E", "move"}, {"Alt-I", "run"}, {"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-A", "activity"}, {"Alt-P", "snippets"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||||
{{"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-A", "activity"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||||
{{"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-S", "close"}},
|
||||
{{"Alt-U/E", "move"}, {"Alt-I", "run"}, {"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-A", "activity"}, {"Alt-P", "snippets"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||||
{{"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-A", "activity"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||||
{{"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-S", "close"}},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const (
|
|||
statusRightModuleTodos = "todos"
|
||||
statusRightModuleFlashMoe = "flash_moe"
|
||||
statusRightModuleHost = "host"
|
||||
statusRightModuleGoal = "goal"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -37,6 +38,7 @@ const (
|
|||
statusIconAgent = ""
|
||||
statusIconTodos = ""
|
||||
statusIconFlashMoe = ""
|
||||
statusIconGoal = "⌖"
|
||||
)
|
||||
|
||||
func statusRightModules() []string {
|
||||
|
|
@ -50,6 +52,7 @@ func statusRightModules() []string {
|
|||
statusRightModuleTodoPreview,
|
||||
statusRightModuleFlashMoe,
|
||||
statusRightModuleHost,
|
||||
statusRightModuleGoal,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +204,11 @@ func renderTmuxRightStatus(args tmuxRightStatusArgs) string {
|
|||
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#cc6666", Text: label, Bold: true})
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleGoal) {
|
||||
if label := loadGoalStatusLabel(args.WindowID); label != "" {
|
||||
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#a3be8c", Text: label, Bold: true})
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleFlashMoe) {
|
||||
if segment, ok := loadFlashMoeStatusSegment(); ok {
|
||||
segments = append(segments, segment)
|
||||
|
|
@ -532,6 +540,48 @@ func loadAgentStatusLabel(windowID string) string {
|
|||
return fmt.Sprintf(" %s %s ", statusIconAgent, device)
|
||||
}
|
||||
|
||||
// loadGoalStatusLabel renders the focused window's goal path + thread name.
|
||||
func loadGoalStatusLabel(windowID string) string {
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
if windowID == "" {
|
||||
return ""
|
||||
}
|
||||
store, err := loadGoalStore()
|
||||
if err != nil || store == nil {
|
||||
return ""
|
||||
}
|
||||
thread := findThreadByWindow(store, windowID)
|
||||
if thread == nil {
|
||||
return ""
|
||||
}
|
||||
path := goalPathTitles(store, thread.GoalID)
|
||||
if len(path) == 0 {
|
||||
return ""
|
||||
}
|
||||
label := strings.Join(path, " › ")
|
||||
return fmt.Sprintf(" %s %s ", statusIconGoal, truncate(label, 40))
|
||||
}
|
||||
|
||||
// goalPathTitles returns ancestor goal titles from root to the given goal.
|
||||
func goalPathTitles(store *GoalStore, goalID string) []string {
|
||||
goalID = strings.TrimSpace(goalID)
|
||||
if goalID == "" || store == nil {
|
||||
return nil
|
||||
}
|
||||
var titles []string
|
||||
seen := map[string]bool{}
|
||||
for goalID != "" && !seen[goalID] {
|
||||
g, ok := store.Goals[goalID]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
seen[goalID] = true
|
||||
titles = append([]string{strings.TrimSpace(g.Title)}, titles...)
|
||||
goalID = strings.TrimSpace(g.ParentID)
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
func refreshMemoryUsageCache() {
|
||||
script := statusMemoryCacheRefreshScript()
|
||||
if strings.TrimSpace(script) == "" || !fileExists(script) {
|
||||
|
|
@ -690,7 +740,7 @@ func loadHostStatusLabel() string {
|
|||
|
||||
func defaultStatusRightModuleEnabled(module string) bool {
|
||||
switch module {
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost:
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost, statusRightModuleGoal:
|
||||
return true
|
||||
case statusRightModuleMemoryTotals:
|
||||
return true
|
||||
|
|
@ -701,7 +751,7 @@ func defaultStatusRightModuleEnabled(module string) bool {
|
|||
|
||||
func isValidStatusRightModule(module string) bool {
|
||||
switch module {
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost:
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost, statusRightModuleGoal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
@ -739,6 +789,8 @@ func (cfg statusRightConfig) moduleEnabled(module string) bool {
|
|||
return derefBool(cfg.FlashMoe, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleHost:
|
||||
return derefBool(cfg.Host, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleGoal:
|
||||
return derefBool(cfg.Goal, defaultStatusRightModuleEnabled(module))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
@ -784,6 +836,8 @@ func (cfg *statusRightConfig) setModuleEnabled(module string, enabled bool) {
|
|||
cfg.FlashMoe = value
|
||||
case statusRightModuleHost:
|
||||
cfg.Host = value
|
||||
case statusRightModuleGoal:
|
||||
cfg.Goal = value
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -791,7 +845,7 @@ func (cfg *statusRightConfig) isDefault() bool {
|
|||
if cfg == nil {
|
||||
return true
|
||||
}
|
||||
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.TodoPreview == nil && cfg.Todos == nil && cfg.FlashMoe == nil && cfg.Host == nil
|
||||
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.TodoPreview == nil && cfg.Todos == nil && cfg.FlashMoe == nil && cfg.Host == nil && cfg.Goal == nil
|
||||
}
|
||||
|
||||
func derefBool(value *bool, fallback bool) bool {
|
||||
|
|
|
|||
|
|
@ -30,10 +30,9 @@ type tmuxTodoItem struct {
|
|||
}
|
||||
|
||||
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"`
|
||||
Version int `json:"version"`
|
||||
Global []tmuxTodoItem `json:"global,omitempty"`
|
||||
Windows map[string][]tmuxTodoItem `json:"windows,omitempty"`
|
||||
}
|
||||
|
||||
type tmuxTodoListFile struct {
|
||||
|
|
@ -78,20 +77,12 @@ func normalizeTmuxTodoStore(store *tmuxTodoStore) *tmuxTodoStore {
|
|||
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)
|
||||
|
|
@ -143,8 +134,6 @@ func bootstrapTmuxTodoStore() (*tmuxTodoStore, error) {
|
|||
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:
|
||||
|
|
@ -155,8 +144,6 @@ func todoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string) []
|
|||
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:
|
||||
|
|
@ -203,20 +190,12 @@ func importLegacyYamlTodos(store *tmuxTodoStore) error {
|
|||
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 := todoScopeGlobal
|
||||
scopeID := "global"
|
||||
switch {
|
||||
case name == "global.yaml":
|
||||
scope = todoScopeGlobal
|
||||
case strings.HasPrefix(name, "window_") && strings.HasSuffix(name, ".yaml"):
|
||||
scope = todoScopeWindow
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(name, "window_"), ".yaml")
|
||||
if strings.HasPrefix(id, "_") {
|
||||
|
|
@ -252,25 +231,6 @@ func collectAllTmuxTodos(currentSessionID, currentWindowID string) []tmuxTodoEnt
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func runTrackerCommand(args []string) error {
|
|||
}
|
||||
command := strings.TrimSpace(rest[0])
|
||||
switch command {
|
||||
case "start_task", "finish_task", "update_task", "acknowledge", "delete_task", "notify":
|
||||
case "start_task", "finish_task", "update_task", "update_phase", "acknowledge", "delete_task", "notify":
|
||||
ctx, err := resolveTrackerContext(env.Session, env.SessionID, env.Window, env.WindowID, env.Pane)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -601,6 +601,7 @@ func sendTrackerCommand(command string, env *ipc.Envelope) error {
|
|||
request.Pane = strings.TrimSpace(env.Pane)
|
||||
request.Summary = strings.TrimSpace(env.Summary)
|
||||
request.Message = strings.TrimSpace(env.Message)
|
||||
request.Phase = strings.TrimSpace(env.Phase)
|
||||
}
|
||||
enc := json.NewEncoder(conn)
|
||||
if err := enc.Encode(&request); err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue