diff --git a/.gitignore b/.gitignore index 00cff47..12bd986 100755 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,5 @@ agent-tracker/run !starship-tmux.toml !/opencode/ +/agent-tracker/bin/agent +/agent-tracker/tracker-server diff --git a/.tmux.conf b/.tmux.conf index 018d6a5..b664def 100644 --- a/.tmux.conf +++ b/.tmux.conf @@ -14,6 +14,8 @@ set -g mouse on set -sg exit-empty on set -g detach-on-destroy off set -g allow-passthrough on +set -g default-shell /bin/zsh +set -gu default-command set -q -g status-utf8 on setw -q -g utf8 on @@ -42,23 +44,27 @@ TERM TERM_PROGRAM \ # -- Hooks (unset before setting to avoid duplicates on reload) set-hook -gu client-attached -set-hook -g client-attached 'run -b "test -x ~/.config/agent-tracker/bin/tracker-client && ~/.config/agent-tracker/bin/tracker-client command acknowledge --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id} || true"' +set-hook -g client-attached 'run -b "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tracker command acknowledge --client #{q:client_tty} --session-id #{q:session_id} --window-id #{q:window_id} --pane #{q:pane_id} || true"' set-hook -ag client-attached 'run -b "tmux refresh-client -S"' set-hook -gu pane-focus-in set-hook -g pane-focus-in "run -b 'bash ~/.config/tmux/fzf_panes.tmux update_mru_pane_ids'" -set-hook -ag pane-focus-in 'run -b "test -x ~/.config/agent-tracker/bin/tracker-client && ~/.config/agent-tracker/bin/tracker-client command acknowledge --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id} || true"' +set-hook -ag pane-focus-in 'run -b "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tmux on-focus --session #{q:session_id} --window #{q:window_id} --pane #{q:pane_id} || true"' +set-hook -ag pane-focus-in 'run -b "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tracker command acknowledge --client #{q:client_tty} --session-id #{q:session_id} --window-id #{q:window_id} --pane #{q:pane_id} || true"' set-hook -ag pane-focus-in 'run -b "tmux set -wu -t #{window_id} @unread 2>/dev/null || true"' set-hook -ag pane-focus-in 'run -b "tmux refresh-client -S"' set-hook -gu pane-died -set-hook -g pane-died 'run -b "test -x ~/.config/agent-tracker/bin/tracker-client && ~/.config/agent-tracker/bin/tracker-client command delete_task --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id} || true"' -set-hook -ag pane-died 'run -b "test -x ~/.config/agent-tracker/bin/tracker-client && ~/.config/agent-tracker/bin/tracker-client command note_archive_pane --client #{client_tty} --session-id #{session_id} --window-id #{window_id} --pane #{pane_id} || true"' +set-hook -g pane-died 'run -b "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tracker command delete_task --client #{q:client_tty} --session-id #{q:session_id} --window-id #{q:window_id} --pane #{q:pane_id} || true"' # -- Project-specific window activation hooks # Checks for ./on-tmux-window-activate.sh or ../on-tmux-window-activate.sh and runs it set-hook -gu after-select-window set-hook -g after-select-window 'run-shell "~/.config/tmux/scripts/check_and_run_on_activate.sh \"#{pane_current_path}\""' +set-hook -ag after-select-window 'run -b "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tmux on-focus --session #{q:session_id} --window #{q:window_id} --pane #{q:pane_id} || true"' +set-hook -ag after-select-window 'run -b "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tracker command acknowledge --client #{q:client_tty} --session-id #{q:session_id} --window-id #{q:window_id} --pane #{q:pane_id} || true"' +set-hook -ag after-select-window 'run -b "tmux set -wu -t #{window_id} @unread 2>/dev/null || true"' +set-hook -ag after-select-window 'run -b "tmux refresh-client -S"' # set-hook -ag window-linked 'run -b "tmux refresh-client -S"' # set-hook -ag window-unlinked 'run -b "tmux refresh-client -S"' @@ -66,9 +72,12 @@ set-hook -g after-select-window 'run-shell "~/.config/tmux/scripts/check_and_run # set-hook -ag window-layout-changed 'run -b "tmux refresh-client -S"' # set-hook -ag after-kill-window 'run -b "tmux refresh-client -S"' # -# set-hook -gu client-session-changed -# set-hook -g client-session-changed 'run -b "tmux refresh-client -S"' -# +set-hook -gu client-session-changed +set-hook -g client-session-changed 'run -b "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tmux on-focus --session #{q:session_id} --window #{q:window_id} --pane #{q:pane_id} || true"' +set-hook -ag client-session-changed 'run -b "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tracker command acknowledge --client #{q:client_tty} --session-id #{q:session_id} --window-id #{q:window_id} --pane #{q:pane_id} || true"' +set-hook -ag client-session-changed 'run -b "tmux set -wu -t #{window_id} @unread 2>/dev/null || true"' +set-hook -ag client-session-changed 'run -b "tmux refresh-client -S"' + set-hook -gu session-created set-hook -ag session-created 'run -b "~/.config/tmux/scripts/session_created.sh"' set-hook -ag session-created 'run -b "tmux refresh-client -S"' @@ -102,14 +111,13 @@ set -g display-time 2000 # create session bind C-c new-session -bind -n M-S run-shell "~/.config/tmux/scripts/new_session.sh" +bind O run-shell "~/.config/tmux/scripts/restart_opencode_pane.sh \"#{pane_id}\"" +bind -n M-S run-shell "~/.config/tmux/scripts/new_session.sh #{q:session_id}" bind -n M-O break-pane -bind -n M-s run-shell "~/.config/tmux/scripts/toggle_scratchpad.sh" # window management -bind -n M-o new-window -c "#{pane_current_path}" +bind -n M-o new-window -a -c "#{pane_current_path}" bind -n M-Q kill-pane -bind -n M-t run-shell "test -x ~/.config/agent-tracker/bin/tracker-client && ~/.config/agent-tracker/bin/tracker-client command toggle --client '#{client_tty}' || true" bind . command-prompt -p "Rename session to:" "run-shell \"~/.config/tmux/scripts/rename_session_prompt.sh '%%'\"" unbind , bind , command-prompt -p "Rename window:" "rename-window '%%'" @@ -183,6 +191,11 @@ bind -n M-n select-pane -L bind -n M-e select-pane -D bind -n M-u select-pane -U bind -n M-i select-pane -R +bind -n M-a run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh left "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true' +bind -n M-g run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh top-right "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true' +bind -n M-s run-shell "~/.config/tmux/scripts/open_agent_palette.sh '#{client_tty}' '#{window_id}' '#{@agent_id}' '#{pane_current_path}' '#{session_name}' '#{window_name}'" +bind -n M-d run-shell "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tmux focus --window '#{window_id}' dashboard || true" +bind -n M-r run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh bottom-right "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true' bind > swap-pane -D bind < swap-pane -U bind | swap-pane @@ -190,6 +203,9 @@ bind -n M-b run-shell 'val=$(tmux show -wv @unread 2>/dev/null); if [ "$val" = " bind -n M-w run-shell -b 'val=$(tmux show -wv @watching 2>/dev/null); if [ "$val" = "1" ]; then tmux set -wu @watching; tmux refresh-client -S; else ~/.config/tmux/scripts/watch_pane.sh "#{pane_id}" "#{window_id}"; fi' bind -n M-m run-shell "test -f ~/.config/agent-tracker/scripts/focus_latest_notified.sh && bash ~/.config/agent-tracker/scripts/focus_latest_notified.sh || true" bind -n M-M run-shell "test -f ~/.config/agent-tracker/scripts/focus_last_origin.sh && bash ~/.config/agent-tracker/scripts/focus_last_origin.sh || true" +unbind -nq M-p +unbind -nq M-P +bind P run-shell "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tracker command -client '#{client_tty}' notifications_toggle || true" bind Space run-shell "~/.config/tmux/scripts/toggle_orientation.sh" bind W choose-tree -Z @@ -229,8 +245,10 @@ bind p paste-buffer bind l run-shell "~/.config/tmux/scripts/move_session.sh left" bind y run-shell "~/.config/tmux/scripts/move_session.sh right" -bind -n M-l swap-window -d -t :-1 -bind -n M-y swap-window -d -t :+1 +bind -n M-l previous-window +bind -n M-y next-window +bind -n M-L run-shell "bash ~/.config/tmux/scripts/swap_window_in_session.sh left" +bind -n M-Y run-shell "bash ~/.config/tmux/scripts/swap_window_in_session.sh right" bind I run-shell "~/.config/tmux/scripts/layout_builder.sh right" bind N run-shell "~/.config/tmux/scripts/layout_builder.sh left" @@ -249,7 +267,7 @@ bind C-g if-shell '[[ $(tmux showw synchronize-panes | cut -d\ -f2) == "on" ]]' # panes run-shell "~/.config/tmux/scripts/update_theme_color.sh" setw -g pane-border-status top -set -g pane-scrollbars on +set -g pane-scrollbars off set -g pane-scrollbars-position right set -g pane-active-border-style fg=#b294bb set -g pane-border-style fg=colour244 @@ -261,8 +279,9 @@ setw -g pane-border-format '#{?pane_active, #[fg=#{@theme_color}]#[bg=#{@them # windows set -g status-justify 'left' +set -g status-interval 1 set -g status-left-length 90 -set -g status-right-length 140 +set -g status-right-length 170 setw -g window-status-separator '' # default statusbar colors @@ -271,14 +290,14 @@ set -g status-bg black #set -g status-left '#[fg=brightyellow] #{?client_prefix,⌨ , } #[fg=magenta,bold] %Y-%m-%d %H:%M ' #set -g status-right '#(rainbarf --battery --remaining --bolt --tmux --rgb)' #set -g status-left "#[fg=magenta,bold] %Y-%m-%d %H:%M | #[fg=brightblue]#(curl icanhazip.com) #(ifconfig en0 | grep 'inet ' | awk '{print \"en0 \" $2}') #(ifconfig en1 | grep 'inet ' | awk '{print \"en1 \" $2}') #(ifconfig en3 | grep 'inet ' | awk '{print \"en3 \" $2}') #(ifconfig tun0 | grep 'inet ' | awk '{print \"vpn \" $2}') " -setw -g window-status-format '#[fg=#c5c8c6] #W#(~/.config/tmux/tmux-status/window_task_icon.sh "#{window_id}") ' -setw -g window-status-current-format '#[fg=#{@theme_color},bold] #W#(~/.config/tmux/tmux-status/window_task_icon.sh "#{window_id}") ' +setw -g window-status-format '#[fg=#c5c8c6] #W#(~/.config/tmux/tmux-status/window_task_icon.sh "#{window_id}" "#{@unread}" "#{@watching}") ' +setw -g window-status-current-format '#[fg=#{@theme_color},bold] #W#(~/.config/tmux/tmux-status/window_task_icon.sh "#{window_id}" "#{@unread}" "#{@watching}") ' setw -g window-status-activity-style bg=black setw -g window-status-bell-style bg=black #set-window-option -g window-status-current-format "#[fg=colour235, bg=colour27]⮀#[fg=colour255, bg=colour27] #I ⮁ #W #[fg=colour27, bg=colour235]⮀" -set-option -g status-left "#(~/.config/tmux/tmux-status/left.sh \"#{session_id}\" \"#{session_name}\") " -set-option -g status-right "#(~/.config/tmux/tmux-status/right.sh)" +set-option -g status-left "#(~/.config/tmux/tmux-status/left.sh #{q:session_id} #{q:session_name} #{q:client_width} #{q:status-bg}) " +set-option -g status-right "#(~/.config/tmux/tmux-status/right.sh \"#{client_width}\" \"#{status-bg}\" \"#{session_name}\" \"#{window_index}\" \"#{pane_id}\" \"#{window_id}\")" # session index switching @@ -315,12 +334,13 @@ set -g @tpm-install 'M-F12' set -g @tpm-update 'M-F12' set -g @tpm-clean 'M-F12' -# Resurrect: save pane contents, custom save key set -g @resurrect-capture-pane-contents 'on' -set -g @resurrect-save 's' +set -g @resurrect-processes 'script lazygit yazi' +set -g @resurrect-hook-post-restore-all '~/.config/tmux/scripts/post_resurrect_restore.sh' # Continuum: auto-save every 5 minutes set -g @continuum-save-interval '5' +set -g @continuum-restore 'off' # Initialize TPM (keep at very bottom) run '~/.tmux/plugins/tpm/tpm' diff --git a/agent-tracker/agent-config.json b/agent-tracker/agent-config.json new file mode 100644 index 0000000..3521101 --- /dev/null +++ b/agent-tracker/agent-config.json @@ -0,0 +1,27 @@ +{ + "keys": { + "move_left": "n", + "move_right": "i", + "move_up": "u", + "move_down": "e", + "edit": "Enter", + "cancel": "Escape", + "add_todo": "a", + "toggle_todo": "x", + "destroy": "D", + "confirm": "y", + "back": "Escape", + "delete_todo": "d", + "help": "?", + "focus_ai": "M-a", + "focus_git": "M-g", + "focus_dashboard": "M-s", + "focus_run": "M-r" + }, + "devices": [ + "web-server", + "ipm", + "opm", + "macos" + ] +} diff --git a/agent-tracker/cmd/agent/activity_monitor.go b/agent-tracker/cmd/agent/activity_monitor.go new file mode 100644 index 0000000..e7e737f --- /dev/null +++ b/agent-tracker/cmd/agent/activity_monitor.go @@ -0,0 +1,849 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + "unicode" +) + +var errClosePalette = errors.New("close palette") + +type activitySortKey int + +const ( + activitySortCPU activitySortKey = iota + activitySortMemory + activitySortDownload + activitySortUpload + activitySortPorts + activitySortLocation + activitySortCommand +) + +var activitySortKeys = []activitySortKey{ + activitySortCPU, + activitySortMemory, + activitySortDownload, + activitySortUpload, + activitySortCommand, + activitySortLocation, + activitySortPorts, +} + +type activitySnapshot struct { + Processes map[int]*activityProcess + MemoryByPID map[int]activityMemory + NetworkByPID map[int]activityNetwork + PortsByPID map[int][]string + TmuxByPanePID map[int]*activityTmuxLocation + LastProcessLoad time.Time + LastMemoryLoad time.Time + LastNetworkLoad time.Time + LastPortLoad time.Time + LastTmuxLoad time.Time + RefreshedAt time.Time + Status string +} + +type activityRefreshInput struct { + Force bool + Initial bool + ShowAll bool + Processes map[int]*activityProcess + MemoryByPID map[int]activityMemory + NetworkByPID map[int]activityNetwork + PortsByPID map[int][]string + TmuxByPanePID map[int]*activityTmuxLocation + LastProcessLoad time.Time + LastMemoryLoad time.Time + LastNetworkLoad time.Time + LastPortLoad time.Time + LastTmuxLoad time.Time + RefreshedAt time.Time +} + +type activityMemory struct { + ResidentMB float64 + CompressedMB float64 +} + +type activityNetwork struct { + PID int + BytesIn int64 + BytesOut int64 + DownKBps float64 + UpKBps float64 +} + +type activityTmuxLocation struct { + SessionID string + SessionName string + WindowID string + WindowIndex string + WindowName string + PaneID string + PaneIndex string + RootPID int +} + +type activityProcess struct { + PID int + PPID int + CPU float64 + RSSKB int64 + ResidentMB float64 + CompressedMB float64 + BytesIn int64 + BytesOut int64 + DownKBps float64 + UpKBps float64 + State string + Elapsed string + Command string + ShortCommand string + Ports []string + Tmux *activityTmuxLocation + Parent *activityProcess + Children []*activityProcess +} + +type activityRow struct { + PID int +} + +func collectActivitySnapshot(input activityRefreshInput) (*activitySnapshot, error) { + now := time.Now() + snapshot := &activitySnapshot{ + Processes: cloneActivityProcesses(input.Processes), + MemoryByPID: cloneActivityMemoryMap(input.MemoryByPID), + NetworkByPID: cloneActivityNetworkMap(input.NetworkByPID), + PortsByPID: cloneActivityPortsMap(input.PortsByPID), + TmuxByPanePID: cloneActivityTmuxMap(input.TmuxByPanePID), + LastProcessLoad: input.LastProcessLoad, + LastMemoryLoad: input.LastMemoryLoad, + LastNetworkLoad: input.LastNetworkLoad, + LastPortLoad: input.LastPortLoad, + LastTmuxLoad: input.LastTmuxLoad, + RefreshedAt: input.RefreshedAt, + } + nonFatal := []string{} + needProcessLoad := input.Initial || input.Force || now.Sub(input.LastProcessLoad) >= time.Second + if input.Initial || input.Force || now.Sub(input.LastTmuxLoad) >= 4*time.Second { + values, err := loadActivityTmuxPanes() + if err != nil { + nonFatal = append(nonFatal, "tmux data unavailable") + } else { + snapshot.TmuxByPanePID = values + snapshot.LastTmuxLoad = now + needProcessLoad = true + } + } + if input.Initial || input.Force || now.Sub(input.LastMemoryLoad) >= 5*time.Second { + values, err := loadActivityMemory() + if err != nil { + nonFatal = append(nonFatal, "memory data unavailable") + } else { + snapshot.MemoryByPID = values + snapshot.LastMemoryLoad = now + needProcessLoad = true + } + } + if input.Initial || input.Force || now.Sub(input.LastNetworkLoad) >= 2*time.Second { + values, err := loadActivityNetwork(snapshot.NetworkByPID, input.LastNetworkLoad, now) + if err != nil { + nonFatal = append(nonFatal, "network data unavailable") + } else { + snapshot.NetworkByPID = values + snapshot.LastNetworkLoad = now + needProcessLoad = true + } + } + if needProcessLoad { + processes, err := loadActivityProcesses(snapshot.MemoryByPID, snapshot.TmuxByPanePID) + if err != nil { + return nil, err + } + snapshot.Processes = processes + snapshot.LastProcessLoad = now + snapshot.RefreshedAt = now + } + applyActivityNetwork(snapshot.Processes, snapshot.NetworkByPID) + applyActivityPorts(snapshot.Processes, snapshot.PortsByPID) + if !input.Initial && (input.Force || now.Sub(input.LastPortLoad) >= 5*time.Second) { + values, err := loadActivityPorts(activityPortTargetPIDs(snapshot.Processes, input.ShowAll)) + if err != nil { + nonFatal = append(nonFatal, "port data unavailable") + } else { + snapshot.PortsByPID = values + snapshot.LastPortLoad = now + applyActivityPorts(snapshot.Processes, values) + } + } + if len(nonFatal) > 0 { + snapshot.Status = strings.Join(nonFatal, "; ") + } + return snapshot, nil +} + +func cloneActivityMemoryMap(values map[int]activityMemory) map[int]activityMemory { + cloned := make(map[int]activityMemory, len(values)) + for pid, value := range values { + cloned[pid] = value + } + return cloned +} + +func cloneActivityNetworkMap(values map[int]activityNetwork) map[int]activityNetwork { + cloned := make(map[int]activityNetwork, len(values)) + for pid, value := range values { + cloned[pid] = value + } + return cloned +} + +func cloneActivityPortsMap(values map[int][]string) map[int][]string { + cloned := make(map[int][]string, len(values)) + for pid, ports := range values { + cloned[pid] = append([]string(nil), ports...) + } + return cloned +} + +func cloneActivityTmuxMap(values map[int]*activityTmuxLocation) map[int]*activityTmuxLocation { + cloned := make(map[int]*activityTmuxLocation, len(values)) + for pid, value := range values { + if value == nil { + continue + } + copyValue := *value + cloned[pid] = ©Value + } + return cloned +} + +func cloneActivityProcesses(values map[int]*activityProcess) map[int]*activityProcess { + cloned := make(map[int]*activityProcess, len(values)) + for pid, proc := range values { + if proc == nil { + continue + } + copyProc := *proc + copyProc.Parent = nil + copyProc.Children = nil + copyProc.Ports = append([]string(nil), proc.Ports...) + cloned[pid] = ©Proc + } + return cloned +} + +func activityPortTargetPIDs(processes map[int]*activityProcess, showAll bool) []int { + pids := make([]int, 0, len(processes)) + for pid, proc := range processes { + if proc == nil { + continue + } + if !showAll && proc.Tmux == nil { + continue + } + pids = append(pids, pid) + } + sort.Ints(pids) + return pids +} + +func loadActivityProcesses(memoryByPID map[int]activityMemory, tmuxByPanePID map[int]*activityTmuxLocation) (map[int]*activityProcess, error) { + out, err := runActivityCommand(2*time.Second, "ps", "axww", "-o", "pid=", "-o", "ppid=", "-o", "%cpu=", "-o", "rss=", "-o", "state=", "-o", "etime=", "-o", "command=") + if err != nil && strings.TrimSpace(out) == "" { + return nil, err + } + processes := map[int]*activityProcess{} + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 7 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil || pid <= 0 { + continue + } + if pid == 1 { + continue + } + ppid, _ := strconv.Atoi(fields[1]) + cpu, _ := strconv.ParseFloat(fields[2], 64) + rss, _ := strconv.ParseInt(fields[3], 10, 64) + command := strings.Join(fields[6:], " ") + short := activityShortCommand(command) + memory := memoryByPID[pid] + processes[pid] = &activityProcess{ + PID: pid, + PPID: ppid, + CPU: cpu, + RSSKB: rss, + ResidentMB: memory.ResidentMB, + CompressedMB: memory.CompressedMB, + State: fields[4], + Elapsed: fields[5], + Command: command, + ShortCommand: short, + } + } + for _, proc := range processes { + if parent := processes[proc.PPID]; parent != nil && parent.PID != proc.PID { + proc.Parent = parent + parent.Children = append(parent.Children, proc) + } + } + assignActivityTmuxLocations(processes, tmuxByPanePID) + return processes, nil +} + +func applyActivityPorts(processes map[int]*activityProcess, portsByPID map[int][]string) { + for pid, proc := range processes { + proc.Ports = append([]string(nil), portsByPID[pid]...) + } +} + +func applyActivityNetwork(processes map[int]*activityProcess, networkByPID map[int]activityNetwork) { + for pid, proc := range processes { + network := networkByPID[pid] + proc.BytesIn = network.BytesIn + proc.BytesOut = network.BytesOut + proc.DownKBps = network.DownKBps + proc.UpKBps = network.UpKBps + } +} + +func assignActivityTmuxLocations(processes map[int]*activityProcess, tmuxByPanePID map[int]*activityTmuxLocation) { + panePIDs := make([]int, 0, len(tmuxByPanePID)) + for pid := range tmuxByPanePID { + panePIDs = append(panePIDs, pid) + } + sort.Ints(panePIDs) + var assign func(int, *activityTmuxLocation) + assign = func(pid int, loc *activityTmuxLocation) { + proc := processes[pid] + if proc == nil || proc.Tmux != nil { + return + } + proc.Tmux = loc + for _, child := range proc.Children { + assign(child.PID, loc) + } + } + for _, pid := range panePIDs { + assign(pid, tmuxByPanePID[pid]) + } +} + +func loadActivityMemory() (map[int]activityMemory, error) { + out, err := runActivityCommand(2500*time.Millisecond, "top", "-l", "1", "-o", "mem", "-n", "500", "-stats", "pid,mem,cmprs") + if err != nil && strings.TrimSpace(out) == "" { + return nil, err + } + values := map[int]activityMemory{} + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 3 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil || pid <= 0 { + continue + } + values[pid] = activityMemory{ + ResidentMB: parseActivityMemoryMB(fields[1]), + CompressedMB: parseActivityMemoryMB(fields[2]), + } + } + if len(values) == 0 && err != nil { + return nil, err + } + return values, nil +} + +func loadActivityTmuxPanes() (map[int]*activityTmuxLocation, error) { + out, err := runActivityCommand(1500*time.Millisecond, "tmux", "list-panes", "-a", "-F", "#{session_id}\t#{session_name}\t#{window_id}\t#{window_index}\t#{window_name}\t#{pane_id}\t#{pane_index}\t#{pane_pid}") + if err != nil && strings.TrimSpace(out) == "" { + return nil, err + } + values := map[int]*activityTmuxLocation{} + for _, line := range strings.Split(out, "\n") { + parts := strings.Split(strings.TrimSpace(line), "\t") + if len(parts) != 8 { + continue + } + pid, err := strconv.Atoi(strings.TrimSpace(parts[7])) + if err != nil || pid <= 0 { + continue + } + values[pid] = &activityTmuxLocation{ + SessionID: strings.TrimSpace(parts[0]), + SessionName: strings.TrimSpace(parts[1]), + WindowID: strings.TrimSpace(parts[2]), + WindowIndex: strings.TrimSpace(parts[3]), + WindowName: strings.TrimSpace(parts[4]), + PaneID: strings.TrimSpace(parts[5]), + PaneIndex: strings.TrimSpace(parts[6]), + RootPID: pid, + } + } + return values, nil +} + +func loadActivityPorts(pids []int) (map[int][]string, error) { + values := map[int][]string{} + if len(pids) == 0 { + return values, nil + } + pidList := make([]string, 0, len(pids)) + for _, pid := range pids { + if pid > 0 { + pidList = append(pidList, strconv.Itoa(pid)) + } + } + if len(pidList) == 0 { + return values, nil + } + commands := [][]string{ + {"-nP", "-a", "-p", strings.Join(pidList, ","), "-iTCP", "-sTCP:LISTEN", "-F", "pn"}, + {"-nP", "-a", "-p", strings.Join(pidList, ","), "-iUDP", "-F", "pn"}, + } + seen := map[int]map[string]bool{} + for _, args := range commands { + out, err := runActivityCommand(1800*time.Millisecond, "lsof", args...) + if err != nil && strings.TrimSpace(out) == "" { + continue + } + currentPID := 0 + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + switch line[0] { + case 'p': + pid, err := strconv.Atoi(strings.TrimSpace(line[1:])) + if err != nil || pid <= 0 { + currentPID = 0 + continue + } + currentPID = pid + case 'n': + if currentPID <= 0 { + continue + } + label := normalizeActivityPort(strings.TrimSpace(line[1:])) + if label == "" { + continue + } + if seen[currentPID] == nil { + seen[currentPID] = map[string]bool{} + } + if seen[currentPID][label] { + continue + } + seen[currentPID][label] = true + values[currentPID] = append(values[currentPID], label) + } + } + } + for pid := range values { + sort.Strings(values[pid]) + } + return values, nil +} + +func loadActivityNetwork(previous map[int]activityNetwork, lastLoad, now time.Time) (map[int]activityNetwork, error) { + out, err := runActivityCommand(2*time.Second, "nettop", "-P", "-L", "1", "-x", "-J", "bytes_in,bytes_out") + if err != nil && strings.TrimSpace(out) == "" { + return nil, err + } + values := map[int]activityNetwork{} + elapsed := now.Sub(lastLoad).Seconds() + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, ",bytes_in,") { + continue + } + current, ok := parseActivityNetworkLine(line) + if !ok { + continue + } + if previousValue, ok := previous[current.PID]; ok && elapsed > 0 { + if current.BytesIn >= previousValue.BytesIn { + current.DownKBps = float64(current.BytesIn-previousValue.BytesIn) / 1024.0 / elapsed + } + if current.BytesOut >= previousValue.BytesOut { + current.UpKBps = float64(current.BytesOut-previousValue.BytesOut) / 1024.0 / elapsed + } + } + values[current.PID] = current + } + if len(values) == 0 && err != nil { + return nil, err + } + return values, nil +} + +func parseActivityNetworkLine(line string) (activityNetwork, bool) { + parts := strings.Split(strings.TrimSpace(line), ",") + if len(parts) < 3 { + return activityNetwork{}, false + } + processField := strings.TrimSpace(parts[0]) + lastDot := strings.LastIndex(processField, ".") + if lastDot <= 0 || lastDot >= len(processField)-1 { + return activityNetwork{}, false + } + pid, err := strconv.Atoi(strings.TrimSpace(processField[lastDot+1:])) + if err != nil || pid <= 0 { + return activityNetwork{}, false + } + bytesIn, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if err != nil { + return activityNetwork{}, false + } + bytesOut, err := strconv.ParseInt(strings.TrimSpace(parts[2]), 10, 64) + if err != nil { + return activityNetwork{}, false + } + return activityNetwork{BytesIn: bytesIn, BytesOut: bytesOut, PID: pid}, true +} + +func focusActivityTmuxLocation(loc *activityTmuxLocation) error { + if loc == nil { + return fmt.Errorf("process is not in tmux") + } + if strings.TrimSpace(loc.SessionID) != "" { + if err := runTmux("switch-client", "-t", loc.SessionID); err != nil { + return err + } + } + if strings.TrimSpace(loc.WindowID) != "" { + if err := runTmux("select-window", "-t", loc.WindowID); err != nil { + return err + } + } + if strings.TrimSpace(loc.PaneID) != "" { + if err := runTmux("select-pane", "-t", loc.PaneID); err != nil { + return err + } + } + return nil +} + +func runActivityCommand(timeout time.Duration, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stderr = io.Discard + out, err := cmd.Output() + if ctx.Err() == context.DeadlineExceeded { + return string(out), fmt.Errorf("%s timed out", name) + } + if err != nil { + return string(out), fmt.Errorf("%s failed", name) + } + return string(out), nil +} + +func activityColumnWidths(width int) (int, int, int, int, int, int, int, int) { + cpuW := 6 + memW := 7 + downW := 9 + upW := 9 + procW := 18 + sessionW := 10 + windowW := 12 + portW := 10 + procW = width - cpuW - memW - downW - upW - sessionW - windowW - portW - 7 + for procW < 18 && windowW > 12 { + windowW-- + procW++ + } + for procW < 18 && sessionW > 10 { + sessionW-- + procW++ + } + for procW < 18 && downW > 7 { + downW-- + procW++ + } + for procW < 18 && upW > 7 { + upW-- + procW++ + } + for procW < 18 && portW > 8 { + portW-- + procW++ + } + for procW < 18 && memW > 6 { + memW-- + procW++ + } + if procW < 12 { + procW = 12 + } + return cpuW, memW, downW, upW, procW, sessionW, windowW, portW +} + +func formatActivitySpeed(kbps float64) string { + if kbps <= 0.05 { + return "-" + } + if kbps >= 1024 { + return fmt.Sprintf("%.1fM", kbps/1024) + } + if kbps >= 100 { + return fmt.Sprintf("%.0fK", kbps) + } + if kbps >= 10 { + return fmt.Sprintf("%.1fK", kbps) + } + return fmt.Sprintf("%.2fK", kbps) +} + +func activitySessionLabel(loc *activityTmuxLocation) string { + if loc == nil { + return "-" + } + return blankIfEmpty(loc.SessionName, loc.SessionID) +} + +func activityWindowLabel(loc *activityTmuxLocation, currentWindowID string) string { + if loc == nil { + return "-" + } + label := blankIfEmpty(loc.WindowIndex, "?") + if strings.TrimSpace(loc.WindowName) != "" { + label += " " + loc.WindowName + } + if currentWindowID != "" && loc.WindowID == currentWindowID { + return "*" + label + } + return label +} + +func activityProcessLabel(proc *activityProcess) string { + return blankIfEmpty(proc.ShortCommand, proc.Command) +} + +func activityShortCommand(command string) string { + fields := strings.Fields(strings.TrimSpace(command)) + if len(fields) == 0 { + return "unknown" + } + base := filepath.Base(fields[0]) + if strings.TrimSpace(base) == "" { + return fields[0] + } + return base +} + +func formatActivityMB(mb float64) string { + if mb <= 0 { + return "0M" + } + if mb >= 100 { + gb := mb / 1024 + if gb >= 10 { + return fmt.Sprintf("%.0fG", gb) + } + return fmt.Sprintf("%.1fG", gb) + } + if mb >= 10 { + return fmt.Sprintf("%.0fM", mb) + } + return fmt.Sprintf("%.1fM", mb) +} + +func formatActivityPorts(ports []string) string { + if len(ports) == 0 { + return "-" + } + if len(ports) == 1 { + return ports[0] + } + if len(ports) == 2 { + return ports[0] + "," + ports[1] + } + return fmt.Sprintf("%s,%s+%d", ports[0], ports[1], len(ports)-2) +} + +func normalizeActivityPort(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = strings.Split(value, "->")[0] + fields := strings.Fields(value) + if len(fields) == 0 { + return "" + } + value = fields[0] + if idx := strings.LastIndex(value, "]:"); idx >= 0 { + return value[idx+2:] + } + if idx := strings.LastIndex(value, ":"); idx >= 0 && idx+1 < len(value) { + value = value[idx+1:] + } + if value == "" || value == "*" { + return "" + } + return value +} + +func parseActivityMemoryMB(value string) float64 { + value = strings.TrimSpace(value) + if value == "" || value == "-" { + return 0 + } + unit := byte('M') + last := value[len(value)-1] + if (last >= 'A' && last <= 'Z') || (last >= 'a' && last <= 'z') { + unit = byte(unicode.ToUpper(rune(last))) + value = strings.TrimSpace(value[:len(value)-1]) + } + amount, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0 + } + switch unit { + case 'B': + return amount / 1024 / 1024 + case 'K': + return amount / 1024 + case 'G': + return amount * 1024 + case 'T': + return amount * 1024 * 1024 + default: + return amount + } +} + +func activityResidentMemoryMB(proc *activityProcess) float64 { + if proc == nil { + return 0 + } + return proc.ResidentMB +} + +func activityTotalMemoryMB(proc *activityProcess) float64 { + if proc == nil { + return 0 + } + return activityResidentMemoryMB(proc) + proc.CompressedMB +} + +func defaultActivitySortDescending(key activitySortKey) bool { + switch key { + case activitySortCPU, activitySortMemory, activitySortDownload, activitySortUpload, activitySortPorts: + return true + default: + return false + } +} + +func (key activitySortKey) label() string { + switch key { + case activitySortCPU: + return "CPU" + case activitySortMemory: + return "MEM" + case activitySortDownload: + return "DOWN" + case activitySortUpload: + return "UP" + case activitySortPorts: + return "PORTS" + case activitySortLocation: + return "LOCATION" + default: + return "PROCESS" + } +} + +func compareActivityLocation(left, right *activityTmuxLocation) int { + leftSession := strings.ToLower(activitySessionLabel(left)) + rightSession := strings.ToLower(activitySessionLabel(right)) + if cmp := strings.Compare(leftSession, rightSession); cmp != 0 { + return cmp + } + leftIndex, leftOK := strconv.Atoi(strings.TrimSpace(blankIfEmpty(windowIndexOf(left), ""))) + rightIndex, rightOK := strconv.Atoi(strings.TrimSpace(blankIfEmpty(windowIndexOf(right), ""))) + switch { + case leftOK == nil && rightOK == nil: + if cmp := compareInt(leftIndex, rightIndex); cmp != 0 { + return cmp + } + default: + if cmp := strings.Compare(strings.ToLower(windowIndexOf(left)), strings.ToLower(windowIndexOf(right))); cmp != 0 { + return cmp + } + } + return strings.Compare(strings.ToLower(windowNameOf(left)), strings.ToLower(windowNameOf(right))) +} + +func windowIndexOf(loc *activityTmuxLocation) string { + if loc == nil { + return "" + } + return strings.TrimSpace(loc.WindowIndex) +} + +func windowNameOf(loc *activityTmuxLocation) string { + if loc == nil { + return "" + } + return strings.TrimSpace(loc.WindowName) +} + +func compareFloat64(left, right float64) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} + +func compareInt64(left, right int64) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} + +func compareInt(left, right int) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} + +func blankIfEmpty(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/agent-tracker/cmd/agent/activity_monitor_bubbletea.go b/agent-tracker/cmd/agent/activity_monitor_bubbletea.go new file mode 100644 index 0000000..4e985ae --- /dev/null +++ b/agent-tracker/cmd/agent/activity_monitor_bubbletea.go @@ -0,0 +1,1012 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "sort" + "strings" + "syscall" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type activityMonitorBT struct { + windowID string + embedded bool + sortKey activitySortKey + sortDescending bool + selectedPID int + selectedRow int + rowOffset int + lockSelection bool + showAllProcesses bool + processes map[int]*activityProcess + rows []activityRow + memoryByPID map[int]activityMemory + networkByPID map[int]activityNetwork + portsByPID map[int][]string + tmuxByPanePID map[int]*activityTmuxLocation + lastProcessLoad time.Time + lastMemoryLoad time.Time + lastNetworkLoad time.Time + lastPortLoad time.Time + lastTmuxLoad time.Time + refreshedAt time.Time + status string + statusUntil time.Time + confirmKillPID int + copyOptions []activityCopyOption + copyOptionIndex int + width int + height int + refreshInFlight bool + pendingRefresh bool + pendingForce bool + showAltHints bool + requestBack bool + requestClose bool + styles activityStyles +} + +type activityStyles struct { + title lipgloss.Style + meta lipgloss.Style + header lipgloss.Style + headerActive lipgloss.Style + row lipgloss.Style + rowSelected lipgloss.Style + cell lipgloss.Style + cellSelected lipgloss.Style + detailTitle lipgloss.Style + detailLabel lipgloss.Style + detailValue lipgloss.Style + muted lipgloss.Style + footer lipgloss.Style + status lipgloss.Style + statusBad lipgloss.Style + divider lipgloss.Style + shortcutKey lipgloss.Style + shortcutText lipgloss.Style + modal lipgloss.Style + modalTitle lipgloss.Style + modalBody lipgloss.Style + modalHint lipgloss.Style +} + +type activityTickMsg struct{} + +type activityRefreshMsg struct { + snapshot *activitySnapshot + err error +} + +type activityCopyOption struct { + Label string + Value string +} + +var activityClipboardWriter = writeActivityClipboard + +func newActivityStyles() activityStyles { + accent := lipgloss.Color("223") + cyan := lipgloss.Color("117") + selected := lipgloss.Color("238") + text := lipgloss.Color("252") + muted := lipgloss.Color("245") + bright := lipgloss.Color("230") + warning := lipgloss.Color("203") + success := lipgloss.Color("150") + return activityStyles{ + title: lipgloss.NewStyle().Bold(true).Foreground(bright), + meta: lipgloss.NewStyle().Foreground(muted), + header: lipgloss.NewStyle().Bold(true).Foreground(cyan), + headerActive: lipgloss.NewStyle().Bold(true).Foreground(accent).Background(selected), + row: lipgloss.NewStyle().Padding(0, 1), + rowSelected: lipgloss.NewStyle().Background(selected).Padding(0, 1), + cell: lipgloss.NewStyle().Foreground(text), + cellSelected: lipgloss.NewStyle().Foreground(bright).Background(selected), + detailTitle: lipgloss.NewStyle().Bold(true).Foreground(accent), + detailLabel: lipgloss.NewStyle().Foreground(cyan), + detailValue: lipgloss.NewStyle().Foreground(text), + muted: lipgloss.NewStyle().Foreground(muted), + footer: lipgloss.NewStyle().Foreground(lipgloss.Color("216")), + status: lipgloss.NewStyle().Foreground(success), + statusBad: lipgloss.NewStyle().Foreground(warning), + divider: lipgloss.NewStyle().Foreground(lipgloss.Color("239")), + shortcutKey: lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(accent).Padding(0, 1).Bold(true), + shortcutText: lipgloss.NewStyle().Foreground(muted), + modal: lipgloss.NewStyle().Border(paletteModalBorder).BorderForeground(accent).Padding(1, 2).Background(lipgloss.Color("235")), + modalTitle: lipgloss.NewStyle().Bold(true).Foreground(warning), + modalBody: lipgloss.NewStyle().Foreground(text), + modalHint: lipgloss.NewStyle().Foreground(muted), + } +} + +func newActivityMonitorModel(windowID string, embedded bool) *activityMonitorBT { + model := &activityMonitorBT{ + windowID: strings.TrimSpace(windowID), + embedded: embedded, + sortKey: activitySortCPU, + sortDescending: true, + showAllProcesses: true, + processes: map[int]*activityProcess{}, + memoryByPID: map[int]activityMemory{}, + networkByPID: map[int]activityNetwork{}, + portsByPID: map[int][]string{}, + tmuxByPanePID: map[int]*activityTmuxLocation{}, + styles: newActivityStyles(), + } + model.setStatus("Loading...", 0) + return model +} + +func runBubbleTeaActivityMonitor(windowID string) error { + model := newActivityMonitorModel(windowID, false) + _, err := tea.NewProgram(model).Run() + return err +} + +func (m *activityMonitorBT) Init() tea.Cmd { + return tea.Batch( + activityRequestRefreshBT(true, true, m), + activityTickCmd(), + ) +} + +func (m *activityMonitorBT) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case activityTickMsg: + return m, tea.Batch( + activityTickCmd(), + activityRequestRefreshBT(false, false, m), + ) + case activityRefreshMsg: + m.refreshInFlight = false + if msg.err != nil { + m.setStatus(msg.err.Error(), 5*time.Second) + } else if msg.snapshot != nil { + m.applySnapshot(msg.snapshot) + if strings.TrimSpace(m.status) == "Loading..." { + m.setStatus("", 0) + } + } + if m.pendingForce || m.pendingRefresh { + force := m.pendingForce + m.pendingForce = false + m.pendingRefresh = false + return m, activityRequestRefreshBT(force, false, m) + } + return m, nil + case tea.KeyMsg: + if isAltFooterToggleKey(msg) { + m.showAltHints = !m.showAltHints + return m, nil + } + m.showAltHints = false + key := msg.String() + if key == "alt+s" { + if m.embedded { + m.requestClose = true + return m, nil + } + return m, tea.Quit + } + if len(m.copyOptions) > 0 { + return m.updateCopyMenu(key) + } + if m.confirmKillPID != 0 { + return m.updateConfirmKill(key) + } + return m.updateNormal(key) + } + return m, nil +} + +func (m *activityMonitorBT) updateConfirmKill(key string) (tea.Model, tea.Cmd) { + if key == "esc" || key == "n" || key == "N" { + m.confirmKillPID = 0 + return m, nil + } + if key == "y" || key == "Y" || key == "enter" { + pid := m.confirmKillPID + m.confirmKillPID = 0 + if err := m.killProcess(pid); err != nil { + m.setStatus(err.Error(), 5*time.Second) + } + return m, activityRequestRefreshBT(true, false, m) + } + m.confirmKillPID = 0 + return m, nil +} + +func (m *activityMonitorBT) updateCopyMenu(key string) (tea.Model, tea.Cmd) { + if len(m.copyOptions) == 0 { + return m, nil + } + switch key { + case "esc", "n", "N": + m.copyOptions = nil + m.copyOptionIndex = 0 + return m, nil + case "u", "up", "ctrl+u": + m.copyOptionIndex = clampInt(m.copyOptionIndex-1, 0, len(m.copyOptions)-1) + return m, nil + case "e", "down", "ctrl+e": + m.copyOptionIndex = clampInt(m.copyOptionIndex+1, 0, len(m.copyOptions)-1) + return m, nil + case "enter", "y", "Y": + option := m.copyOptions[clampInt(m.copyOptionIndex, 0, len(m.copyOptions)-1)] + if err := activityClipboardWriter(option.Value); err != nil { + m.setStatus(err.Error(), 5*time.Second) + return m, nil + } + m.copyOptions = nil + m.copyOptionIndex = 0 + m.setStatus("Copied "+strings.ToLower(strings.TrimSpace(option.Label)), 4*time.Second) + return m, nil + } + if idx, ok := activityCopyIndexForKey(key, len(m.copyOptions)); ok { + option := m.copyOptions[idx] + if err := activityClipboardWriter(option.Value); err != nil { + m.setStatus(err.Error(), 5*time.Second) + return m, nil + } + m.copyOptions = nil + m.copyOptionIndex = 0 + m.setStatus("Copied "+strings.ToLower(strings.TrimSpace(option.Label)), 4*time.Second) + } + return m, nil +} + +func (m *activityMonitorBT) updateNormal(key string) (tea.Model, tea.Cmd) { + if key == "esc" || key == "ctrl+c" { + if m.embedded { + m.requestBack = true + return m, nil + } + return m, tea.Quit + } + switch key { + case "u", "up": + m.moveSelection(-1) + case "e", "down": + m.moveSelection(1) + case "n", "left": + m.shiftSort(-1) + case "i", "right": + m.shiftSort(1) + case "a": + m.showAllProcesses = !m.showAllProcesses + m.resortRows() + return m, activityRequestRefreshBT(true, false, m) + case "l": + m.lockSelection = !m.lockSelection + if !m.lockSelection { + m.resortRows() + } + case "r": + m.sortDescending = !m.sortDescending + m.resortRows() + case "c": + m.setSort(activitySortCPU) + case "m": + m.setSort(activitySortMemory) + case "j": + m.setSort(activitySortDownload) + case "k": + m.setSort(activitySortUpload) + case "o": + m.setSort(activitySortPorts) + case "t", "w": + m.setSort(activitySortLocation) + case "f": + m.setSort(activitySortCommand) + case "enter", "g": + proc := m.selectedProcess() + if proc == nil || proc.Tmux == nil { + m.setStatus("Not in tmux", 3*time.Second) + return m, nil + } + if err := focusActivityTmuxLocation(proc.Tmux); err != nil { + m.setStatus(err.Error(), 4*time.Second) + return m, nil + } + if m.embedded { + m.requestClose = true + return m, nil + } + return m, tea.Quit + case "d", "D": + if proc := m.selectedProcess(); proc != nil { + m.confirmKillPID = proc.PID + } + case "y", "Y": + proc := m.selectedProcess() + if proc == nil { + m.setStatus("No process selected", 3*time.Second) + return m, nil + } + m.copyOptions = activityCopyOptions(proc) + m.copyOptionIndex = 0 + if len(m.copyOptions) == 0 { + m.setStatus("Nothing available to copy", 3*time.Second) + } + } + return m, nil +} + +func (m *activityMonitorBT) View() string { + w := m.width + h := m.height + if w < 72 || h < 14 { + return m.styles.title.Render("Window too small") + } + + title := fmt.Sprintf("Activity Monitor %d processes", len(m.rows)) + headerLine := m.styles.title.Render(title) + + scope := "tmux" + if m.showAllProcesses { + scope = "all" + } + dir := "asc" + if m.sortDescending { + dir = "desc" + } + follow := "row" + if m.lockSelection { + follow = "item" + } + metaLine := m.styles.meta.Render(fmt.Sprintf("Scope %s Sort %s %s Follow %s", scope, m.sortKey.label(), dir, follow)) + + tableX := 1 + tableY := 3 + contentH := h - tableY - 1 + tableW := w - 2 + previewX := 0 + previewW := 0 + showPreview := w >= 108 + if showPreview { + tableW = maxInt(70, (w-3)*68/100) + previewX = tableX + tableW + 1 + previewW = w - previewX - 1 + } + + tableContent := m.renderTable(tableW, contentH) + left := lipgloss.NewStyle().Width(tableW).Height(contentH).Render(tableContent) + + body := left + if showPreview { + divider := m.renderVerticalDivider(contentH) + rightContent := m.renderDetails(previewW, contentH) + right := lipgloss.NewStyle().Width(previewW).Height(contentH).Render(rightContent) + body = lipgloss.JoinHorizontal(lipgloss.Top, left, divider, right) + } + + footer := m.renderFooter(w) + + view := lipgloss.JoinVertical(lipgloss.Left, + headerLine, + metaLine, + "", + body, + "", + footer, + ) + + result := lipgloss.NewStyle().Width(w).Height(h).Padding(0, 1).Render(view) + + if m.confirmKillPID != 0 { + procName := fmt.Sprintf("PID %d", m.confirmKillPID) + if proc := m.processes[m.confirmKillPID]; proc != nil { + procName = fmt.Sprintf("%s (PID %d)", proc.ShortCommand, proc.PID) + } + modal := lipgloss.JoinVertical(lipgloss.Left, + m.styles.modalTitle.Render("Kill process"), + m.styles.modalBody.Render(procName), + "", + m.styles.modalHint.Render("y confirm n cancel"), + ) + box := m.styles.modal.Render(modal) + result = lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box, lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceBackground(lipgloss.Color("235"))) + } else if len(m.copyOptions) > 0 { + rows := make([]string, 0, len(m.copyOptions)) + for idx, option := range m.copyOptions { + prefix := fmt.Sprintf("%d ", idx+1) + labelStyle := m.styles.modalBody + rowStyle := lipgloss.NewStyle() + if idx == clampInt(m.copyOptionIndex, 0, len(m.copyOptions)-1) { + rowStyle = rowStyle.Background(lipgloss.Color("238")) + labelStyle = labelStyle.Copy().Background(lipgloss.Color("238")).Foreground(lipgloss.Color("230")) + } + rows = append(rows, rowStyle.Render(prefix+labelStyle.Render(option.Label))) + } + modal := lipgloss.JoinVertical(lipgloss.Left, + m.styles.modalTitle.Render("Copy"), + m.styles.modalBody.Render("Choose a field from the selected process"), + "", + strings.Join(rows, "\n"), + "", + m.styles.modalHint.Render("u/e move enter copy 1-9 direct esc cancel"), + ) + box := m.styles.modal.Width(minInt(48, maxInt(30, w-10))).Render(modal) + result = lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box, lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceBackground(lipgloss.Color("235"))) + } + + return result +} + +func (m *activityMonitorBT) renderTable(width, height int) string { + cpuW, memW, downW, upW, procW, sessionW, windowW, portW := activityColumnWidths(width) + + headers := []struct { + key activitySortKey + label string + w int + }{ + {activitySortCPU, "CPU", cpuW}, + {activitySortMemory, "MEM", memW}, + {activitySortDownload, "DOWN", downW}, + {activitySortUpload, "UP", upW}, + {activitySortCommand, "PROCESS", procW}, + {activitySortLocation, "SESSION", sessionW}, + {activitySortLocation, "WIN", windowW}, + {activitySortPorts, "PORTS", portW}, + } + + headerParts := make([]string, len(headers)) + for i, h := range headers { + style := m.styles.header + label := h.label + if h.key == m.sortKey { + style = m.styles.headerActive + if m.sortDescending { + label += " ▼" + } else { + label += " ▲" + } + } + headerParts[i] = style.Width(h.w).Render(truncate(label, h.w)) + } + headerLine := lipgloss.JoinHorizontal(lipgloss.Left, headerParts...) + + visibleRows := maxInt(1, height-1) + selectedIndex := m.selectedIndex() + if selectedIndex < 0 { + selectedIndex = 0 + } + offset := stableListOffset(m.rowOffset, selectedIndex, visibleRows, len(m.rows)) + m.rowOffset = offset + + rowLines := []string{} + for row := 0; row < visibleRows; row++ { + idx := offset + row + if idx >= len(m.rows) { + break + } + info := m.rows[idx] + proc := m.processes[info.PID] + if proc == nil { + continue + } + selected := proc.PID == m.selectedPID + rowStyle := m.styles.row + cellStyle := m.styles.cell + if selected { + rowStyle = m.styles.rowSelected + cellStyle = m.styles.cellSelected + } + + cpuStyle := cellStyle + if proc.CPU >= 50 { + cpuStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Background(cellStyle.GetBackground()) + } else if proc.CPU >= 20 { + cpuStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("216")).Background(cellStyle.GetBackground()) + } + + memStyle := cellStyle + totalMem := activityTotalMemoryMB(proc) + if totalMem >= 1024 { + memStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Background(cellStyle.GetBackground()) + } else if totalMem >= 512 { + memStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("216")).Background(cellStyle.GetBackground()) + } + + cells := []string{ + cpuStyle.Width(cpuW).Render(truncate(fmt.Sprintf("%.1f", proc.CPU), cpuW)), + memStyle.Width(memW).Render(truncate(formatActivityMB(totalMem), memW)), + cellStyle.Width(downW).Render(truncate(formatActivitySpeed(proc.DownKBps), downW)), + cellStyle.Width(upW).Render(truncate(formatActivitySpeed(proc.UpKBps), upW)), + cellStyle.Width(procW).Render(truncate(activityProcessLabel(proc), procW)), + cellStyle.Width(sessionW).Render(truncate(activitySessionLabel(proc.Tmux), sessionW)), + cellStyle.Width(windowW).Render(truncate(activityWindowLabel(proc.Tmux, m.windowID), windowW)), + cellStyle.Width(portW).Render(truncate(formatActivityPorts(proc.Ports), portW)), + } + rowLines = append(rowLines, rowStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, cells...))) + } + + if len(rowLines) == 0 { + msg := "No tmux processes" + if m.showAllProcesses { + msg = "No processes" + } + return headerLine + "\n" + m.styles.muted.Width(width).Render(msg) + } + + return headerLine + "\n" + strings.Join(rowLines, "\n") +} + +func (m *activityMonitorBT) renderDetails(width, height int) string { + lines := []string{m.styles.detailTitle.Render("Details")} + + proc := m.selectedProcess() + if proc == nil { + lines = append(lines, m.styles.muted.Render("No process selected")) + return strings.Join(lines, "\n") + } + + lines = append(lines, + m.renderDetailRow("PID", fmt.Sprintf("%d", proc.PID), width), + m.renderDetailRow("PPID", fmt.Sprintf("%d", proc.PPID), width), + m.renderDetailRow("CPU", fmt.Sprintf("%.1f%%", proc.CPU), width), + m.renderDetailRow("MEM", formatActivityMB(activityTotalMemoryMB(proc)), width), + m.renderDetailRow("Download", formatActivitySpeed(proc.DownKBps), width), + m.renderDetailRow("Upload", formatActivitySpeed(proc.UpKBps), width), + ) + + if proc.ResidentMB > 0 || proc.CompressedMB > 0 { + lines = append(lines, m.renderDetailRow("Resident", formatActivityMB(proc.ResidentMB), width)) + if proc.CompressedMB > 0 { + lines = append(lines, m.renderDetailRow("Compressed", formatActivityMB(proc.CompressedMB), width)) + } + } + + lines = append(lines, + m.renderDetailRow("State", blankIfEmpty(proc.State, "-"), width), + m.renderDetailRow("Elapsed", blankIfEmpty(proc.Elapsed, "-"), width), + ) + + if proc.Tmux == nil { + lines = append(lines, m.renderDetailRow("Tmux", "outside tmux", width)) + } else { + lines = append(lines, + m.renderDetailRow("Session", blankIfEmpty(proc.Tmux.SessionName, proc.Tmux.SessionID), width), + m.renderDetailRow("Window", activityWindowLabel(proc.Tmux, m.windowID), width), + m.renderDetailRow("Pane", proc.Tmux.PaneIndex, width), + ) + } + + if len(proc.Ports) > 0 { + lines = append(lines, m.renderDetailRow("Ports", strings.Join(proc.Ports, ", "), width)) + } else { + lines = append(lines, m.renderDetailRow("Ports", "none", width)) + } + + lines = append(lines, "", m.styles.detailTitle.Render("Command")) + cmdLines := wrapText(blankIfEmpty(proc.Command, proc.ShortCommand), width) + for _, l := range cmdLines { + lines = append(lines, m.styles.cell.Render(truncate(l, width))) + } + + return clampActivityLines(lines, height, width, m.styles.muted) +} + +func clampActivityLines(lines []string, height, width int, muted lipgloss.Style) string { + if height <= 0 { + return "" + } + if len(lines) <= height { + return strings.Join(lines, "\n") + } + if height == 1 { + return muted.Render(truncate("...", width)) + } + clipped := append([]string(nil), lines[:height]...) + clipped[height-1] = muted.Render(truncate("...", width)) + return strings.Join(clipped, "\n") +} + +func (m *activityMonitorBT) renderDetailRow(label, value string, width int) string { + labelW := 10 + return m.styles.detailLabel.Width(labelW).Render(label+":") + " " + m.styles.detailValue.Render(truncate(value, maxInt(10, width-labelW-2))) +} + +func (m *activityMonitorBT) renderVerticalDivider(height int) string { + lines := make([]string, maxInt(1, height)) + for i := range lines { + lines[i] = m.styles.divider.Render("│") + } + return strings.Join(lines, "\n") +} + +func (m *activityMonitorBT) renderFooter(width int) string { + status := m.currentStatus() + if status != "" { + style := m.styles.status + lower := strings.ToLower(status) + if strings.Contains(lower, "error") || strings.Contains(lower, "failed") || strings.Contains(lower, "refusing") { + style = m.styles.statusBad + } + return style.Width(width).Render(truncate(status, width)) + } + + renderSegments := func(pairs [][2]string) string { + return renderShortcutPairs(func(v string) string { return m.styles.shortcutKey.Render(v) }, func(v string) string { return m.styles.shortcutText.Render(v) }, " ", pairs) + } + footer := "" + if m.showAltHints { + footer = pickRenderedShortcutFooter(width, renderSegments, + [][2]string{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, + [][2]string{{"Alt-S", "close"}}, + ) + } else { + footer = pickRenderedShortcutFooter(width, renderSegments, + [][2]string{{"u/e", "move"}, {"n/i", "sort"}, {"j/k", "net"}, {"a", "scope"}, {"l", "follow"}, {"r", "reverse"}, {"Enter", "tmux"}, {"d", "kill"}, {"y", "copy"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"n/i", "sort"}, {"j/k", "net"}, {"a", "scope"}, {"Enter", "tmux"}, {"d", "kill"}, {"y", "copy"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"Enter", "tmux"}, {"y", "copy"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + ) + } + return lipgloss.NewStyle().Width(width).Render(footer) +} + +func (m *activityMonitorBT) moveSelection(delta int) { + if len(m.rows) == 0 { + m.selectedPID = 0 + m.selectedRow = 0 + return + } + m.ensureSelection() + idx := m.selectedRow + delta + if idx < 0 { + idx = 0 + } + if idx >= len(m.rows) { + idx = len(m.rows) - 1 + } + m.selectedRow = idx + m.selectedPID = m.rows[m.selectedRow].PID +} + +func (m *activityMonitorBT) shiftSort(delta int) { + index := 0 + for idx, key := range activitySortKeys { + if key == m.sortKey { + index = idx + break + } + } + index += delta + if index < 0 { + index = len(activitySortKeys) - 1 + } + if index >= len(activitySortKeys) { + index = 0 + } + key := activitySortKeys[index] + if key == m.sortKey { + return + } + m.sortKey = key + m.sortDescending = defaultActivitySortDescending(key) + m.resortRows() +} + +func (m *activityMonitorBT) killProcess(pid int) error { + if pid <= 0 { + return fmt.Errorf("invalid pid") + } + if pid == os.Getpid() { + return fmt.Errorf("refusing to kill self") + } + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + return err + } + m.setStatus(fmt.Sprintf("Sent SIGTERM to %d", pid), 4*time.Second) + return nil +} + +func (m *activityMonitorBT) setSort(key activitySortKey) { + if m.sortKey == key { + m.sortDescending = !m.sortDescending + } else { + m.sortKey = key + m.sortDescending = defaultActivitySortDescending(key) + } + m.resortRows() +} + +func (m *activityMonitorBT) resortRows() { + previousPID := m.selectedPID + previousRow := m.selectedRow + rows := make([]activityRow, 0, len(m.processes)) + for _, proc := range m.sortedProcesses(m.visibleProcesses()) { + rows = append(rows, activityRow{PID: proc.PID}) + } + m.rows = rows + m.restoreSelection(previousPID, previousRow) +} + +func (m *activityMonitorBT) visibleProcesses() []*activityProcess { + visible := make([]*activityProcess, 0, len(m.processes)) + for _, proc := range m.processes { + if !m.showAllProcesses && proc.Tmux == nil { + continue + } + visible = append(visible, proc) + } + return visible +} + +func (m *activityMonitorBT) sortedProcesses(values []*activityProcess) []*activityProcess { + cloned := append([]*activityProcess(nil), values...) + sort.SliceStable(cloned, func(i, j int) bool { + return m.less(cloned[i], cloned[j]) + }) + return cloned +} + +func (m *activityMonitorBT) less(left, right *activityProcess) bool { + cmp := 0 + switch m.sortKey { + case activitySortCPU: + cmp = compareFloat64(left.CPU, right.CPU) + case activitySortMemory: + cmp = compareFloat64(activityTotalMemoryMB(left), activityTotalMemoryMB(right)) + case activitySortDownload: + cmp = compareFloat64(left.DownKBps, right.DownKBps) + case activitySortUpload: + cmp = compareFloat64(left.UpKBps, right.UpKBps) + case activitySortPorts: + cmp = compareInt(len(left.Ports), len(right.Ports)) + if cmp == 0 { + cmp = strings.Compare(strings.Join(left.Ports, ","), strings.Join(right.Ports, ",")) + } + case activitySortLocation: + cmp = compareActivityLocation(left.Tmux, right.Tmux) + case activitySortCommand: + cmp = strings.Compare(strings.ToLower(blankIfEmpty(left.ShortCommand, left.Command)), strings.ToLower(blankIfEmpty(right.ShortCommand, right.Command))) + } + if cmp == 0 { + cmp = compareFloat64(left.CPU, right.CPU) + } + if cmp == 0 { + cmp = compareFloat64(activityTotalMemoryMB(left), activityTotalMemoryMB(right)) + } + if cmp == 0 { + cmp = compareInt(left.PID, right.PID) + } + if m.sortDescending { + return cmp > 0 + } + return cmp < 0 +} + +func (m *activityMonitorBT) ensureSelection() { + if len(m.rows) == 0 { + m.selectedPID = 0 + m.selectedRow = 0 + return + } + if m.lockSelection && m.selectedPID != 0 { + for idx, row := range m.rows { + if row.PID == m.selectedPID { + m.selectedRow = idx + return + } + } + } + if m.selectedRow < 0 { + m.selectedRow = 0 + } + if m.selectedRow >= len(m.rows) { + m.selectedRow = len(m.rows) - 1 + } + m.selectedPID = m.rows[m.selectedRow].PID +} + +func (m *activityMonitorBT) restoreSelection(previousPID, previousRow int) { + if len(m.rows) == 0 { + m.selectedPID = 0 + m.selectedRow = 0 + return + } + if m.lockSelection && previousPID != 0 { + for idx, row := range m.rows { + if row.PID == previousPID { + m.selectedPID = previousPID + m.selectedRow = idx + return + } + } + } + if previousRow < 0 { + previousRow = 0 + } + if previousRow >= len(m.rows) { + previousRow = len(m.rows) - 1 + } + m.selectedRow = previousRow + m.selectedPID = m.rows[m.selectedRow].PID +} + +func (m *activityMonitorBT) selectedIndex() int { + m.ensureSelection() + if len(m.rows) == 0 { + return -1 + } + return m.selectedRow +} + +func (m *activityMonitorBT) selectedProcess() *activityProcess { + m.ensureSelection() + if m.selectedPID == 0 { + return nil + } + return m.processes[m.selectedPID] +} + +func (m *activityMonitorBT) setStatus(text string, duration time.Duration) { + m.status = strings.TrimSpace(text) + if duration > 0 { + m.statusUntil = time.Now().Add(duration) + } else { + m.statusUntil = time.Time{} + } +} + +func (m *activityMonitorBT) currentStatus() string { + if m.status == "" { + return "" + } + if !m.statusUntil.IsZero() && time.Now().After(m.statusUntil) { + m.status = "" + m.statusUntil = time.Time{} + return "" + } + return m.status +} + +func (m *activityMonitorBT) applySnapshot(snapshot *activitySnapshot) { + if snapshot == nil { + return + } + m.processes = snapshot.Processes + m.memoryByPID = snapshot.MemoryByPID + m.networkByPID = snapshot.NetworkByPID + m.portsByPID = snapshot.PortsByPID + m.tmuxByPanePID = snapshot.TmuxByPanePID + m.lastProcessLoad = snapshot.LastProcessLoad + m.lastMemoryLoad = snapshot.LastMemoryLoad + m.lastNetworkLoad = snapshot.LastNetworkLoad + m.lastPortLoad = snapshot.LastPortLoad + m.lastTmuxLoad = snapshot.LastTmuxLoad + m.refreshedAt = snapshot.RefreshedAt + m.resortRows() + if strings.TrimSpace(snapshot.Status) != "" && m.currentStatus() == "" { + m.setStatus(snapshot.Status, 4*time.Second) + } +} + +func activityTickCmd() tea.Cmd { + return tea.Tick(time.Second, func(time.Time) tea.Msg { + return activityTickMsg{} + }) +} + +func activityRequestRefreshBT(force, initial bool, m *activityMonitorBT) tea.Cmd { + if m.refreshInFlight { + if force || initial { + m.pendingForce = true + } else { + m.pendingRefresh = true + } + return nil + } + m.refreshInFlight = true + input := activityRefreshInput{ + Force: force, + Initial: initial, + ShowAll: m.showAllProcesses, + Processes: cloneActivityProcesses(m.processes), + MemoryByPID: cloneActivityMemoryMap(m.memoryByPID), + NetworkByPID: cloneActivityNetworkMap(m.networkByPID), + PortsByPID: cloneActivityPortsMap(m.portsByPID), + TmuxByPanePID: cloneActivityTmuxMap(m.tmuxByPanePID), + LastProcessLoad: m.lastProcessLoad, + LastMemoryLoad: m.lastMemoryLoad, + LastNetworkLoad: m.lastNetworkLoad, + LastPortLoad: m.lastPortLoad, + LastTmuxLoad: m.lastTmuxLoad, + RefreshedAt: m.refreshedAt, + } + return func() tea.Msg { + snapshot, err := collectActivitySnapshot(input) + return activityRefreshMsg{snapshot: snapshot, err: err} + } +} + +func activityCopyOptions(proc *activityProcess) []activityCopyOption { + if proc == nil { + return nil + } + options := []activityCopyOption{ + {Label: "PID", Value: fmt.Sprintf("%d", proc.PID)}, + {Label: "Parent PID", Value: fmt.Sprintf("%d", proc.PPID)}, + {Label: "Process", Value: blankIfEmpty(proc.ShortCommand, proc.Command)}, + {Label: "Command", Value: blankIfEmpty(proc.Command, proc.ShortCommand)}, + {Label: "Download", Value: formatActivitySpeed(proc.DownKBps)}, + {Label: "Upload", Value: formatActivitySpeed(proc.UpKBps)}, + } + if proc.Tmux != nil { + session := strings.TrimSpace(blankIfEmpty(proc.Tmux.SessionName, proc.Tmux.SessionID)) + if session != "" { + if strings.TrimSpace(proc.Tmux.SessionID) != "" && session != strings.TrimSpace(proc.Tmux.SessionID) { + session += " (" + strings.TrimSpace(proc.Tmux.SessionID) + ")" + } + options = append(options, activityCopyOption{Label: "Session", Value: session}) + } + window := activityWindowLabel(proc.Tmux, "") + if strings.TrimSpace(window) != "-" && strings.TrimSpace(window) != "" { + if strings.TrimSpace(proc.Tmux.WindowID) != "" { + window += " (" + strings.TrimSpace(proc.Tmux.WindowID) + ")" + } + options = append(options, activityCopyOption{Label: "Window", Value: window}) + } + if pane := strings.TrimSpace(proc.Tmux.PaneID); pane != "" { + options = append(options, activityCopyOption{Label: "Pane", Value: pane}) + } + } + if len(proc.Ports) > 0 { + options = append(options, activityCopyOption{Label: "Ports", Value: strings.Join(proc.Ports, ", ")}) + } + filtered := make([]activityCopyOption, 0, len(options)) + for _, option := range options { + if strings.TrimSpace(option.Value) == "" || strings.TrimSpace(option.Value) == "-" { + continue + } + filtered = append(filtered, option) + } + return filtered +} + +func activityCopyIndexForKey(key string, total int) (int, bool) { + if total <= 0 { + return 0, false + } + runes := []rune(strings.TrimSpace(key)) + if len(runes) != 1 { + return 0, false + } + if runes[0] < '1' || runes[0] > '9' { + return 0, false + } + idx := int(runes[0] - '1') + if idx < 0 || idx >= total { + return 0, false + } + return idx, true +} + +func writeActivityClipboard(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return fmt.Errorf("nothing to copy") + } + cmd := exec.Command("pbcopy") + cmd.Stdin = strings.NewReader(value) + if output, err := cmd.CombinedOutput(); err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return err + } + return fmt.Errorf("clipboard copy failed: %s", message) + } + return nil +} diff --git a/agent-tracker/cmd/agent/activity_monitor_test.go b/agent-tracker/cmd/agent/activity_monitor_test.go new file mode 100644 index 0000000..cc34689 --- /dev/null +++ b/agent-tracker/cmd/agent/activity_monitor_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "strings" + "testing" +) + +func TestActivityCopyOptionsIncludeCoreFields(t *testing.T) { + proc := &activityProcess{ + PID: 123, + PPID: 45, + DownKBps: 12.5, + UpKBps: 4.25, + ShortCommand: "node", + Command: "node server.js --port 3000", + Ports: []string{"3000", "9229"}, + Tmux: &activityTmuxLocation{ + SessionID: "$1", + SessionName: "3-Config", + WindowID: "@12", + WindowIndex: "2", + WindowName: "agent", + PaneID: "%31", + }, + } + + options := activityCopyOptions(proc) + joined := make([]string, 0, len(options)) + for _, option := range options { + joined = append(joined, option.Label+":"+option.Value) + } + text := strings.Join(joined, "\n") + for _, expected := range []string{"PID:123", "Parent PID:45", "Process:node", "Command:node server.js --port 3000", "Download:12.5K", "Upload:4.25K", "Session:3-Config ($1)", "Window:2 agent (@12)", "Pane:%31", "Ports:3000, 9229"} { + if !strings.Contains(text, expected) { + t.Fatalf("expected %q in copy options, got %q", expected, text) + } + } +} + +func TestParseActivityNetworkLine(t *testing.T) { + parsed, ok := parseActivityNetworkLine("Google Chrome H.34802,54961,47736,") + if !ok { + t.Fatal("expected network line to parse") + } + if parsed.PID != 34802 || parsed.BytesIn != 54961 || parsed.BytesOut != 47736 { + t.Fatalf("unexpected parsed network line: %#v", parsed) + } +} + +func TestActivityMonitorRenderIncludesNetworkColumns(t *testing.T) { + model := newActivityMonitorModel("@12", true) + model.width = 120 + model.height = 24 + model.processes = map[int]*activityProcess{123: { + PID: 123, + CPU: 10.5, + ResidentMB: 128, + DownKBps: 64, + UpKBps: 8.5, + ShortCommand: "node", + Command: "node server.js", + }} + model.rows = []activityRow{{PID: 123}} + model.selectedPID = 123 + model.selectedRow = 0 + + table := model.renderTable(80, 10) + if !strings.Contains(table, "DOWN") || !strings.Contains(table, "UP") { + t.Fatalf("expected network columns in table, got %q", table) + } + if !strings.Contains(table, "64.0K") || !strings.Contains(table, "8.50K") { + t.Fatalf("expected network speeds in table, got %q", table) + } +} + +func TestActivityMonitorDefaultsToAllProcesses(t *testing.T) { + model := newActivityMonitorModel("@12", true) + if !model.showAllProcesses { + t.Fatal("expected activity monitor to default to all processes") + } +} + +func TestActivityMonitorNetworkHotkeysSetSeparateSortColumns(t *testing.T) { + model := newActivityMonitorModel("@12", true) + + updated, _ := model.updateNormal("j") + activity := updated.(*activityMonitorBT) + if activity.sortKey != activitySortDownload { + t.Fatalf("expected j to sort by download, got %v", activity.sortKey) + } + if !activity.sortDescending { + t.Fatal("expected download sort to default descending") + } + + updated, _ = activity.updateNormal("k") + activity = updated.(*activityMonitorBT) + if activity.sortKey != activitySortUpload { + t.Fatalf("expected k to sort by upload, got %v", activity.sortKey) + } + if !activity.sortDescending { + t.Fatal("expected upload sort to default descending") + } +} + +func TestActivityMonitorYOpensCopyMenu(t *testing.T) { + model := newActivityMonitorModel("@12", true) + model.processes = map[int]*activityProcess{123: {PID: 123, PPID: 1, ShortCommand: "node", Command: "node server.js"}} + model.rows = []activityRow{{PID: 123}} + model.selectedPID = 123 + model.selectedRow = 0 + + updated, _ := model.updateNormal("y") + activity := updated.(*activityMonitorBT) + if len(activity.copyOptions) == 0 { + t.Fatalf("expected copy options to open") + } + if activity.copyOptions[0].Label != "PID" { + t.Fatalf("expected first copy option to be PID, got %#v", activity.copyOptions[0]) + } +} + +func TestActivityMonitorCopyMenuCopiesSelectedOption(t *testing.T) { + model := newActivityMonitorModel("@12", true) + model.copyOptions = []activityCopyOption{{Label: "PID", Value: "123"}, {Label: "Command", Value: "node server.js"}} + model.copyOptionIndex = 1 + + var copied string + prev := activityClipboardWriter + activityClipboardWriter = func(value string) error { + copied = value + return nil + } + defer func() { activityClipboardWriter = prev }() + + updated, _ := model.updateCopyMenu("enter") + activity := updated.(*activityMonitorBT) + if copied != "node server.js" { + t.Fatalf("expected copied value %q, got %q", "node server.js", copied) + } + if len(activity.copyOptions) != 0 { + t.Fatalf("expected copy menu to close after copying") + } + if !strings.Contains(strings.ToLower(activity.currentStatus()), "copied command") { + t.Fatalf("expected copied status, got %q", activity.currentStatus()) + } +} diff --git a/agent-tracker/cmd/agent/agent_start_test.go b/agent-tracker/cmd/agent/agent_start_test.go new file mode 100644 index 0000000..15270c0 --- /dev/null +++ b/agent-tracker/cmd/agent/agent_start_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func gitCheckoutBranch(t *testing.T, repo, branch string) { + t.Helper() + cmd := exec.Command("git", "checkout", "-b", branch) + cmd.Dir = repo + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git checkout -b %s: %v: %s", branch, err, strings.TrimSpace(string(output))) + } +} + +func TestResolveStartSourceBranchUsesCurrentLocalBranch(t *testing.T) { + repo := t.TempDir() + initTestGitRepo(t, repo) + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write readme: %v", err) + } + gitAddPath(t, repo, "README.md") + gitCommitAll(t, repo, "initial commit") + gitCheckoutBranch(t, repo, "release") + + branch := resolveStartSourceBranch(repo, &repoConfig{BaseBranch: "main"}) + if branch != "release" { + t.Fatalf("expected current local branch release, got %q", branch) + } +} + +func TestResolveBootstrapStartOptionsPrefersRecordedValues(t *testing.T) { + options := resolveBootstrapStartOptions("/tmp/repo", &repoConfig{BaseBranch: "main"}, &agentRecord{SourceBranch: "release", KeepWorktree: true}) + if options.SourceBranch != "release" { + t.Fatalf("expected recorded source branch release, got %q", options.SourceBranch) + } + if !options.KeepWorktree { + t.Fatalf("expected keep-worktree to stay enabled") + } + + options = resolveBootstrapStartOptions("/tmp/repo", &repoConfig{BaseBranch: "main"}, nil) + if options.SourceBranch != "main" { + t.Fatalf("expected repo config base branch main, got %q", options.SourceBranch) + } + if options.KeepWorktree { + t.Fatalf("expected keep-worktree to default off") + } +} + +func TestAgentRunPaneCommandLeavesFlutterPaneIdleWithoutDevice(t *testing.T) { + record := &agentRecord{WorkspaceRoot: "/tmp/demo", Runtime: "flutter", Device: ""} + cmd := agentRunPaneCommand(record) + if strings.Contains(cmd, "./ensure-server.sh") { + t.Fatalf("expected empty-device flutter run pane to stay idle, got %q", cmd) + } + if !strings.Contains(cmd, "exec ${SHELL:-/bin/zsh}") { + t.Fatalf("expected empty-device flutter run pane to open a shell, got %q", cmd) + } + + record.Device = "web-server" + cmd = agentRunPaneCommand(record) + if !strings.Contains(cmd, "./ensure-server.sh") { + t.Fatalf("expected configured flutter device to auto-start, got %q", cmd) + } +} + +func TestLoadFeatureConfigDefaultsMissingFlutterDeviceToWebServer(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent.json") + payload := map[string]any{"feature": "demo", "is_flutter": true} + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := loadFeatureConfig(path) + if err != nil { + t.Fatalf("load feature config: %v", err) + } + if cfg.Device != defaultManagedDeviceID { + t.Fatalf("expected missing flutter device to default to %q, got %q", defaultManagedDeviceID, cfg.Device) + } +} + +func TestSaveFeatureConfigPreservesExplicitEmptyDevice(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent.json") + if err := saveFeatureConfig(path, featureConfig{Feature: "demo", Device: "", IsFlutter: true}); err != nil { + t.Fatalf("save feature config: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read feature config: %v", err) + } + text := string(data) + if !strings.Contains(text, "\"device\": \"\"") { + t.Fatalf("expected explicit empty device to be persisted, got %q", text) + } +} + +func TestRunFeatureCommandSyncsRegistryDeviceAndBrowserState(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + workspace := filepath.Join(home, "repo", ".agents", "demo") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + featurePath := filepath.Join(workspace, "agent.json") + if err := saveFeatureConfig(featurePath, featureConfig{Feature: "demo", Device: "ipm", IsFlutter: true}); err != nil { + t.Fatalf("save feature config: %v", err) + } + reg := ®istry{Agents: map[string]*agentRecord{ + "demo": { + ID: "demo", + WorkspaceRoot: workspace, + FeatureConfig: featurePath, + Device: "ipm", + BrowserEnabled: false, + }, + }} + if err := saveRegistry(reg); err != nil { + t.Fatalf("save registry: %v", err) + } + + if err := runFeatureCommand([]string{"--workspace", workspace, "--device", "web-server"}); err != nil { + t.Fatalf("run feature command: %v", err) + } + + updatedCfg, err := loadFeatureConfig(featurePath) + if err != nil { + t.Fatalf("load feature config: %v", err) + } + if updatedCfg.Device != "web-server" { + t.Fatalf("expected feature config device web-server, got %q", updatedCfg.Device) + } + updatedReg, err := loadRegistry() + if err != nil { + t.Fatalf("load registry: %v", err) + } + record := updatedReg.Agents["demo"] + if record == nil { + t.Fatal("expected registry record") + } + if record.Device != "web-server" { + t.Fatalf("expected registry device web-server, got %q", record.Device) + } + if !record.BrowserEnabled { + t.Fatal("expected browser to be enabled for web-server device") + } +} + +func TestRunDestroyRequiresConfirmWhenRepoHasUncommittedChanges(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + workspace := filepath.Join(home, "repo", ".agents", "demo") + repoCopy := filepath.Join(workspace, "repo") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + if err := os.MkdirAll(repoCopy, 0o755); err != nil { + t.Fatalf("mkdir repo copy: %v", err) + } + initTestGitRepo(t, repoCopy) + if err := os.WriteFile(filepath.Join(repoCopy, "README.md"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty repo file: %v", err) + } + reg := ®istry{Agents: map[string]*agentRecord{ + "demo": { + ID: "demo", + WorkspaceRoot: workspace, + RepoCopyPath: repoCopy, + }, + }} + if err := saveRegistry(reg); err != nil { + t.Fatalf("save registry: %v", err) + } + + err := runDestroy([]string{"--id", "demo"}) + if err == nil || !strings.Contains(err.Error(), "--confirm destroy") { + t.Fatalf("expected destroy confirm error, got %v", err) + } +} diff --git a/agent-tracker/cmd/agent/chrome_permissions_test.go b/agent-tracker/cmd/agent/chrome_permissions_test.go new file mode 100644 index 0000000..74e5ee8 --- /dev/null +++ b/agent-tracker/cmd/agent/chrome_permissions_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestChromeAppleEventsEnabledReadsPreference(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("darwin-only preference path") + } + home := t.TempDir() + t.Setenv("HOME", home) + prefsPath := filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Preferences") + if err := os.MkdirAll(filepath.Dir(prefsPath), 0o755); err != nil { + t.Fatalf("mkdir prefs dir: %v", err) + } + if err := os.WriteFile(prefsPath, []byte(`{"browser":{"allow_javascript_apple_events":true}}`), 0o644); err != nil { + t.Fatalf("write prefs: %v", err) + } + + enabled, err := chromeAppleEventsEnabled() + if err != nil { + t.Fatalf("chromeAppleEventsEnabled: %v", err) + } + if !enabled { + t.Fatalf("expected allow_javascript_apple_events=true") + } +} + +func TestEnsureChromeAppleEventsEnabledErrorsWhenDisabled(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("darwin-only preference path") + } + home := t.TempDir() + t.Setenv("HOME", home) + prefsPath := filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Preferences") + if err := os.MkdirAll(filepath.Dir(prefsPath), 0o755); err != nil { + t.Fatalf("mkdir prefs dir: %v", err) + } + if err := os.WriteFile(prefsPath, []byte(`{"browser":{"allow_javascript_apple_events":false}}`), 0o644); err != nil { + t.Fatalf("write prefs: %v", err) + } + + err := ensureChromeAppleEventsEnabled() + if err == nil { + t.Fatalf("expected disabled preference to fail") + } +} diff --git a/agent-tracker/cmd/agent/create_window_test.go b/agent-tracker/cmd/agent/create_window_test.go new file mode 100644 index 0000000..c14a281 --- /dev/null +++ b/agent-tracker/cmd/agent/create_window_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestPreferredNewWindowTarget(t *testing.T) { + if got := preferredNewWindowTarget("@9", true, "@3"); got != "@9" { + t.Fatalf("expected explicit target window, got %q", got) + } + if got := preferredNewWindowTarget("", true, "@3"); got != "@3" { + t.Fatalf("expected current tmux window fallback, got %q", got) + } + if got := preferredNewWindowTarget("", false, "@3"); got != "" { + t.Fatalf("expected no target outside tmux, got %q", got) + } +} + +func TestPositionedNewWindowArgs(t *testing.T) { + got := positionedNewWindowArgs("feature-x", "/tmp/repo", "@7") + want := []string{"new-window", "-P", "-F", "#{window_id}", "-a", "-t", "@7", "-n", "feature-x", "-c", "/tmp/repo"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected positioned new-window args %v, got %v", want, got) + } + + got = positionedNewWindowArgs("feature-x", "/tmp/repo", "") + want = []string{"new-window", "-P", "-F", "#{window_id}", "-n", "feature-x", "-c", "/tmp/repo"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected default new-window args %v, got %v", want, got) + } +} diff --git a/agent-tracker/cmd/agent/dashboard.go b/agent-tracker/cmd/agent/dashboard.go new file mode 100644 index 0000000..2997071 --- /dev/null +++ b/agent-tracker/cmd/agent/dashboard.go @@ -0,0 +1,769 @@ +package main + +import ( + "flag" + "fmt" + "io" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/gdamore/tcell/v2" +) + +type dashboardMode int + +const ( + modeView dashboardMode = iota + modeEditNotes + modeEditTask + modeTodoPrompt + modeConfirmDestroy +) + +type todoPromptMode int + +const ( + todoPromptAdd todoPromptMode = iota + todoPromptEdit +) + +type todoPrompt struct { + active bool + mode todoPromptMode + text []rune + cursor int + index int +} + +func runDashboard(args []string) error { + fs := flag.NewFlagSet("agent dashboard", flag.ContinueOnError) + var agentID string + fs.StringVar(&agentID, "agent-id", "", "agent id") + fs.SetOutput(io.Discard) + if err := fs.Parse(args); err != nil { + return err + } + if agentID == "" { + return fmt.Errorf("agent-id is required") + } + reg, err := loadRegistry() + if err != nil { + return err + } + record := reg.Agents[agentID] + if record == nil { + return fmt.Errorf("unknown agent: %s", agentID) + } + if record.Dashboard.Todos == nil { + record.Dashboard.Todos = []todoItem{} + } + + cfg := loadAppConfig() + screen, err := tcell.NewScreen() + if err != nil { + return err + } + if err := screen.Init(); err != nil { + return err + } + defer screen.Fini() + screen.Clear() + + selectedCol := 0 + todoIndex := 0 + noteScroll := 0 + taskCursor := utf8.RuneCountInString(record.Dashboard.CurrentTask) + noteCursor := len([]rune(record.Dashboard.Notes)) + mode := modeView + prompt := todoPrompt{} + helpVisible := false + + persist := func() { + fresh, err := loadRegistry() + if err != nil { + return + } + record.UpdatedAt = time.Now() + fresh.Agents[record.ID] = record + _ = saveRegistry(fresh) + } + + ensureTodoIndex := func() { + if len(record.Dashboard.Todos) == 0 { + todoIndex = 0 + return + } + if todoIndex < 0 { + todoIndex = 0 + } + if todoIndex >= len(record.Dashboard.Todos) { + todoIndex = len(record.Dashboard.Todos) - 1 + } + } + + draw := func() { + screen.Clear() + w, h := screen.Size() + if w <= 0 || h <= 0 { + return + } + + headerStyle := tcell.StyleDefault.Foreground(tcell.ColorLightCyan).Bold(true) + selectedHeaderStyle := headerStyle.Background(tcell.ColorDarkSlateGray) + selectedStyle := tcell.StyleDefault.Background(tcell.ColorDarkSlateGray) + normalStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite) + subtleStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray) + actionStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSalmon) + + colWidths := []int{ + maxInt(18, w*30/100), + maxInt(24, w*38/100), + maxInt(18, w*22/100), + } + actionsWidth := maxInt(10, w-colWidths[0]-colWidths[1]-colWidths[2]-3) + used := colWidths[0] + colWidths[1] + colWidths[2] + actionsWidth + 3 + if used > w { + actionsWidth -= used - w + } + if actionsWidth < 8 { + actionsWidth = 8 + } + contentHeight := h - 1 + if contentHeight < 2 { + contentHeight = h + } + + columns := []struct { + title string + x int + w int + }{ + {title: "TODOS", x: 0, w: colWidths[0]}, + {title: "NOTES", x: colWidths[0] + 1, w: colWidths[1]}, + {title: "CURRENT TASK", x: colWidths[0] + colWidths[1] + 2, w: colWidths[2]}, + {title: "ACTIONS", x: colWidths[0] + colWidths[1] + colWidths[2] + 3, w: actionsWidth}, + } + + for idx, col := range columns { + style := headerStyle + if idx == selectedCol && mode == modeView { + style = selectedHeaderStyle + } + writeStyledLine(screen, col.x, 0, padRight(truncate(col.title, col.w), col.w), style) + } + for _, sepX := range []int{colWidths[0], colWidths[0] + colWidths[1] + 1, colWidths[0] + colWidths[1] + colWidths[2] + 2} { + if sepX >= 0 && sepX < w { + for row := 0; row < h; row++ { + screen.SetContent(sepX, row, '│', nil, subtleStyle) + } + } + } + + for row := 1; row < contentHeight; row++ { + for x := 0; x < w; x++ { + screen.SetContent(x, row, ' ', nil, tcell.StyleDefault) + } + } + + ensureTodoIndex() + visibleTodoRows := contentHeight - 1 + if visibleTodoRows < 1 { + visibleTodoRows = 1 + } + todoOffset := 0 + if todoIndex >= visibleTodoRows { + todoOffset = todoIndex - visibleTodoRows + 1 + } + for row := 0; row < visibleTodoRows; row++ { + idx := todoOffset + row + lineStyle := normalStyle + if selectedCol == 0 && idx == todoIndex && mode == modeView { + lineStyle = selectedStyle + } + text := "" + if idx < len(record.Dashboard.Todos) { + box := "[ ]" + if record.Dashboard.Todos[idx].Done { + box = "[x]" + } + text = box + " " + record.Dashboard.Todos[idx].Title + } else if len(record.Dashboard.Todos) == 0 && row == 0 { + text = cfg.Keys.AddTodo + " add todo" + lineStyle = subtleStyle + } + writeStyledLine(screen, columns[0].x, row+1, padRight(truncate(text, columns[0].w), columns[0].w), lineStyle) + } + + noteLines := strings.Split(record.Dashboard.Notes, "\n") + if len(noteLines) == 0 { + noteLines = []string{""} + } + if noteScroll < 0 { + noteScroll = 0 + } + maxScroll := maxInt(0, len(noteLines)-visibleTodoRows) + if noteScroll > maxScroll { + noteScroll = maxScroll + } + for row := 0; row < visibleTodoRows; row++ { + idx := noteScroll + row + style := normalStyle + if selectedCol == 1 && mode == modeView { + style = selectedStyle + } + line := "" + if idx < len(noteLines) { + line = noteLines[idx] + } else if record.Dashboard.Notes == "" && row == 0 { + line = "Enter to edit notes" + style = subtleStyle + } + if selectedCol == 1 && mode == modeEditNotes { + style = selectedStyle + } + writeStyledLine(screen, columns[1].x, row+1, padRight(truncate(line, columns[1].w), columns[1].w), style) + } + + taskLines := wrapText(record.Dashboard.CurrentTask, maxInt(1, columns[2].w)) + if len(taskLines) == 0 { + taskLines = []string{""} + } + for row := 0; row < visibleTodoRows; row++ { + style := normalStyle + if selectedCol == 2 && mode == modeView { + style = selectedStyle + } + if selectedCol == 2 && mode == modeEditTask { + style = selectedStyle + } + line := "" + if row < len(taskLines) { + line = taskLines[row] + } else if record.Dashboard.CurrentTask == "" && row == 0 { + line = "Enter to set current task" + style = subtleStyle + } + writeStyledLine(screen, columns[2].x, row+1, padRight(truncate(line, columns[2].w), columns[2].w), style) + } + + actionLines := []string{"[D] Destroy"} + for row := 0; row < visibleTodoRows; row++ { + style := actionStyle + if selectedCol == 3 && mode == modeView { + style = selectedStyle.Foreground(tcell.ColorLightSalmon) + } + line := "" + if row < len(actionLines) { + line = actionLines[row] + } + writeStyledLine(screen, columns[3].x, row+1, padRight(truncate(line, columns[3].w), columns[3].w), style) + } + + if helpVisible && h > 1 { + help := fmt.Sprintf("%s/%s/%s/%s move %s edit %s cancel %s add todo %s toggle %s destroy", cfg.Keys.MoveLeft, cfg.Keys.MoveRight, cfg.Keys.MoveUp, cfg.Keys.MoveDown, cfg.Keys.Edit, cfg.Keys.Cancel, cfg.Keys.AddTodo, cfg.Keys.ToggleTodo, cfg.Keys.Destroy) + writeStyledLine(screen, 0, h-1, padRight(truncate(help, w), w), subtleStyle) + } + + if prompt.active { + drawPopup(screen, w, h, promptTitle(prompt.mode), string(prompt.text), prompt.cursor) + } + if mode == modeConfirmDestroy { + drawPopup(screen, w, h, "Destroy agent", "Destroy this agent? [y/N]", len([]rune("Destroy this agent? [y/N]"))) + } + + if selectedCol == 1 && mode == modeEditNotes { + line, col := cursorLineCol(record.Dashboard.Notes, noteCursor) + displayLine := line - noteScroll + 1 + if displayLine >= 1 && displayLine < h { + x := columns[1].x + minInt(col, columns[1].w-1) + screen.ShowCursor(x, displayLine) + } + } else if selectedCol == 2 && mode == modeEditTask { + screen.ShowCursor(columns[2].x+minInt(taskCursor, columns[2].w-1), 1) + } else { + screen.HideCursor() + } + screen.Show() + } + + draw() + for { + ev := screen.PollEvent() + switch tev := ev.(type) { + case *tcell.EventResize: + screen.Sync() + draw() + case *tcell.EventKey: + if matchesKey(tev, cfg.Keys.Help) { + helpVisible = !helpVisible + draw() + continue + } + + if prompt.active { + handled := handleSingleLineEditKey(tev, &prompt.text, &prompt.cursor, true) + if tev.Key() == tcell.KeyEnter { + text := strings.TrimSpace(string(prompt.text)) + if prompt.mode == todoPromptAdd && text != "" { + record.Dashboard.Todos = append(record.Dashboard.Todos, todoItem{Title: text}) + todoIndex = len(record.Dashboard.Todos) - 1 + persist() + } else if prompt.mode == todoPromptEdit && prompt.index >= 0 && prompt.index < len(record.Dashboard.Todos) { + record.Dashboard.Todos[prompt.index].Title = text + persist() + } + prompt.active = false + draw() + continue + } + if tev.Key() == tcell.KeyEscape { + prompt.active = false + draw() + continue + } + if handled { + draw() + continue + } + } + + if mode == modeConfirmDestroy { + if tev.Key() == tcell.KeyEscape || matchesKey(tev, cfg.Keys.Back) { + mode = modeView + draw() + continue + } + if matchesKey(tev, cfg.Keys.Confirm) { + screen.Fini() + return runDestroy([]string{"--id", record.ID}) + } + if tev.Key() == tcell.KeyRune { + mode = modeView + draw() + } + continue + } + + switch mode { + case modeEditNotes: + if tev.Key() == tcell.KeyEscape || matchesKey(tev, cfg.Keys.Cancel) { + mode = modeView + draw() + continue + } + if handleMultilineEditKey(tev, &record.Dashboard.Notes, ¬eCursor) { + persist() + draw() + } + continue + case modeEditTask: + if tev.Key() == tcell.KeyEscape || matchesKey(tev, cfg.Keys.Cancel) { + mode = modeView + draw() + continue + } + if tev.Key() == tcell.KeyEnter { + mode = modeView + persist() + draw() + continue + } + if handleSingleLineEditKey(tev, (*[]rune)(nil), nil, false) { + } + runes := []rune(record.Dashboard.CurrentTask) + if handleSingleLineEditKey(tev, &runes, &taskCursor, false) { + record.Dashboard.CurrentTask = string(runes) + persist() + draw() + } + continue + } + + if tev.Key() == tcell.KeyCtrlC { + continue + } + if matchesKey(tev, cfg.Keys.MoveLeft) { + selectedCol = maxInt(0, selectedCol-1) + draw() + continue + } + if matchesKey(tev, cfg.Keys.MoveRight) { + selectedCol = minInt(3, selectedCol+1) + draw() + continue + } + switch selectedCol { + case 0: + if matchesKey(tev, cfg.Keys.MoveUp) { + todoIndex-- + ensureTodoIndex() + draw() + continue + } + if matchesKey(tev, cfg.Keys.MoveDown) { + todoIndex++ + ensureTodoIndex() + draw() + continue + } + if matchesKey(tev, cfg.Keys.AddTodo) { + prompt = todoPrompt{active: true, mode: todoPromptAdd, text: []rune{}, cursor: 0, index: -1} + draw() + continue + } + if matchesKey(tev, cfg.Keys.ToggleTodo) && len(record.Dashboard.Todos) > 0 { + record.Dashboard.Todos[todoIndex].Done = !record.Dashboard.Todos[todoIndex].Done + persist() + draw() + continue + } + if matchesKey(tev, cfg.Keys.DeleteTodo) && len(record.Dashboard.Todos) > 0 { + record.Dashboard.Todos = append(record.Dashboard.Todos[:todoIndex], record.Dashboard.Todos[todoIndex+1:]...) + ensureTodoIndex() + persist() + draw() + continue + } + if tev.Key() == tcell.KeyEnter && len(record.Dashboard.Todos) > 0 { + text := []rune(record.Dashboard.Todos[todoIndex].Title) + prompt = todoPrompt{active: true, mode: todoPromptEdit, text: text, cursor: len(text), index: todoIndex} + draw() + continue + } + case 1: + if matchesKey(tev, cfg.Keys.MoveUp) { + noteScroll-- + draw() + continue + } + if matchesKey(tev, cfg.Keys.MoveDown) { + noteScroll++ + draw() + continue + } + if tev.Key() == tcell.KeyEnter || matchesKey(tev, cfg.Keys.Edit) { + mode = modeEditNotes + noteCursor = len([]rune(record.Dashboard.Notes)) + draw() + continue + } + case 2: + if tev.Key() == tcell.KeyEnter || matchesKey(tev, cfg.Keys.Edit) { + mode = modeEditTask + taskCursor = len([]rune(record.Dashboard.CurrentTask)) + draw() + continue + } + case 3: + if matchesKey(tev, cfg.Keys.Destroy) || tev.Key() == tcell.KeyEnter { + mode = modeConfirmDestroy + draw() + continue + } + } + if matchesKey(tev, cfg.Keys.Destroy) { + mode = modeConfirmDestroy + draw() + } + } + } +} + +func promptTitle(mode todoPromptMode) string { + if mode == todoPromptAdd { + return "Add todo" + } + return "Edit todo" +} + +func drawPopup(screen tcell.Screen, width, height int, title, text string, cursor int) { + boxW := minInt(maxInt(24, width*60/100), width-4) + boxH := 5 + boxX := (width - boxW) / 2 + boxY := maxInt(1, (height-boxH)/2) + style := tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite) + border := tcell.StyleDefault.Foreground(tcell.ColorLightCyan) + for y := 0; y < boxH; y++ { + for x := 0; x < boxW; x++ { + ch := ' ' + st := style + if y == 0 || y == boxH-1 { + ch = '─' + st = border + } + if x == 0 || x == boxW-1 { + ch = '│' + st = border + } + if (x == 0 || x == boxW-1) && (y == 0 || y == boxH-1) { + switch { + case x == 0 && y == 0: + ch = '┌' + case x == boxW-1 && y == 0: + ch = '┐' + case x == 0 && y == boxH-1: + ch = '└' + default: + ch = '┘' + } + } + screen.SetContent(boxX+x, boxY+y, ch, nil, st) + } + } + writeStyledLine(screen, boxX+2, boxY+1, truncate(title, boxW-4), border.Bold(true)) + writeStyledLine(screen, boxX+2, boxY+2, padRight(truncate(text, boxW-4), boxW-4), style) + screen.ShowCursor(boxX+2+minInt(cursor, boxW-5), boxY+2) +} + +func handleSingleLineEditKey(ev *tcell.EventKey, text *[]rune, cursor *int, allowEnter bool) bool { + if text == nil || cursor == nil { + return false + } + switch ev.Key() { + case tcell.KeyLeft: + if *cursor > 0 { + *cursor = *cursor - 1 + } + return true + case tcell.KeyRight: + if *cursor < len(*text) { + *cursor = *cursor + 1 + } + return true + case tcell.KeyBackspace, tcell.KeyBackspace2: + if *cursor > 0 { + *text = append((*text)[:*cursor-1], (*text)[*cursor:]...) + *cursor = *cursor - 1 + } + return true + case tcell.KeyCtrlA: + *cursor = 0 + return true + case tcell.KeyCtrlE: + *cursor = len(*text) + return true + case tcell.KeyCtrlU: + *text = (*text)[*cursor:] + *cursor = 0 + return true + case tcell.KeyCtrlW: + start := previousWordBoundary(*text, *cursor) + *text = append((*text)[:start], (*text)[*cursor:]...) + *cursor = start + return true + case tcell.KeyRune: + r := ev.Rune() + *text = append((*text)[:*cursor], append([]rune{r}, (*text)[*cursor:]...)...) + *cursor = *cursor + 1 + return true + case tcell.KeyEnter: + return allowEnter + default: + return false + } +} + +func handleMultilineEditKey(ev *tcell.EventKey, value *string, cursor *int) bool { + runes := []rune(*value) + switch ev.Key() { + case tcell.KeyLeft: + if *cursor > 0 { + *cursor = *cursor - 1 + } + case tcell.KeyRight: + if *cursor < len(runes) { + *cursor = *cursor + 1 + } + case tcell.KeyUp: + line, col := cursorLineCol(*value, *cursor) + if line > 0 { + *cursor = lineColToIndex(*value, line-1, col) + } + case tcell.KeyDown: + line, col := cursorLineCol(*value, *cursor) + *cursor = lineColToIndex(*value, line+1, col) + case tcell.KeyBackspace, tcell.KeyBackspace2: + if *cursor > 0 { + runes = append(runes[:*cursor-1], runes[*cursor:]...) + *cursor = *cursor - 1 + } + case tcell.KeyEnter: + runes = append(runes[:*cursor], append([]rune{'\n'}, runes[*cursor:]...)...) + *cursor = *cursor + 1 + case tcell.KeyCtrlA: + line, _ := cursorLineCol(*value, *cursor) + *cursor = lineColToIndex(*value, line, 0) + case tcell.KeyCtrlE: + line, _ := cursorLineCol(*value, *cursor) + lines := strings.Split(*value, "\n") + if line >= len(lines) { + line = len(lines) - 1 + } + if line < 0 { + line = 0 + } + *cursor = lineColToIndex(*value, line, len([]rune(lines[line]))) + case tcell.KeyCtrlU: + line, _ := cursorLineCol(*value, *cursor) + start := lineColToIndex(*value, line, 0) + runes = append(runes[:start], runes[*cursor:]...) + *cursor = start + case tcell.KeyCtrlW: + start := previousWordBoundary(runes, *cursor) + runes = append(runes[:start], runes[*cursor:]...) + *cursor = start + case tcell.KeyRune: + r := ev.Rune() + runes = append(runes[:*cursor], append([]rune{r}, runes[*cursor:]...)...) + *cursor = *cursor + 1 + default: + return false + } + *value = string(runes) + return true +} + +func previousWordBoundary(runes []rune, cursor int) int { + i := cursor + for i > 0 && unicode.IsSpace(runes[i-1]) { + i-- + } + for i > 0 && !unicode.IsSpace(runes[i-1]) { + i-- + } + return i +} + +func cursorLineCol(value string, cursor int) (line, col int) { + runes := []rune(value) + if cursor < 0 { + cursor = 0 + } + if cursor > len(runes) { + cursor = len(runes) + } + for i := 0; i < cursor; i++ { + if runes[i] == '\n' { + line++ + col = 0 + } else { + col++ + } + } + return line, col +} + +func lineColToIndex(value string, wantLine, wantCol int) int { + lines := strings.Split(value, "\n") + if wantLine < 0 { + wantLine = 0 + } + if wantLine >= len(lines) { + wantLine = len(lines) - 1 + } + if wantLine < 0 { + return 0 + } + idx := 0 + for i := 0; i < wantLine; i++ { + idx += len([]rune(lines[i])) + 1 + } + lineRunes := []rune(lines[wantLine]) + if wantCol > len(lineRunes) { + wantCol = len(lineRunes) + } + if wantCol < 0 { + wantCol = 0 + } + return idx + wantCol +} + +func matchesKey(ev *tcell.EventKey, binding string) bool { + binding = strings.TrimSpace(binding) + if binding == "" { + return false + } + switch strings.ToLower(binding) { + case "enter": + return ev.Key() == tcell.KeyEnter + case "escape", "esc": + return ev.Key() == tcell.KeyEscape + case "space": + return ev.Key() == tcell.KeyRune && ev.Rune() == ' ' + default: + if len([]rune(binding)) == 1 { + return ev.Key() == tcell.KeyRune && unicode.ToLower(ev.Rune()) == unicode.ToLower([]rune(binding)[0]) + } + return false + } +} + +func wrapText(text string, width int) []string { + if width <= 0 { + return []string{""} + } + if text == "" { + return []string{""} + } + words := strings.Fields(text) + if len(words) == 0 { + return []string{""} + } + var lines []string + current := words[0] + for _, word := range words[1:] { + candidate := current + " " + word + if len([]rune(candidate)) <= width { + current = candidate + continue + } + lines = append(lines, current) + current = word + } + lines = append(lines, current) + return lines +} + +func truncate(text string, width int) string { + if width <= 0 { + return "" + } + runes := []rune(text) + if len(runes) <= width { + return text + } + if width == 1 { + return string(runes[:1]) + } + return string(runes[:width-1]) + "…" +} + +func writeStyledLine(s tcell.Screen, x, y int, text string, style tcell.Style) { + for idx, r := range []rune(text) { + s.SetContent(x+idx, y, r, nil, style) + } +} + +func padRight(text string, width int) string { + count := len([]rune(text)) + if count >= width { + return text + } + return text + strings.Repeat(" ", width-count) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/agent-tracker/cmd/agent/device_panel.go b/agent-tracker/cmd/agent/device_panel.go new file mode 100644 index 0000000..7e5b514 --- /dev/null +++ b/agent-tracker/cmd/agent/device_panel.go @@ -0,0 +1,305 @@ +package main + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type devicePanelMode int + +const ( + devicePanelModeList devicePanelMode = iota + devicePanelModeAdd + devicePanelModeConfirmDelete +) + +type devicePanelModel struct { + devices []string + selected int + mode devicePanelMode + width int + height int + addText []rune + addCursor int + deleteDevice string + status string + statusUntil time.Time + showAltHints bool + requestBack bool +} + +func newDevicePanelModel() *devicePanelModel { + model := &devicePanelModel{} + model.reload() + return model +} + +func (m *devicePanelModel) reload() { + m.devices = loadManagedDevices() + if len(m.devices) == 0 { + m.devices = []string{defaultManagedDeviceID} + } + m.selected = clampInt(m.selected, 0, len(m.devices)-1) +} + +func (m *devicePanelModel) Init() tea.Cmd { + return nil +} + +func (m *devicePanelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyMsg: + if isAltFooterToggleKey(msg) { + m.showAltHints = !m.showAltHints + return m, nil + } + m.showAltHints = false + key := msg.String() + switch m.mode { + case devicePanelModeAdd: + return m.updateAdd(key) + case devicePanelModeConfirmDelete: + return m.updateConfirmDelete(key) + default: + return m.updateList(key) + } + } + return m, nil +} + +func (m *devicePanelModel) updateList(key string) (tea.Model, tea.Cmd) { + switch key { + case "esc": + m.requestBack = true + case "ctrl+u", "alt+u", "up", "u": + m.selected = clampInt(m.selected-1, 0, len(m.devices)-1) + case "ctrl+e", "alt+e", "down", "e": + m.selected = clampInt(m.selected+1, 0, len(m.devices)-1) + case "a": + m.mode = devicePanelModeAdd + m.addText = nil + m.addCursor = 0 + case "d", "x", "delete": + deviceID := m.currentDevice() + if deviceID == defaultManagedDeviceID { + m.setStatus(defaultManagedDeviceID+" cannot be removed", 1500*time.Millisecond) + return m, nil + } + if deviceID != "" { + m.deleteDevice = deviceID + m.mode = devicePanelModeConfirmDelete + } + } + return m, nil +} + +func (m *devicePanelModel) updateAdd(key string) (tea.Model, tea.Cmd) { + if key == "esc" { + m.mode = devicePanelModeList + return m, nil + } + if key == "enter" { + deviceID := normalizeManagedDeviceID(string(m.addText)) + if deviceID == "" { + m.mode = devicePanelModeList + return m, nil + } + if err := addManagedDevice(deviceID); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + } else { + m.reload() + for idx, existing := range m.devices { + if existing == deviceID { + m.selected = idx + break + } + } + } + m.mode = devicePanelModeList + return m, nil + } + applyPaletteInputKey(key, &m.addText, &m.addCursor, true) + return m, nil +} + +func (m *devicePanelModel) updateConfirmDelete(key string) (tea.Model, tea.Cmd) { + if key == "esc" || key == "n" { + m.mode = devicePanelModeList + m.deleteDevice = "" + return m, nil + } + if key == "y" || key == "enter" { + if err := removeManagedDevice(m.deleteDevice); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + } else { + m.reload() + } + m.mode = devicePanelModeList + m.deleteDevice = "" + return m, nil + } + return m, nil +} + +func (m *devicePanelModel) View() string { + return m.render(newPaletteStyles(), m.width, m.height) +} + +func (m *devicePanelModel) render(styles paletteStyles, width, height int) string { + if width <= 0 { + width = 96 + } + if height <= 0 { + height = 28 + } + if m.mode == devicePanelModeAdd { + return m.renderAdd(styles, width, height) + } + if m.mode == devicePanelModeConfirmDelete { + return m.renderConfirmDelete(styles, width, height) + } + return m.renderList(styles, width, height) +} + +func (m *devicePanelModel) renderList(styles paletteStyles, width, height int) string { + header := lipgloss.JoinVertical(lipgloss.Left, + styles.title.Render("Devices"), + styles.meta.Render("Global launch devices managed by agent-tracker"), + ) + + lines := []string{styles.meta.Render(fmt.Sprintf("%d devices", len(m.devices))), ""} + for idx, deviceID := range m.devices { + rowStyle := styles.item.Width(maxInt(24, width-2)) + titleStyle := styles.itemTitle + metaStyle := styles.itemSubtitle + fillStyle := lipgloss.NewStyle() + badgeStyle := styles.keyword + badgeLabel := "CUSTOM" + if idx == m.selected { + selectedBG := lipgloss.Color("238") + rowStyle = styles.selectedItem.Width(maxInt(24, width-2)) + titleStyle = titleStyle.Background(selectedBG).Foreground(lipgloss.Color("230")) + metaStyle = styles.selectedSubtle.Background(selectedBG) + fillStyle = fillStyle.Background(selectedBG) + badgeStyle = badgeStyle.Background(lipgloss.Color("240")) + } + if deviceID == defaultManagedDeviceID { + badgeLabel = "DEFAULT" + badgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("150")).Padding(0, 1).Bold(true) + } + badge := badgeStyle.Render(badgeLabel) + innerWidth := maxInt(22, width-2) + titleWidth := maxInt(8, innerWidth-lipgloss.Width(badge)-1) + titleText := truncate(deviceID, titleWidth) + gapWidth := maxInt(1, innerWidth-lipgloss.Width(titleText)-lipgloss.Width(badge)) + titleRow := lipgloss.JoinHorizontal(lipgloss.Left, + titleStyle.Render(titleText), + fillStyle.Render(strings.Repeat(" ", gapWidth)), + badge, + ) + detail := "Manual launch device" + if deviceID == defaultManagedDeviceID { + detail = "Always available and cannot be removed" + } + detailText := truncate(detail, innerWidth) + detailGap := maxInt(0, innerWidth-lipgloss.Width(detailText)) + detailRow := lipgloss.JoinHorizontal(lipgloss.Left, + metaStyle.Render(detailText), + fillStyle.Render(strings.Repeat(" ", detailGap)), + ) + lines = append(lines, rowStyle.Render(lipgloss.JoinVertical(lipgloss.Left, titleRow, detailRow))) + } + + bodyHeight := maxInt(8, height-7) + body := lipgloss.NewStyle().Height(bodyHeight).Render(strings.Join(lines, "\n")) + footer := m.renderFooter(styles, width) + view := lipgloss.JoinVertical(lipgloss.Left, header, "", body, "", footer) + return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view) +} + +func (m *devicePanelModel) renderAdd(styles paletteStyles, width, height int) string { + body := lipgloss.JoinVertical(lipgloss.Left, + styles.modalTitle.Render("Add device"), + styles.modalBody.Render("Enter a global device id. Example: ios, macos, android."), + "", + styles.input.Render(renderInputValue(m.addText, m.addCursor, styles)), + "", + styles.modalHint.Render("Enter save Esc back"), + ) + box := styles.modal.Width(minInt(76, maxInt(36, width-10))).Render(body) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} + +func (m *devicePanelModel) renderConfirmDelete(styles paletteStyles, width, height int) string { + body := lipgloss.JoinVertical(lipgloss.Left, + styles.modalTitle.Render("Remove device"), + styles.modalBody.Render(fmt.Sprintf("Remove %s from the global device list?", m.deleteDevice)), + "", + styles.modalHint.Render("y confirm n cancel"), + ) + box := styles.modal.Width(minInt(64, maxInt(36, width-10))).Render(body) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} + +func (m *devicePanelModel) renderFooter(styles paletteStyles, width int) string { + status := strings.TrimSpace(m.currentStatus()) + renderSegments := func(pairs [][2]string) string { + return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs) + } + footer := "" + if m.showAltHints { + footer = pickRenderedShortcutFooter(width, renderSegments, + [][2]string{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, + [][2]string{{"Alt-S", "close"}}, + ) + } else { + footer = pickRenderedShortcutFooter(width, renderSegments, + [][2]string{{"u/e", "move"}, {"a", "add"}, {"d", "remove"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"a", "add"}, {"d", "remove"}, {footerHintToggleKey, "more"}}, + [][2]string{{"Esc", "back"}, {footerHintToggleKey, "more"}}, + ) + } + if status != "" { + statusText := styles.statusBad.Render(truncate(status, maxInt(12, minInt(24, width/3)))) + if lipgloss.Width(footer)+2+lipgloss.Width(statusText) <= width { + gap := width - lipgloss.Width(footer) - lipgloss.Width(statusText) + if gap < 2 { + gap = 2 + } + return footer + strings.Repeat(" ", gap) + statusText + } + return statusText + } + return lipgloss.NewStyle().Width(width).Render(footer) +} + +func (m *devicePanelModel) currentDevice() string { + if len(m.devices) == 0 || m.selected < 0 || m.selected >= len(m.devices) { + return "" + } + return m.devices[m.selected] +} + +func (m *devicePanelModel) setStatus(text string, duration time.Duration) { + m.status = text + m.statusUntil = time.Now().Add(duration) +} + +func (m *devicePanelModel) currentStatus() string { + if m.status == "" { + return "" + } + if !m.statusUntil.IsZero() && time.Now().After(m.statusUntil) { + m.status = "" + return "" + } + return m.status +} diff --git a/agent-tracker/cmd/agent/devices.go b/agent-tracker/cmd/agent/devices.go new file mode 100644 index 0000000..60cd1c1 --- /dev/null +++ b/agent-tracker/cmd/agent/devices.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +const defaultManagedDeviceID = "web-server" + +var managedDevicePattern = regexp.MustCompile(`[^a-z0-9._-]+`) + +func normalizeManagedDeviceID(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, " ", "-") + value = managedDevicePattern.ReplaceAllString(value, "-") + value = strings.Trim(value, "-._") + return value +} + +func normalizeManagedDevices(values []string) []string { + seen := map[string]bool{defaultManagedDeviceID: true} + devices := []string{defaultManagedDeviceID} + for _, value := range values { + deviceID := normalizeManagedDeviceID(value) + if deviceID == "" || seen[deviceID] { + continue + } + seen[deviceID] = true + devices = append(devices, deviceID) + } + return devices +} + +func loadManagedDevices() []string { + return normalizeManagedDevices(loadAppConfig().Devices) +} + +func saveManagedDevices(devices []string) error { + return updateAppConfig(func(cfg *appConfig) { + cfg.Devices = normalizeManagedDevices(devices) + }) +} + +func addManagedDevice(deviceID string) error { + deviceID = normalizeManagedDeviceID(deviceID) + if deviceID == "" { + return fmt.Errorf("device id is required") + } + devices := loadManagedDevices() + for _, existing := range devices { + if existing == deviceID { + return fmt.Errorf("device %q already exists", deviceID) + } + } + devices = append(devices, deviceID) + return saveManagedDevices(devices) +} + +func removeManagedDevice(deviceID string) error { + deviceID = normalizeManagedDeviceID(deviceID) + if deviceID == "" { + return fmt.Errorf("device id is required") + } + if deviceID == defaultManagedDeviceID { + return fmt.Errorf("%s cannot be removed", defaultManagedDeviceID) + } + devices := loadManagedDevices() + filtered := make([]string, 0, len(devices)) + removed := false + for _, existing := range devices { + if existing == deviceID { + removed = true + continue + } + filtered = append(filtered, existing) + } + if !removed { + return fmt.Errorf("device %q not found", deviceID) + } + return saveManagedDevices(filtered) +} + +func updateAppConfig(update func(*appConfig)) error { + cfg := loadAppConfig() + update(&cfg) + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + path := configPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return err + } + return os.Rename(tmpPath, path) +} diff --git a/agent-tracker/cmd/agent/devices_test.go b/agent-tracker/cmd/agent/devices_test.go new file mode 100644 index 0000000..52fdb3e --- /dev/null +++ b/agent-tracker/cmd/agent/devices_test.go @@ -0,0 +1,32 @@ +package main + +import "testing" + +func TestLoadManagedDevicesDefaultsToWebServer(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + devices := loadManagedDevices() + if len(devices) != 1 || devices[0] != defaultManagedDeviceID { + t.Fatalf("unexpected default devices: %#v", devices) + } +} + +func TestSaveManagedDevicesKeepsWebServerFirst(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := saveManagedDevices([]string{"ios", "web-server", "macos", "IOS"}); err != nil { + t.Fatalf("save managed devices: %v", err) + } + devices := loadManagedDevices() + if len(devices) != 3 { + t.Fatalf("unexpected devices length: %#v", devices) + } + if devices[0] != defaultManagedDeviceID || devices[1] != "ios" || devices[2] != "macos" { + t.Fatalf("unexpected normalized devices: %#v", devices) + } +} + +func TestRemoveManagedDeviceRejectsWebServer(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := removeManagedDevice(defaultManagedDeviceID); err == nil { + t.Fatal("expected web-server removal to fail") + } +} diff --git a/agent-tracker/cmd/agent/flutter_helpers_test.go b/agent-tracker/cmd/agent/flutter_helpers_test.go new file mode 100644 index 0000000..becddf1 --- /dev/null +++ b/agent-tracker/cmd/agent/flutter_helpers_test.go @@ -0,0 +1,256 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func initTestGitRepo(t *testing.T, repo string) { + t.Helper() + cmd := exec.Command("git", "init") + cmd.Dir = repo + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, strings.TrimSpace(string(output))) + } +} + +func gitAddPath(t *testing.T, repo, relPath string) { + t.Helper() + cmd := exec.Command("git", "add", "--", relPath) + cmd.Dir = repo + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add %s: %v: %s", relPath, err, strings.TrimSpace(string(output))) + } +} + +func gitCommitAll(t *testing.T, repo, message string) { + t.Helper() + cmd := exec.Command("git", "-c", "user.name=Test User", "-c", "user.email=test@example.com", "commit", "-m", message) + cmd.Dir = repo + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit: %v: %s", err, strings.TrimSpace(string(output))) + } +} + +func gitStatusPath(t *testing.T, repo, relPath string) string { + t.Helper() + cmd := exec.Command("git", "status", "--porcelain", "--", relPath) + cmd.Dir = repo + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git status %s: %v: %s", relPath, err, strings.TrimSpace(string(output))) + } + return strings.TrimSpace(string(output)) +} + +func gitLsFilesPath(t *testing.T, repo, relPath string) string { + t.Helper() + cmd := exec.Command("git", "ls-files", "-v", "--", relPath) + cmd.Dir = repo + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git ls-files %s: %v: %s", relPath, err, strings.TrimSpace(string(output))) + } + return strings.TrimSpace(string(output)) +} + +func TestWriteFlutterHelperScriptsCreatesHotReloadScript(t *testing.T) { + workspace := t.TempDir() + repo := filepath.Join(workspace, "repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + initTestGitRepo(t, repo) + if err := os.WriteFile(filepath.Join(repo, "hot-reload.sh"), []byte("#!/bin/bash\nexit 0\n"), 0o755); err != nil { + t.Fatalf("seed tracked hot-reload.sh: %v", err) + } + gitAddPath(t, repo, "hot-reload.sh") + gitCommitAll(t, repo, "seed hot reload") + if err := writeFlutterHelperScripts(workspace, repo, "http://localhost:9100", "web-server"); err != nil { + t.Fatalf("write flutter helper scripts: %v", err) + } + + for _, path := range []string{filepath.Join(workspace, "ensure-server.sh"), filepath.Join(repo, "hot-reload.sh")} { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + if info.Mode()&0o111 == 0 { + t.Fatalf("expected %s to be executable, mode=%v", path, info.Mode()) + } + } + + if _, err := os.Stat(filepath.Join(workspace, "hot-reload.sh")); !os.IsNotExist(err) { + t.Fatalf("expected workspace hot-reload.sh to be removed, err=%v", err) + } + + ensureData, err := os.ReadFile(filepath.Join(workspace, "ensure-server.sh")) + if err != nil { + t.Fatalf("read ensure-server.sh: %v", err) + } + ensureText := string(ensureData) + for _, snippet := range []string{ + `tmux capture-pane -p -S -200 -t "$TMUX_PANE"`, + `browser refresh --workspace "$DIR" --preserve-focus`, + } { + if !strings.Contains(ensureText, snippet) { + t.Fatalf("expected ensure-server.sh to contain %q", snippet) + } + } + + data, err := os.ReadFile(filepath.Join(repo, "hot-reload.sh")) + if err != nil { + t.Fatalf("read hot-reload.sh: %v", err) + } + text := string(data) + for _, snippet := range []string{ + `WORKSPACE_DIR="$(dirname "$REPO_DIR")"`, + `flutter analyze lib --no-fatal-infos --no-fatal-warnings`, + `tmux send-keys -t "$target_pane" r`, + `cd '$WORKSPACE_DIR' && ./ensure-server.sh`, + `browser refresh --workspace "$WORKSPACE_DIR" --preserve-focus`, + } { + if !strings.Contains(text, snippet) { + t.Fatalf("expected hot-reload.sh to contain %q", snippet) + } + } + + if status := gitStatusPath(t, repo, "hot-reload.sh"); status != "" { + t.Fatalf("expected repo hot-reload.sh to stay hidden from git status, got %q", status) + } +} + +func TestWriteFlutterHelperScriptsRemovesLegacyBrowserHelpers(t *testing.T) { + workspace := t.TempDir() + repo := filepath.Join(workspace, "repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + initTestGitRepo(t, repo) + for _, name := range []string{"open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} { + if err := os.WriteFile(filepath.Join(workspace, name), []byte("legacy"), 0o755); err != nil { + t.Fatalf("seed %s: %v", name, err) + } + } + if err := os.WriteFile(filepath.Join(workspace, "hot-reload.sh"), []byte("legacy"), 0o755); err != nil { + t.Fatalf("seed workspace hot-reload.sh: %v", err) + } + + if err := writeFlutterHelperScripts(workspace, repo, "http://localhost:9100", "web-server"); err != nil { + t.Fatalf("write flutter helper scripts: %v", err) + } + + for _, name := range []string{"hot-reload.sh", "open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} { + if _, err := os.Stat(filepath.Join(workspace, name)); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed, err=%v", name, err) + } + } +} + +func TestRunFeatureCommandWriteHelperScripts(t *testing.T) { + workspace := t.TempDir() + repo := filepath.Join(workspace, "repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + initTestGitRepo(t, repo) + if err := saveFeatureConfig(filepath.Join(workspace, "agent.json"), featureConfig{ + Feature: "demo", + Port: 9100, + URL: "http://localhost:9100", + Device: "web-server", + IsFlutter: true, + }); err != nil { + t.Fatalf("save feature config: %v", err) + } + + if err := runFeatureCommand([]string{"--workspace", workspace, "--write-helper-scripts"}); err != nil { + t.Fatalf("run feature command: %v", err) + } + + if _, err := os.Stat(filepath.Join(repo, "hot-reload.sh")); err != nil { + t.Fatalf("expected hot-reload.sh after rewrite: %v", err) + } +} + +func TestWriteFlutterHelperScriptsIgnoresUntrackedRepoHelper(t *testing.T) { + workspace := t.TempDir() + repo := filepath.Join(workspace, "repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + initTestGitRepo(t, repo) + + if err := writeFlutterHelperScripts(workspace, repo, "http://localhost:9100", "web-server"); err != nil { + t.Fatalf("write flutter helper scripts: %v", err) + } + + if status := gitStatusPath(t, repo, "hot-reload.sh"); status != "" { + t.Fatalf("expected untracked repo hot-reload.sh to stay hidden from git status, got %q", status) + } + + data, err := os.ReadFile(filepath.Join(repo, ".git", "info", "exclude")) + if err != nil { + t.Fatalf("read .git/info/exclude: %v", err) + } + if !strings.Contains(string(data), "hot-reload.sh") { + t.Fatalf("expected .git/info/exclude to contain hot-reload.sh") + } +} + +func TestRunBootstrapRewritesTrackedHotReloadScript(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + repoRoot := t.TempDir() + initTestGitRepo(t, repoRoot) + if err := os.WriteFile(filepath.Join(repoRoot, "pubspec.yaml"), []byte("name: demo\n"), 0o644); err != nil { + t.Fatalf("write pubspec: %v", err) + } + if err := os.WriteFile(filepath.Join(repoRoot, "hot-reload.sh"), []byte("#!/bin/bash\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write source hot-reload: %v", err) + } + gitAddPath(t, repoRoot, "pubspec.yaml") + gitAddPath(t, repoRoot, "hot-reload.sh") + gitCommitAll(t, repoRoot, "seed flutter repo") + + workspace := filepath.Join(repoRoot, ".agents", "feature-x") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + if err := saveFeatureConfig(filepath.Join(workspace, "agent.json"), featureConfig{ + Feature: "feature-x", + Port: 9100, + URL: "http://localhost:9100", + Device: "web-server", + IsFlutter: true, + }); err != nil { + t.Fatalf("save feature config: %v", err) + } + + if err := runBootstrap([]string{"--workspace", workspace}); err != nil { + t.Fatalf("run bootstrap: %v", err) + } + + repoCopy := filepath.Join(workspace, "repo") + data, err := os.ReadFile(filepath.Join(repoCopy, "hot-reload.sh")) + if err != nil { + t.Fatalf("read rewritten hot-reload: %v", err) + } + text := string(data) + for _, snippet := range []string{ + `INFO="$WORKSPACE_DIR/agent.json"`, + `browser refresh --workspace "$WORKSPACE_DIR" --preserve-focus`, + } { + if !strings.Contains(text, snippet) { + t.Fatalf("expected rewritten hot-reload to contain %q", snippet) + } + } + if status := gitStatusPath(t, repoCopy, "hot-reload.sh"); status != "" { + t.Fatalf("expected rewritten hot-reload to stay hidden from git status, got %q", status) + } + if marker := gitLsFilesPath(t, repoCopy, "hot-reload.sh"); !strings.HasPrefix(marker, "S ") { + t.Fatalf("expected rewritten hot-reload to be marked skip-worktree, got %q", marker) + } +} diff --git a/agent-tracker/cmd/agent/footer_hints.go b/agent-tracker/cmd/agent/footer_hints.go new file mode 100644 index 0000000..fb6c283 --- /dev/null +++ b/agent-tracker/cmd/agent/footer_hints.go @@ -0,0 +1,37 @@ +package main + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const footerHintToggleKey = "?" + +func isAltFooterToggleKey(msg tea.KeyMsg) bool { + return msg.String() == footerHintToggleKey || (msg.Alt && msg.Type == tea.KeyEscape) +} + +func renderShortcutPairs(renderKey func(string) string, renderText func(string) string, gap string, pairs [][2]string) string { + segments := make([]string, 0, len(pairs)) + for _, pair := range pairs { + segments = append(segments, renderKey(pair[0])+renderText(" "+pair[1])) + } + return strings.Join(segments, gap) +} + +func pickRenderedShortcutFooter(width int, render func([][2]string) string, candidates ...[][2]string) string { + if len(candidates) == 0 { + return "" + } + footer := render(candidates[len(candidates)-1]) + for _, candidate := range candidates { + rendered := render(candidate) + if lipgloss.Width(rendered) <= maxInt(1, width) { + return rendered + } + footer = rendered + } + return footer +} diff --git a/agent-tracker/cmd/agent/main.go b/agent-tracker/cmd/agent/main.go new file mode 100644 index 0000000..0be4a45 --- /dev/null +++ b/agent-tracker/cmd/agent/main.go @@ -0,0 +1,3137 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "gopkg.in/yaml.v3" +) + +type registry struct { + Agents map[string]*agentRecord `json:"agents"` + FocusedAgentID string `json:"focused_agent_id,omitempty"` +} + +type agentRecord struct { + ID string `json:"id"` + Name string `json:"name"` + RepoRoot string `json:"repo_root"` + WorkspaceRoot string `json:"workspace_root"` + RepoCopyPath string `json:"repo_copy_path"` + Branch string `json:"branch"` + SourceBranch string `json:"source_branch,omitempty"` + KeepWorktree bool `json:"keep_worktree,omitempty"` + Runtime string `json:"runtime,omitempty"` + Device string `json:"device,omitempty"` + FeatureConfig string `json:"feature_config,omitempty"` + RunLogPath string `json:"run_log_path,omitempty"` + Port int `json:"port,omitempty"` + URL string `json:"url,omitempty"` + BrowserEnabled bool `json:"browser_enabled,omitempty"` + TmuxSessionName string `json:"tmux_session_name,omitempty"` + TmuxSessionID string `json:"tmux_session_id,omitempty"` + TmuxWindowID string `json:"tmux_window_id,omitempty"` + Panes agentPanes `json:"panes"` + Dashboard dashboardDoc `json:"dashboard"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastFocusedAt *time.Time `json:"last_focused_at,omitempty"` + LaunchWindowID string `json:"-"` +} + +type agentPanes struct { + AI string `json:"ai,omitempty"` + Git string `json:"git,omitempty"` + Run string `json:"run,omitempty"` + Dashboard string `json:"dashboard,omitempty"` +} + +type agentStartOptions struct { + SourceBranch string + KeepWorktree bool +} + +type dashboardDoc struct { + Todos []todoItem `json:"todos"` + Notes string `json:"notes"` + CurrentTask string `json:"current_task"` +} + +type todoItem struct { + Title string `json:"title"` + Done bool `json:"done"` +} + +type appConfig struct { + Keys keyConfig `json:"keys"` + Devices []string `json:"devices,omitempty"` +} + +type keyConfig struct { + MoveLeft string `json:"move_left"` + MoveRight string `json:"move_right"` + MoveUp string `json:"move_up"` + MoveDown string `json:"move_down"` + Edit string `json:"edit"` + Cancel string `json:"cancel"` + AddTodo string `json:"add_todo"` + ToggleTodo string `json:"toggle_todo"` + Destroy string `json:"destroy"` + Confirm string `json:"confirm"` + Back string `json:"back"` + DeleteTodo string `json:"delete_todo"` + Help string `json:"help"` + FocusAI string `json:"focus_ai"` + FocusGit string `json:"focus_git"` + FocusDash string `json:"focus_dashboard"` + FocusRun string `json:"focus_run"` +} + +type repoConfig struct { + BaseBranch string `yaml:"base_branch,omitempty"` + CopyIgnore []string `yaml:"copy_ignore,omitempty"` + AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"` +} + +type featureConfig struct { + Feature string `json:"feature"` + Port int `json:"port,omitempty"` + URL string `json:"url,omitempty"` + Device string `json:"device"` + IsFlutter bool `json:"is_flutter,omitempty"` + Ready bool `json:"ready,omitempty"` + ShouldOpenTab bool `json:"should_open_tab,omitempty"` + ChromeWindow int `json:"chrome_window_index,omitempty"` + ChromeTab int `json:"chrome_tab_index,omitempty"` +} + +var featureNamePattern = regexp.MustCompile(`[^a-z0-9._-]+`) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: agent ") + } + switch args[0] { + case "start": + return runStart(args[1:]) + case "resume": + return runResume(args[1:]) + case "list": + return runList(args[1:]...) + case "destroy": + return runDestroy(args[1:]) + case "init": + return runInit(args[1:]) + case "config": + return runConfig(args[1:]) + case "setup": + return runSetup(args[1:]) + case "dashboard": + return runDashboard(args[1:]) + case "palette": + return runPalette(args[1:]) + case "tmux": + return runTmuxCommand(args[1:]) + case "tracker": + return runTracker(args[1:]) + case "browser": + return runBrowserCommand(args[1:]) + case "feature": + return runFeatureCommand(args[1:]) + case "bootstrap": + return runBootstrap(args[1:]) + default: + return fmt.Errorf("unknown subcommand: %s", args[0]) + } +} + +func runStart(args []string) error { + fs := flag.NewFlagSet("agent start", flag.ContinueOnError) + var feature string + var device string + var noDevice bool + var keepWorktree bool + fs.StringVar(&feature, "name", "", "feature name") + fs.StringVar(&device, "d", "", "flutter device") + fs.BoolVar(&noDevice, "no-device", false, "leave the run pane idle until a device is chosen") + fs.BoolVar(&keepWorktree, "keep-worktree", false, "copy the current repo worktree into the new agent") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + repoRoot, err := repoRoot() + if err != nil { + return fmt.Errorf("%w; run `agent init` in your repo to set up agent config", err) + } + isFlutter := fileExists(filepath.Join(repoRoot, "pubspec.yaml")) + if err := ensureGitExcludeEntries(repoRoot, []string{".agents"}); err != nil { + return err + } + repoCfg, err := loadRepoConfig(repoRoot) + if err != nil { + return err + } + if feature == "" && fs.NArg() > 0 { + feature = fs.Arg(0) + } + feature = sanitizeFeatureName(feature) + if feature == "" { + value, err := promptInput("Feature name: ") + if err != nil { + return err + } + feature = sanitizeFeatureName(value) + } + if feature == "" { + return fmt.Errorf("feature name is required") + } + + reg, err := loadRegistry() + if err != nil { + return err + } + if _, exists := reg.Agents[feature]; exists { + return fmt.Errorf("agent %q already exists", feature) + } + + workspaceRoot := filepath.Join(repoRoot, ".agents", feature) + repoCopyPath := filepath.Join(workspaceRoot, "repo") + featureConfigPath := filepath.Join(workspaceRoot, "agent.json") + if err := os.MkdirAll(filepath.Join(workspaceRoot, "logs"), 0o755); err != nil { + return err + } + if err := os.MkdirAll(repoCopyPath, 0o755); err != nil { + return err + } + port := 0 + url := "" + runtime := "" + browserEnabled := false + device = strings.TrimSpace(device) + if isFlutter { + runtime = "flutter" + if noDevice { + device = "" + } else if device == "" { + device = "web-server" + } + browserEnabled = device == "web-server" + port, err = allocatePort(repoRoot, 9100) + if err != nil { + return err + } + url = fmt.Sprintf("http://localhost:%d", port) + if err := saveFeatureConfig(featureConfigPath, featureConfig{ + Feature: feature, + Port: port, + URL: url, + Device: device, + IsFlutter: true, + Ready: false, + ShouldOpenTab: false, + }); err != nil { + return err + } + if device == "web-server" { + if err := ensureChromeAppleEventsEnabled(); err != nil { + return err + } + } + } + sourceBranch := resolveStartSourceBranch(repoRoot, repoCfg) + if err := prepareAgentContext(repoRoot, repoCopyPath, repoCfg.AgentKeyPaths, false); err != nil { + return err + } + if isFlutter { + if err := writeFlutterHelperScripts(workspaceRoot, repoCopyPath, url, device); err != nil { + return err + } + } + + record := &agentRecord{ + ID: feature, + Name: feature, + RepoRoot: repoRoot, + WorkspaceRoot: workspaceRoot, + RepoCopyPath: repoCopyPath, + Branch: feature, + SourceBranch: sourceBranch, + KeepWorktree: keepWorktree, + Runtime: runtime, + Device: device, + FeatureConfig: featureConfigPath, + RunLogPath: filepath.Join(workspaceRoot, "logs", "run.log"), + Port: port, + URL: url, + BrowserEnabled: browserEnabled, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + LaunchWindowID: strings.TrimSpace(os.Getenv("AGENT_TMUX_TARGET_WINDOW")), + } + reg.Agents[record.ID] = record + if err := saveRegistry(reg); err != nil { + return err + } + bootstrapPID, err := spawnWorkspaceBootstrap(workspaceRoot) + if err != nil { + delete(reg.Agents, record.ID) + _ = saveRegistry(reg) + _ = os.RemoveAll(workspaceRoot) + return err + } + + if err := launchAgentLayout(record); err != nil { + _ = killProcessGroup(bootstrapPID) + delete(reg.Agents, record.ID) + _ = saveRegistry(reg) + _ = os.RemoveAll(workspaceRoot) + return err + } + _ = primeAgentAIPane(record.Panes.AI) + return nil +} + +func runInit(args []string) error { + fs := flag.NewFlagSet("agent init", flag.ContinueOnError) + var force bool + fs.BoolVar(&force, "force", false, "overwrite an existing .agent.yaml") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + repoRoot, err := repoRoot() + if err != nil { + return err + } + if err := ensureFlutterWebRepo(repoRoot); err != nil { + return err + } + path := repoConfigPath(repoRoot) + configExisted := fileExists(path) + if configExisted && !force { + fmt.Printf("Keeping existing %s\n", path) + return nil + } + cfg := defaultRepoConfig() + cfg.BaseBranch = detectDefaultBaseBranch(repoRoot) + if err := saveRepoConfig(repoRoot, cfg); err != nil { + return err + } + fmt.Printf("Wrote %s\n", path) + return nil +} + +func runConfig(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: agent config ") + } + repoRoot, err := repoRoot() + if err != nil { + return err + } + cfg, err := loadRepoConfig(repoRoot) + if err != nil { + return err + } + switch args[0] { + case "show": + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + fmt.Printf("%s\n%s", repoConfigPath(repoRoot), string(data)) + return nil + case "set-base-branch": + branch := "" + if len(args) > 1 { + branch = strings.TrimSpace(args[1]) + } + if branch == "" { + branch, err = promptInputWithDefault("Base branch", cfg.BaseBranch) + if err != nil { + return err + } + branch = strings.TrimSpace(branch) + } + if branch == "" { + return fmt.Errorf("base branch is required") + } + cfg.BaseBranch = branch + case "add-ignore": + values := normalizeIgnoreValues(args[1:]) + if len(values) == 0 { + value, err := promptInput("Ignore path to add: ") + if err != nil { + return err + } + values = normalizeIgnoreValues([]string{value}) + } + if len(values) == 0 { + return fmt.Errorf("at least one ignore path is required") + } + for _, value := range values { + if !containsString(cfg.CopyIgnore, value) { + cfg.CopyIgnore = append(cfg.CopyIgnore, value) + } + } + case "remove-ignore": + values := normalizeIgnoreValues(args[1:]) + if len(values) == 0 { + value, err := promptInput("Ignore path to remove: ") + if err != nil { + return err + } + values = normalizeIgnoreValues([]string{value}) + } + if len(values) == 0 { + return fmt.Errorf("at least one ignore path is required") + } + filtered := cfg.CopyIgnore[:0] + for _, existing := range cfg.CopyIgnore { + if !containsString(values, existing) { + filtered = append(filtered, existing) + } + } + cfg.CopyIgnore = filtered + default: + return fmt.Errorf("unknown config subcommand: %s", args[0]) + } + if err := saveRepoConfig(repoRoot, cfg); err != nil { + return err + } + fmt.Printf("Wrote %s\n", repoConfigPath(repoRoot)) + return nil +} + +func runSetup(args []string) error { + fs := flag.NewFlagSet("agent setup", flag.ContinueOnError) + var baseBranch string + fs.StringVar(&baseBranch, "base-branch", "", "base branch used for copied repos") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + forwarded := []string{"set-base-branch"} + if strings.TrimSpace(baseBranch) != "" { + forwarded = append(forwarded, strings.TrimSpace(baseBranch)) + } + return runConfig(forwarded) +} + +func spawnWorkspaceBootstrap(workspaceRoot string) (int, error) { + exe, err := os.Executable() + if err != nil { + return 0, err + } + if err := os.MkdirAll(filepath.Join(workspaceRoot, "logs"), 0o755); err != nil { + return 0, err + } + logFile, err := os.OpenFile(bootstrapLogPath(workspaceRoot), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return 0, err + } + cmd := exec.Command(exe, "bootstrap", "--workspace", workspaceRoot) + cmd.Stdin = nil + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + _ = logFile.Close() + return 0, err + } + pid := cmd.Process.Pid + _ = logFile.Close() + return pid, nil +} + +func spawnDetachedAgentCommand(args ...string) error { + exe, err := os.Executable() + if err != nil { + return err + } + devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return err + } + defer devNull.Close() + cmd := exec.Command(exe, args...) + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + return cmd.Start() +} + +func processRunning(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil || err == syscall.EPERM +} + +func killProcessGroup(pid int) error { + if pid <= 0 { + return nil + } + err := syscall.Kill(-pid, syscall.SIGTERM) + if err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + time.Sleep(150 * time.Millisecond) + if !processRunning(pid) { + return nil + } + err = syscall.Kill(-pid, syscall.SIGKILL) + if err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + return nil +} + +func stopWorkspaceBootstrap(workspaceRoot string) error { + data, err := os.ReadFile(bootstrapPIDPath(workspaceRoot)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return err + } + return killProcessGroup(pid) +} + +func ensureWorkspaceBootstrap(record *agentRecord, _ *repoConfig) error { + if fileExists(bootstrapRepoReadyPath(record.WorkspaceRoot)) { + return nil + } + data, err := os.ReadFile(bootstrapPIDPath(record.WorkspaceRoot)) + if err == nil { + pid, convErr := strconv.Atoi(strings.TrimSpace(string(data))) + if convErr == nil && processRunning(pid) { + return nil + } + } + _, err = spawnWorkspaceBootstrap(record.WorkspaceRoot) + return err +} + +func resetBootstrapState(workspaceRoot string) error { + stateDir := bootstrapStateDirPath(workspaceRoot) + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return err + } + for _, path := range []string{ + bootstrapGitReadyPath(workspaceRoot), + bootstrapRepoReadyPath(workspaceRoot), + bootstrapFailedPath(workspaceRoot), + } { + _ = os.Remove(path) + } + return nil +} + +func markBootstrapReady(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(time.Now().Format(time.RFC3339Nano)+"\n"), 0o644) +} + +func writeBootstrapFailure(workspaceRoot string, err error) { + if err == nil { + _ = os.Remove(bootstrapFailedPath(workspaceRoot)) + return + } + _ = os.MkdirAll(bootstrapStateDirPath(workspaceRoot), 0o755) + _ = os.Remove(bootstrapGitReadyPath(workspaceRoot)) + _ = os.Remove(bootstrapRepoReadyPath(workspaceRoot)) + _ = os.WriteFile(bootstrapFailedPath(workspaceRoot), []byte(err.Error()+"\n"), 0o644) +} + +func runBootstrap(args []string) error { + fs := flag.NewFlagSet("agent bootstrap", flag.ContinueOnError) + var workspaceRoot string + fs.StringVar(&workspaceRoot, "workspace", "", "workspace root") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) + if workspaceRoot == "" { + return fmt.Errorf("--workspace is required") + } + repoRoot := repoRootFromWorkspaceRoot(workspaceRoot) + if repoRoot == "" { + return fmt.Errorf("unable to detect repo root for %s", workspaceRoot) + } + repoCfg, err := loadRepoConfigOrDefault(repoRoot) + if err != nil { + return err + } + startOptions := resolveBootstrapStartOptions(repoRoot, repoCfg, loadAgentRecordByWorkspaceRoot(workspaceRoot)) + feature := sanitizeFeatureName(filepath.Base(workspaceRoot)) + featureCfgPath := filepath.Join(workspaceRoot, "agent.json") + featureCfg, featureErr := loadFeatureConfig(featureCfgPath) + isFlutter := fileExists(filepath.Join(repoRoot, "pubspec.yaml")) + port := 0 + if featureErr == nil { + if sanitized := sanitizeFeatureName(featureCfg.Feature); sanitized != "" { + feature = sanitized + } + isFlutter = featureCfg.IsFlutter || isFlutter + port = featureCfg.Port + } + if feature == "" { + return fmt.Errorf("feature name is required") + } + if err := resetBootstrapState(workspaceRoot); err != nil { + return err + } + if err := os.WriteFile(bootstrapPIDPath(workspaceRoot), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil { + return err + } + defer func() { _ = os.Remove(bootstrapPIDPath(workspaceRoot)) }() + defer writeBootstrapFailure(workspaceRoot, err) + + repoCopyPath := filepath.Join(workspaceRoot, "repo") + if err = copyGitMetadata(repoRoot, repoCopyPath); err != nil { + return err + } + if err = ensureRepoCopyLocalExcludes(repoCopyPath, isFlutter); err != nil { + return err + } + if _, err = createFeatureBranch(repoCopyPath, feature, startOptions.SourceBranch, repoCfg.AgentKeyPaths); err != nil { + return err + } + if err = applyRepoCopyIgnores(repoRoot, repoCopyPath, repoCfg.CopyIgnore); err != nil { + return err + } + if startOptions.KeepWorktree { + if err = syncRepoWorktree(repoRoot, repoCopyPath, repoCfg.CopyIgnore); err != nil { + return err + } + } + if err = markBootstrapReady(bootstrapGitReadyPath(workspaceRoot)); err != nil { + return err + } + if isFlutter { + if err = removeLegacyRuntimeProject(workspaceRoot); err != nil { + return err + } + if err = configureFlutterWebConfig(repoRoot, repoCopyPath, port); err != nil { + return err + } + if featureErr == nil { + if err = writeFlutterHelperScripts(workspaceRoot, repoCopyPath, featureCfg.URL, featureCfg.Device); err != nil { + return err + } + } + } + if err = markBootstrapReady(bootstrapRepoReadyPath(workspaceRoot)); err != nil { + return err + } + writeBootstrapFailure(workspaceRoot, nil) + return nil +} + +func runResume(args []string) error { + repoRoot, err := repoRoot() + if err != nil { + return fmt.Errorf("agent resume defaults to the current repo; run inside a git repo") + } + reg, err := loadRegistry() + if err != nil { + reg = ®istry{Agents: map[string]*agentRecord{}} + } + recordsByID, err := loadWorkspaceAgentRecords(repoRoot, reg) + if err != nil { + return err + } + if len(recordsByID) == 0 { + return fmt.Errorf("no agents found in %s", filepath.Join(repoRoot, ".agents")) + } + var agentID string + if len(args) > 0 { + agentID = sanitizeFeatureName(args[0]) + } else { + ids := make([]string, 0, len(recordsByID)) + for id := range recordsByID { + ids = append(ids, id) + } + sort.Strings(ids) + fmt.Println("Select an agent:") + for idx, id := range ids { + fmt.Printf(" [%d] %s\n", idx+1, id) + } + value, err := promptInput("Choice: ") + if err != nil { + return err + } + choice, convErr := strconv.Atoi(strings.TrimSpace(value)) + if convErr != nil || choice < 1 || choice > len(ids) { + return fmt.Errorf("invalid choice") + } + agentID = ids[choice-1] + } + record := recordsByID[agentID] + if record == nil { + return fmt.Errorf("unknown agent: %s", agentID) + } + if _, err := os.Stat(record.RepoCopyPath); err != nil { + return fmt.Errorf("agent repo copy missing: %s", record.RepoCopyPath) + } + if windowAlive(record.TmuxSessionID, record.TmuxWindowID) { + return selectTmuxWindow(record.TmuxWindowID) + } + repoCfg, err := loadRepoConfigOrDefault(record.RepoRoot) + if err != nil { + return err + } + if err := prepareAgentContext(record.RepoRoot, record.RepoCopyPath, repoCfg.AgentKeyPaths, true); err != nil { + return err + } + if err := removeLegacyRuntimeProject(record.WorkspaceRoot); err != nil { + return err + } + if err := ensureWorkspaceBootstrap(record, repoCfg); err != nil { + return err + } + if record.Runtime == "flutter" { + if strings.TrimSpace(record.Device) == "web-server" { + if err := ensureChromeAppleEventsEnabled(); err != nil { + return err + } + } + if err := writeFlutterHelperScripts(record.WorkspaceRoot, record.RepoCopyPath, record.URL, record.Device); err != nil { + return err + } + } + if err := launchAgentLayout(record); err != nil { + return err + } + return nil +} + +func loadWorkspaceAgentRecords(repoRoot string, reg *registry) (map[string]*agentRecord, error) { + agentsRoot := filepath.Join(repoRoot, ".agents") + entries, err := os.ReadDir(agentsRoot) + if errors.Is(err, os.ErrNotExist) { + return map[string]*agentRecord{}, nil + } + if err != nil { + return nil, err + } + recordsByID := make(map[string]*agentRecord) + 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") + if !pathExists(featurePath) && !pathExists(repoCopyPath) { + continue + } + record, err := loadWorkspaceAgentRecord(repoRoot, workspaceRoot, reg) + if err != nil { + return nil, err + } + if record == nil { + continue + } + if existing := recordsByID[record.ID]; existing != nil { + return nil, fmt.Errorf("duplicate agent %q in %s and %s", record.ID, existing.WorkspaceRoot, workspaceRoot) + } + recordsByID[record.ID] = record + } + return recordsByID, nil +} + +func loadWorkspaceAgentRecord(repoRoot, workspaceRoot string, reg *registry) (*agentRecord, error) { + workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) + if workspaceRoot == "" { + return nil, nil + } + featurePath := filepath.Join(workspaceRoot, "agent.json") + repoCopyPath := filepath.Join(workspaceRoot, "repo") + featureCfg, err := loadFeatureConfig(featurePath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("load feature config for %s: %w", workspaceRoot, err) + } + agentID := sanitizeFeatureName(filepath.Base(workspaceRoot)) + if featureCfg != nil { + if sanitized := sanitizeFeatureName(featureCfg.Feature); sanitized != "" { + agentID = sanitized + } + } + if agentID == "" { + return nil, nil + } + var record agentRecord + if existing := registryRecordForWorkspace(reg, repoRoot, workspaceRoot, featurePath, agentID); existing != nil { + record = *existing + } + record.ID = agentID + record.Name = agentID + record.RepoRoot = repoRoot + record.WorkspaceRoot = workspaceRoot + record.RepoCopyPath = repoCopyPath + record.FeatureConfig = featurePath + if strings.TrimSpace(record.Branch) == "" { + record.Branch = agentID + } + if featureCfg != nil { + if featureCfg.IsFlutter || (strings.TrimSpace(record.Runtime) == "" && fileExists(filepath.Join(repoRoot, "pubspec.yaml"))) { + record.Runtime = "flutter" + } + record.Device = strings.TrimSpace(featureCfg.Device) + record.Port = featureCfg.Port + record.URL = strings.TrimSpace(featureCfg.URL) + record.BrowserEnabled = record.Device == "web-server" + } else if strings.TrimSpace(record.Runtime) == "" && fileExists(filepath.Join(repoRoot, "pubspec.yaml")) { + record.Runtime = "flutter" + } + return &record, nil +} + +func registryRecordForWorkspace(reg *registry, repoRoot, workspaceRoot, featurePath, agentID string) *agentRecord { + if reg == nil { + return nil + } + repoRoot = filepath.Clean(strings.TrimSpace(repoRoot)) + workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) + featurePath = filepath.Clean(strings.TrimSpace(featurePath)) + for _, record := range reg.Agents { + if record == nil { + continue + } + if filepath.Clean(strings.TrimSpace(record.WorkspaceRoot)) == workspaceRoot { + return record + } + if filepath.Clean(strings.TrimSpace(record.FeatureConfig)) == featurePath { + return record + } + } + record := reg.Agents[agentID] + if record == nil { + return nil + } + if filepath.Clean(strings.TrimSpace(record.RepoRoot)) != repoRoot { + return nil + } + return record +} + +func runList(args ...string) error { + fs := flag.NewFlagSet("agent list", flag.ContinueOnError) + var showAll bool + fs.BoolVar(&showAll, "all", false, "show agents across all repos") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + + reg, err := loadRegistry() + if err != nil { + return err + } + + repoScope := "" + if !showAll { + repoScope, err = repoRoot() + if err != nil { + return fmt.Errorf("agent list defaults to the current repo; run inside a git repo or use --all") + } + } + + for _, id := range sortedAgentIDs(reg) { + record := reg.Agents[id] + if repoScope != "" && filepath.Clean(record.RepoRoot) != filepath.Clean(repoScope) { + continue + } + state := "stopped" + if windowAlive(record.TmuxSessionID, record.TmuxWindowID) { + state = "running" + } + fmt.Printf("%s\t%s\t%s\n", id, state, record.RepoCopyPath) + } + return nil +} + +func runDestroy(args []string) error { + fs := flag.NewFlagSet("agent destroy", flag.ContinueOnError) + var agentID string + var confirmText string + fs.StringVar(&agentID, "id", "", "agent id") + fs.StringVar(&confirmText, "confirm", "", "required confirmation text for destructive destroy") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + if agentID == "" && fs.NArg() > 0 { + agentID = fs.Arg(0) + } + if agentID == "" { + ctx, err := detectCurrentAgentFromTmux("") + if err != nil { + return err + } + agentID = ctx.ID + } + target, err := loadDestroyTarget(agentID) + if err != nil { + return err + } + if target.RequiresExplicitConfirm && strings.TrimSpace(confirmText) != "destroy" { + return fmt.Errorf("agent has uncommitted changes; rerun with --confirm destroy") + } + reg := target.Reg + record := target.Record + windowID := target.WindowID + destroyingCurrentWindow := target.DestroyingCurrentWindow + if record.URL != "" { + _ = closeChromeTab(record.URL) + } + delete(reg.Agents, agentID) + if reg.FocusedAgentID == agentID { + reg.FocusedAgentID = "" + } + if err := saveRegistry(reg); err != nil { + return err + } + _ = stopWorkspaceBootstrap(record.WorkspaceRoot) + if err := os.RemoveAll(record.WorkspaceRoot); err != nil { + return err + } + if windowAlive(record.TmuxSessionID, windowID) { + if destroyingCurrentWindow { + return runTmux("run-shell", "-b", fmt.Sprintf("sleep 0.2; tmux kill-window -t %s", shellQuote(windowID))) + } + _ = runTmux("kill-window", "-t", windowID) + } + return nil +} + +type destroyTarget struct { + Reg *registry + Record *agentRecord + WindowID string + DestroyingCurrentWindow bool + RequiresExplicitConfirm bool +} + +func loadDestroyTarget(agentID string) (destroyTarget, error) { + agentID = strings.TrimSpace(agentID) + reg, err := loadRegistry() + if err != nil { + return destroyTarget{}, err + } + record := reg.Agents[agentID] + if record == nil { + return destroyTarget{}, fmt.Errorf("unknown agent: %s", agentID) + } + windowID := activeAgentWindowID(record) + requiresExplicitConfirm, err := destroyRequiresExplicitConfirm(record) + if err != nil { + return destroyTarget{}, err + } + if strings.TrimSpace(windowID) != "" { + openWindowTodos, err := countOpenTmuxTodos(todoScopeWindow, windowID) + if err != nil { + return destroyTarget{}, err + } + if openWindowTodos > 0 { + label := "todos" + if openWindowTodos == 1 { + label = "todo" + } + return destroyTarget{}, fmt.Errorf("refusing to destroy agent with %d open window %s", openWindowTodos, label) + } + } + currentWindowID := currentTmuxWindowID() + return destroyTarget{ + Reg: reg, + Record: record, + WindowID: windowID, + DestroyingCurrentWindow: currentWindowID != "" && strings.TrimSpace(windowID) == currentWindowID, + RequiresExplicitConfirm: requiresExplicitConfirm, + }, nil +} + +func destroyRequiresExplicitConfirm(record *agentRecord) (bool, error) { + if record == nil { + return false, nil + } + repoPath := strings.TrimSpace(record.RepoCopyPath) + if repoPath == "" || !fileExists(repoPath) { + return false, nil + } + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = repoPath + out, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(out)) + if message == "" { + return false, err + } + return false, fmt.Errorf("check git status: %s", message) + } + return strings.TrimSpace(string(out)) != "", nil +} + +func runTmuxCommand(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: agent tmux ") + } + switch args[0] { + case "on-focus": + return runTmuxOnFocus(args[1:]) + case "focus": + return runTmuxFocus(args[1:]) + case "palette": + return runTmuxPalette(args[1:]) + default: + return fmt.Errorf("unknown tmux subcommand: %s", args[0]) + } +} + +func runBrowserCommand(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: agent browser ") + } + fs := flag.NewFlagSet("agent browser", flag.ContinueOnError) + var workspace string + var allowOpen bool + var preserveFocus bool + fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json") + fs.BoolVar(&allowOpen, "allow-open", false, "open a new tab if missing") + fs.BoolVar(&preserveFocus, "preserve-focus", false, "restore the previously frontmost app after browser changes") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args[1:]); err != nil { + return err + } + if strings.TrimSpace(workspace) == "" { + return fmt.Errorf("workspace is required") + } + featurePath := filepath.Join(workspace, "agent.json") + switch args[0] { + case "open": + return syncChromeForFeature(featurePath, allowOpen, preserveFocus) + case "refresh": + return refreshChromeForFeature(featurePath, preserveFocus) + default: + return fmt.Errorf("unknown browser subcommand: %s", args[0]) + } +} + +func runFeatureCommand(args []string) error { + fs := flag.NewFlagSet("agent feature", flag.ContinueOnError) + var workspace string + var device string + var readyText string + var shouldOpenTabText string + var writeScripts bool + fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json") + fs.StringVar(&device, "device", "", "set flutter device") + fs.StringVar(&readyText, "ready", "", "set ready state (true/false)") + fs.StringVar(&shouldOpenTabText, "should-open-tab", "", "set browser-open state (true/false)") + fs.BoolVar(&writeScripts, "write-helper-scripts", false, "rewrite generated helper scripts for the workspace") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return fmt.Errorf("--workspace is required") + } + featurePath := filepath.Join(workspace, "agent.json") + if writeScripts { + cfg, err := loadFeatureConfig(featurePath) + if err != nil { + return err + } + return writeFlutterHelperScripts(workspace, filepath.Join(workspace, "repo"), cfg.URL, cfg.Device) + } + if err := updateFeatureConfig(featurePath, func(cfg *featureConfig) error { + if strings.TrimSpace(device) != "" { + cfg.Device = strings.TrimSpace(device) + } + if strings.TrimSpace(readyText) != "" { + value, err := strconv.ParseBool(strings.TrimSpace(readyText)) + if err != nil { + return fmt.Errorf("invalid --ready value: %w", err) + } + cfg.Ready = value + } + if strings.TrimSpace(shouldOpenTabText) != "" { + value, err := strconv.ParseBool(strings.TrimSpace(shouldOpenTabText)) + if err != nil { + return fmt.Errorf("invalid --should-open-tab value: %w", err) + } + cfg.ShouldOpenTab = value + } + return nil + }); err != nil { + return err + } + if strings.TrimSpace(device) != "" { + if err := syncFeatureDeviceToRegistry(workspace, featurePath, strings.TrimSpace(device)); err != nil { + return err + } + } + return nil +} + +func syncFeatureDeviceToRegistry(workspaceRoot, featurePath, device string) error { + reg, err := loadRegistry() + if err != nil { + return err + } + workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) + featurePath = filepath.Clean(strings.TrimSpace(featurePath)) + device = strings.TrimSpace(device) + browserEnabled := device == "web-server" + updated := false + for _, record := range reg.Agents { + if record == nil { + continue + } + if filepath.Clean(strings.TrimSpace(record.WorkspaceRoot)) != workspaceRoot && filepath.Clean(strings.TrimSpace(record.FeatureConfig)) != featurePath { + continue + } + record.Device = device + record.BrowserEnabled = browserEnabled + record.UpdatedAt = time.Now() + updated = true + break + } + if !updated { + return nil + } + return saveRegistry(reg) +} + +func runTmuxOnFocus(args []string) error { + fs := flag.NewFlagSet("agent tmux on-focus", flag.ContinueOnError) + var sessionID, windowID, paneID string + fs.StringVar(&sessionID, "session", "", "session id") + fs.StringVar(&windowID, "window", "", "window id") + fs.StringVar(&paneID, "pane", "", "pane id") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + _ = sessionID + ctx, err := detectCurrentAgentFromTmux(windowID) + if err != nil { + return nil + } + _ = paneID + reg, err := loadRegistry() + if err != nil { + return err + } + record := reg.Agents[ctx.ID] + if record == nil { + return nil + } + if reg.FocusedAgentID == record.ID { + return nil + } + now := time.Now() + record.LastFocusedAt = &now + record.UpdatedAt = now + reg.FocusedAgentID = record.ID + if err := saveRegistry(reg); err != nil { + return err + } + if record.BrowserEnabled { + _ = syncChromeForFeature(record.FeatureConfig, true, true) + } + return nil +} + +func runTmuxFocus(args []string) error { + fs := flag.NewFlagSet("agent tmux focus", flag.ContinueOnError) + var windowID string + fs.StringVar(&windowID, "window", "", "window id") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() == 0 { + return fmt.Errorf("usage: agent tmux focus ") + } + role := strings.ToLower(fs.Arg(0)) + ctx, err := detectCurrentAgentFromTmux(windowID) + if err != nil { + return err + } + reg, err := loadRegistry() + if err != nil { + return err + } + record := reg.Agents[ctx.ID] + if record == nil { + return fmt.Errorf("unknown agent: %s", ctx.ID) + } + target := "" + switch role { + case "ai": + target = record.Panes.AI + case "git": + target = record.Panes.Git + case "dashboard": + return openDashboardPopup(record.ID) + case "run": + target = record.Panes.Run + default: + return fmt.Errorf("unknown pane role: %s", role) + } + if target == "" { + return fmt.Errorf("pane not found for role: %s", role) + } + return runTmux("select-pane", "-t", target) +} + +func openDashboardPopup(agentID string) error { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return fmt.Errorf("agent id is required") + } + exe, err := os.Executable() + if err != nil { + return err + } + cmd := fmt.Sprintf("%s dashboard --agent-id %s", shellQuote(exe), shellQuote(agentID)) + return runTmux("display-popup", "-E", "-w", "78%", "-h", "80%", "-T", "dashboard", cmd) +} + +func runTmuxPalette(args []string) error { + fs := flag.NewFlagSet("agent tmux palette", flag.ContinueOnError) + var windowID string + fs.StringVar(&windowID, "window", "", "window id") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + ctx, err := tmuxPaletteContext(windowID) + if err != nil { + return err + } + windowID = ctx.WindowID + if windowID == "" { + return fmt.Errorf("window id is required") + } + exe, err := os.Executable() + if err != nil { + return err + } + cmd := fmt.Sprintf( + "%s palette --window=%s --agent-id=%s --path=%s --session-name=%s --window-name=%s", + shellQuote(exe), + shellQuote(ctx.WindowID), + shellQuote(ctx.AgentID), + shellQuote(ctx.CurrentPath), + shellQuote(ctx.SessionName), + shellQuote(ctx.WindowName), + ) + return runTmux("display-popup", "-E", "-w", "78%", "-h", "80%", "-T", "agent", cmd) +} + +type currentAgentRef struct{ ID string } + +type tmuxPaletteLaunchContext struct { + WindowID string + AgentID string + CurrentPath string + SessionName string + WindowName string +} + +func tmuxPaletteContext(windowID string) (tmuxPaletteLaunchContext, error) { + args := []string{"display-message", "-p"} + if strings.TrimSpace(windowID) != "" { + args = append(args, "-t", strings.TrimSpace(windowID)) + } + args = append(args, "#{window_id}\n#{@agent_id}\n#{pane_current_path}\n#{session_name}\n#{window_name}") + out, err := runTmuxOutput(args...) + if err != nil { + return tmuxPaletteLaunchContext{}, err + } + parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 5) + for len(parts) < 5 { + parts = append(parts, "") + } + return tmuxPaletteLaunchContext{ + WindowID: strings.TrimSpace(parts[0]), + AgentID: strings.TrimSpace(parts[1]), + CurrentPath: strings.TrimSpace(parts[2]), + SessionName: strings.TrimSpace(parts[3]), + WindowName: strings.TrimSpace(parts[4]), + }, nil +} + +func detectCurrentAgentFromTmux(windowID string) (currentAgentRef, error) { + if windowID == "" { + out, err := runTmuxOutput("display-message", "-p", "#{window_id}") + if err != nil { + return currentAgentRef{}, err + } + windowID = strings.TrimSpace(out) + } + out, err := runTmuxOutput("show-options", "-wqv", "-t", windowID, "@agent_id") + if err != nil { + return currentAgentRef{}, err + } + id := strings.TrimSpace(out) + if id == "" { + return currentAgentRef{}, fmt.Errorf("no agent id for window %s", windowID) + } + return currentAgentRef{ID: id}, nil +} + +func gatedWorkspaceCommand(workspaceRoot, readyMarker, successCmd string) string { + failurePath := bootstrapFailedPath(workspaceRoot) + return fmt.Sprintf( + "cd %s; while [ ! -f %s ] && [ ! -f %s ]; do sleep 0.2; done; if [ -f %s ]; then cat %s 2>/dev/null; printf '\\nSee %%s\\n' %s; exec ${SHELL:-/bin/zsh}; fi; %s", + shellQuote(workspaceRoot), + shellQuote(readyMarker), + shellQuote(failurePath), + shellQuote(failurePath), + shellQuote(failurePath), + shellQuote(bootstrapLogPath(workspaceRoot)), + successCmd, + ) +} + +func currentTmuxWindowID() string { + if out, err := runTmuxOutput("display-message", "-p", "#{window_id}"); err == nil { + return strings.TrimSpace(out) + } + return "" +} + +func activeAgentWindowID(record *agentRecord) string { + if record == nil { + return "" + } + windowID := strings.TrimSpace(record.TmuxWindowID) + if os.Getenv("TMUX") == "" { + return windowID + } + ctx, err := detectCurrentAgentFromTmux("") + if err != nil || strings.TrimSpace(ctx.ID) != strings.TrimSpace(record.ID) { + return windowID + } + currentWindowID := currentTmuxWindowID() + if currentWindowID != "" { + return currentWindowID + } + return windowID +} + +func launchAgentLayout(record *agentRecord) error { + windowID, sessionID, sessionName, attachAfter, err := createWindow(record.Name, record.RepoCopyPath, record.LaunchWindowID) + if err != nil { + return err + } + + if err := runTmux("select-window", "-t", windowID); err != nil { + return err + } + topPane, err := currentPane(windowID) + if err != nil { + return err + } + if err := runTmux("split-window", "-t", topPane, "-h", "-p", "40", "-c", record.WorkspaceRoot); err != nil { + return err + } + rightPane, err := currentPane(windowID) + if err != nil { + return err + } + if err := runTmux("split-window", "-t", rightPane, "-v", "-p", "35", "-c", record.WorkspaceRoot); err != nil { + return err + } + runPane, err := currentPane(windowID) + if err != nil { + return err + } + gitPane := rightPane + aiPane := topPane + + if err := runTmux("set-option", "-w", "-t", windowID, "@agent_id", record.ID); err != nil { + return err + } + _ = runTmux("rename-window", "-t", windowID, record.ID) + for pane, role := range map[string]string{aiPane: "ai", gitPane: "git", runPane: "run"} { + _ = runTmux("set-option", "-p", "-t", pane, "@agent_role", role) + } + + record.TmuxSessionID = sessionID + record.TmuxSessionName = sessionName + record.TmuxWindowID = windowID + record.Panes = agentPanes{AI: aiPane, Git: gitPane, Run: runPane} + record.UpdatedAt = time.Now() + reg, err := loadRegistry() + if err == nil { + reg.Agents[record.ID] = record + _ = saveRegistry(reg) + } + + aiCmd := fmt.Sprintf("cd %s; exec ${SHELL:-/bin/zsh}", shellQuote(record.RepoCopyPath)) + gitCmd := gatedWorkspaceCommand( + record.WorkspaceRoot, + bootstrapGitReadyPath(record.WorkspaceRoot), + fmt.Sprintf("cd %s; if command -v lazygit >/dev/null 2>&1; then lazygit; fi; exec ${SHELL:-/bin/zsh}", shellQuote(record.RepoCopyPath)), + ) + runCmd := agentRunPaneCommand(record) + for pane, cmd := range map[string]string{aiPane: aiCmd, gitPane: gitCmd, runPane: runCmd} { + if err := runTmux("respawn-pane", "-k", "-t", pane, cmd); err != nil { + return err + } + } + if err := runTmux("select-pane", "-t", aiPane); err != nil { + return err + } + if attachAfter && canAttachTmux() { + return runTmux("attach-session", "-t", sessionID) + } + return nil +} + +func primeAgentAIPane(paneID string) error { + paneID = strings.TrimSpace(paneID) + if paneID == "" { + return nil + } + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + out, err := runTmuxOutput("display-message", "-p", "-t", paneID, "#{pane_current_command}") + if err == nil { + switch strings.TrimSpace(out) { + case "zsh", "bash", "sh", "fish": + deadline = time.Now() + } + } + if !time.Now().Before(deadline) { + break + } + time.Sleep(100 * time.Millisecond) + } + if err := runTmux("send-keys", "-t", paneID, "-l", "op"); err != nil { + return err + } + return runTmux("send-keys", "-t", paneID, "Enter") +} + +func agentRunPaneCommand(record *agentRecord) string { + if record == nil { + return "" + } + shellCmd := gatedWorkspaceCommand( + record.WorkspaceRoot, + bootstrapRepoReadyPath(record.WorkspaceRoot), + fmt.Sprintf("cd %s; exec ${SHELL:-/bin/zsh}", shellQuote(record.WorkspaceRoot)), + ) + if record.Runtime != "flutter" || strings.TrimSpace(record.Device) == "" { + return shellCmd + } + return gatedWorkspaceCommand( + record.WorkspaceRoot, + bootstrapRepoReadyPath(record.WorkspaceRoot), + fmt.Sprintf("cd %s; ./ensure-server.sh %s; exec ${SHELL:-/bin/zsh}", shellQuote(record.WorkspaceRoot), shellQuote(record.Device)), + ) +} + +func canAttachTmux() bool { + if os.Getenv("TMUX") != "" { + return false + } + info, err := os.Stdin.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +func createWindow(feature, path string, targetWindowID string) (windowID, sessionID, sessionName string, attachAfter bool, err error) { + targetWindowID = preferredNewWindowTarget(targetWindowID, os.Getenv("TMUX") != "", currentTmuxWindowID()) + if targetWindowID != "" { + targetSessionID, targetSessionName, resolveErr := tmuxSessionForWindow(targetWindowID) + if resolveErr == nil && targetSessionID != "" { + windowID, err = runTmuxOutput(positionedNewWindowArgs(feature, path, targetWindowID)...) + if err != nil { + return "", "", "", false, err + } + return strings.TrimSpace(windowID), strings.TrimSpace(targetSessionID), strings.TrimSpace(targetSessionName), false, nil + } + } + if os.Getenv("TMUX") != "" { + windowID, err = runTmuxOutput(positionedNewWindowArgs(feature, path, "")...) + if err != nil { + return "", "", "", false, err + } + windowID = strings.TrimSpace(windowID) + sessionID, _ = runTmuxOutput("display-message", "-p", "-t", windowID, "#{session_id}") + sessionName, _ = runTmuxOutput("display-message", "-p", "-t", windowID, "#{session_name}") + return windowID, strings.TrimSpace(sessionID), strings.TrimSpace(sessionName), false, nil + } + repoName := filepath.Base(path) + desiredSessionLabel := sanitizeFeatureName(repoName + "-agents") + if desiredSessionLabel == "" { + desiredSessionLabel = "agents" + } + if existingSessionID, existingSessionName, ok := findTmuxSessionByLabel(desiredSessionLabel); ok { + windowID, err = runTmuxOutput("new-window", "-P", "-F", "#{window_id}", "-t", existingSessionID, "-n", feature, "-c", path) + if err != nil { + return "", "", "", false, err + } + return strings.TrimSpace(windowID), strings.TrimSpace(existingSessionID), strings.TrimSpace(existingSessionName), true, nil + } + if err := runTmux("new-session", "-d", "-s", desiredSessionLabel, "-n", feature, "-c", path); err != nil { + return "", "", "", false, err + } + attachAfter = true + sessionID, _ = runTmuxOutput("display-message", "-p", "-t", desiredSessionLabel+":1", "#{session_id}") + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + if existingSessionID, existingSessionName, ok := findTmuxSessionByLabel(desiredSessionLabel); ok { + sessionID = strings.TrimSpace(existingSessionID) + sessionName = strings.TrimSpace(existingSessionName) + } + } + if sessionID == "" { + return "", "", "", false, fmt.Errorf("unable to resolve tmux session for %s", desiredSessionLabel) + } + windowID, _ = runTmuxOutput("list-windows", "-t", sessionID, "-F", "#{window_id}") + if sessionName == "" { + sessionName, _ = runTmuxOutput("display-message", "-p", "-t", sessionID, "#{session_name}") + } + return strings.TrimSpace(strings.Split(windowID, "\n")[0]), strings.TrimSpace(sessionID), strings.TrimSpace(sessionName), true, nil +} + +func preferredNewWindowTarget(targetWindowID string, inTmux bool, currentWindowID string) string { + targetWindowID = strings.TrimSpace(targetWindowID) + if targetWindowID != "" { + return targetWindowID + } + if !inTmux { + return "" + } + return strings.TrimSpace(currentWindowID) +} + +func positionedNewWindowArgs(feature, path, targetWindowID string) []string { + args := []string{"new-window", "-P", "-F", "#{window_id}"} + if targetWindowID = strings.TrimSpace(targetWindowID); targetWindowID != "" { + args = append(args, "-a", "-t", targetWindowID) + } + args = append(args, "-n", feature, "-c", path) + return args +} + +func tmuxSessionForWindow(windowID string) (sessionID, sessionName string, err error) { + windowID = strings.TrimSpace(windowID) + if windowID == "" { + return "", "", fmt.Errorf("window id is required") + } + out, err := runTmuxOutput("display-message", "-p", "-t", windowID, "#{session_id}\n#{session_name}") + if err != nil { + return "", "", err + } + parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 2) + for len(parts) < 2 { + parts = append(parts, "") + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil +} + +func findTmuxSessionByLabel(label string) (sessionID, sessionName string, ok bool) { + out, err := runTmuxOutput("list-sessions", "-F", "#{session_id}\t#{session_name}") + if err != nil { + return "", "", false + } + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + continue + } + id := strings.TrimSpace(parts[0]) + name := strings.TrimSpace(parts[1]) + if name == label { + return id, name, true + } + if match := regexp.MustCompile(`^\d+-(.+)$`).FindStringSubmatch(name); len(match) == 2 && strings.TrimSpace(match[1]) == label { + return id, name, true + } + } + return "", "", false +} + +func currentPane(windowID string) (string, error) { + out, err := runTmuxOutput("display-message", "-p", "-t", windowID, "#{pane_id}") + return strings.TrimSpace(out), err +} + +func windowAlive(sessionID, windowID string) bool { + if strings.TrimSpace(windowID) == "" { + return false + } + out, err := runTmuxOutput("list-windows", "-a", "-F", "#{session_id}\t#{window_id}") + if err != nil { + return false + } + targetWindow := strings.TrimSpace(windowID) + targetSession := strings.TrimSpace(sessionID) + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) != 2 { + continue + } + if strings.TrimSpace(parts[1]) != targetWindow { + continue + } + if targetSession == "" || strings.TrimSpace(parts[0]) == targetSession { + return true + } + } + return false +} + +func selectTmuxWindow(windowID string) error { + if err := runTmux("select-window", "-t", windowID); err != nil { + return err + } + return nil +} + +func repoRoot() (string, error) { + out, err := runGitOutput("rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("not in a git repo") + } + return strings.TrimSpace(out), nil +} + +func ensureFlutterWebRepo(repoRoot string) error { + if !fileExists(filepath.Join(repoRoot, "pubspec.yaml")) || !dirExists(filepath.Join(repoRoot, "web")) { + return fmt.Errorf("agent init only works for Flutter web repos right now; expected both pubspec.yaml and a web/ directory") + } + return nil +} + +func requiredAgentExcludeEntries(repoRoot string, isFlutter bool) []string { + entries := []string{".agent.yaml"} + entries = append(entries, ".agents") + if isFlutter { + entries = append(entries, "web_dev_config.yaml") + entries = append(entries, "hot-reload.sh") + } + return entries +} + +func ensureRepoCopyLocalExcludes(repoCopyPath string, isFlutter bool) error { + return ensureGitExcludeEntries(repoCopyPath, requiredAgentExcludeEntries(repoCopyPath, isFlutter)) +} + +func gitInfoExcludePath(repoRoot string) string { + return filepath.Join(repoRoot, ".git", "info", "exclude") +} + +func ensureGitExcludeEntries(repoRoot string, entries []string) error { + path := gitInfoExcludePath(repoRoot) + existing := map[string]bool{} + data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err == nil { + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + existing[trimmed] = true + } + } + } + missing := make([]string, 0, len(entries)) + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" || existing[entry] { + continue + } + missing = append(missing, entry) + } + if len(missing) == 0 { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + var builder strings.Builder + if len(data) > 0 { + builder.Write(data) + if !strings.HasSuffix(string(data), "\n") { + builder.WriteByte('\n') + } + } + for _, entry := range missing { + builder.WriteString(entry) + builder.WriteByte('\n') + } + return os.WriteFile(path, []byte(builder.String()), 0o644) +} + +func gitPathIgnored(repoRoot, relPath string) (bool, error) { + relPath = strings.TrimSpace(relPath) + if relPath == "" { + return false, fmt.Errorf("path is required") + } + cmd := exec.Command("git", "check-ignore", "-q", "--", relPath) + cmd.Dir = repoRoot + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err + } + return true, nil +} + +func runGitOutput(args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +func defaultAgentKeyPaths() []string { + return []string{"AGENTS.md", ".agent-prompts", "opencode.json"} +} + +func bootstrapStateDirPath(workspaceRoot string) string { + return filepath.Join(workspaceRoot, ".bootstrap") +} + +func bootstrapGitReadyPath(workspaceRoot string) string { + return filepath.Join(bootstrapStateDirPath(workspaceRoot), "git-ready") +} + +func bootstrapRepoReadyPath(workspaceRoot string) string { + return filepath.Join(bootstrapStateDirPath(workspaceRoot), "repo-ready") +} + +func bootstrapFailedPath(workspaceRoot string) string { + return filepath.Join(bootstrapStateDirPath(workspaceRoot), "failed") +} + +func bootstrapPIDPath(workspaceRoot string) string { + return filepath.Join(bootstrapStateDirPath(workspaceRoot), "bootstrap.pid") +} + +func bootstrapLogPath(workspaceRoot string) string { + return filepath.Join(workspaceRoot, "logs", "bootstrap.log") +} + +func pathExists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func repoRootFromWorkspaceRoot(workspaceRoot string) string { + clean := filepath.Clean(strings.TrimSpace(workspaceRoot)) + needle := string(filepath.Separator) + ".agents" + string(filepath.Separator) + if idx := strings.Index(clean, needle); idx >= 0 { + return clean[:idx] + } + return "" +} + +func loadAgentRecordByWorkspaceRoot(workspaceRoot string) *agentRecord { + reg, err := loadRegistry() + if err != nil { + return nil + } + workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) + for _, record := range reg.Agents { + if record == nil { + continue + } + if filepath.Clean(strings.TrimSpace(record.WorkspaceRoot)) == workspaceRoot { + return record + } + } + return nil +} + +func resolveStartSourceBranch(repoRoot string, repoCfg *repoConfig) string { + if branch := currentLocalBranch(repoRoot); branch != "" { + return branch + } + if repoCfg != nil && strings.TrimSpace(repoCfg.BaseBranch) != "" { + return strings.TrimSpace(repoCfg.BaseBranch) + } + return detectDefaultBaseBranch(repoRoot) +} + +func resolveBootstrapStartOptions(repoRoot string, repoCfg *repoConfig, record *agentRecord) agentStartOptions { + options := agentStartOptions{} + if record != nil { + options.SourceBranch = strings.TrimSpace(record.SourceBranch) + options.KeepWorktree = record.KeepWorktree + } + if options.SourceBranch == "" { + if repoCfg != nil && strings.TrimSpace(repoCfg.BaseBranch) != "" { + options.SourceBranch = strings.TrimSpace(repoCfg.BaseBranch) + } else { + options.SourceBranch = detectDefaultBaseBranch(repoRoot) + } + } + return options +} + +func prepareAgentContext(repoRoot, repoCopyPath string, keyPaths []string, ignoreExisting bool) error { + if err := os.MkdirAll(repoCopyPath, 0o755); err != nil { + return err + } + return copySelectedRepoPaths(repoRoot, repoCopyPath, keyPaths, ignoreExisting) +} + +func copySelectedRepoPaths(srcRoot, destRoot string, paths []string, ignoreExisting bool) error { + for _, relPath := range normalizeIgnoreValues(paths) { + relPath = filepath.Clean(filepath.FromSlash(relPath)) + if relPath == "." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + continue + } + if !pathExists(filepath.Join(srcRoot, relPath)) { + continue + } + args := []string{"-a", "--relative"} + if ignoreExisting { + args = append(args, "--ignore-existing") + } + args = append(args, filepath.ToSlash("."+string(filepath.Separator)+relPath), destRoot+"/") + cmd := exec.Command("rsync", args...) + cmd.Dir = srcRoot + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return err + } + return fmt.Errorf("copy path %s: %w: %s", relPath, err, message) + } + } + return nil +} + +func pathDepth(path string) int { + clean := filepath.Clean(path) + if clean == "." || clean == string(filepath.Separator) { + return 0 + } + return len(strings.Split(clean, string(filepath.Separator))) +} + +func copyRepoExcludeValues(extraIgnores []string) []string { + return append(defaultCopyIgnoreExcludes(), extraIgnores...) +} + +func copyGitMetadata(srcRoot, repoCopyPath string) error { + gitPath := filepath.Join(repoCopyPath, ".git") + if err := os.RemoveAll(gitPath); err != nil { + return err + } + if err := os.MkdirAll(repoCopyPath, 0o755); err != nil { + return err + } + cmd := exec.Command("rsync", "-a", filepath.Join(srcRoot, ".git")+"/", gitPath+"/") + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return err + } + return fmt.Errorf("copy git metadata: %w: %s", err, message) + } + return nil +} + +func syncRepoWorktree(srcRoot, repoCopyPath string, extraIgnores []string) error { + if err := os.MkdirAll(repoCopyPath, 0o755); err != nil { + return err + } + args := []string{"-a", "--delete", "--filter", ":- .gitignore", "--exclude", ".git", "--exclude", ".git/**"} + for _, value := range copyRepoExcludeValues(extraIgnores) { + value = strings.TrimSpace(value) + if value == "" { + continue + } + args = append(args, "--exclude", value) + } + args = append(args, srcRoot+"/", repoCopyPath+"/") + cmd := exec.Command("rsync", args...) + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return err + } + return fmt.Errorf("sync repo worktree: %w: %s", err, message) + } + return nil +} + +func applyRepoCopyIgnores(sourceRepoRoot, repoCopyPath string, extraIgnores []string) error { + relPaths, err := repoCopyIgnoredPaths(sourceRepoRoot, repoCopyPath, extraIgnores) + if err != nil { + return err + } + if len(relPaths) == 0 { + return nil + } + trackedPaths, err := trackedPathsForRepoCopy(repoCopyPath, relPaths) + if err != nil { + return err + } + if err := markGitPathsSkipWorktree(repoCopyPath, trackedPaths); err != nil { + return err + } + for _, relPath := range relPaths { + if err := os.RemoveAll(filepath.Join(repoCopyPath, relPath)); err != nil { + return err + } + } + return nil +} + +func repoCopyIgnoredPaths(sourceRepoRoot, repoCopyPath string, extraIgnores []string) ([]string, error) { + args := []string{"-a", "-n", "--delete", "--delete-excluded", "--itemize-changes", "--exclude", ".git"} + for _, value := range copyRepoExcludeValues(extraIgnores) { + value = strings.TrimSpace(value) + if value == "" { + continue + } + args = append(args, "--exclude", value) + } + args = append(args, sourceRepoRoot+"/", repoCopyPath+"/") + cmd := exec.Command("rsync", args...) + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return nil, err + } + return nil, fmt.Errorf("compute repo copy ignores: %w: %s", err, message) + } + seen := map[string]bool{} + relPaths := make([]string, 0) + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "*deleting ") { + continue + } + relPath := strings.TrimSpace(strings.TrimPrefix(line, "*deleting ")) + relPath = strings.TrimSuffix(relPath, "/") + if relPath == "" { + continue + } + relPath = filepath.Clean(filepath.FromSlash(relPath)) + if relPath == "." || relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + continue + } + if seen[relPath] { + continue + } + seen[relPath] = true + relPaths = append(relPaths, relPath) + } + sort.Slice(relPaths, func(i, j int) bool { + return pathDepth(relPaths[i]) > pathDepth(relPaths[j]) + }) + return relPaths, nil +} + +func trackedPathsForRepoCopy(repoCopyPath string, relPaths []string) ([]string, error) { + seen := map[string]bool{} + trackedPaths := make([]string, 0) + for _, relPath := range relPaths { + cmd := exec.Command("git", "ls-files", "-z", "--", relPath) + cmd.Dir = repoCopyPath + output, err := cmd.Output() + if err != nil { + return nil, err + } + for _, trackedPath := range strings.Split(string(output), "\x00") { + trackedPath = strings.TrimSpace(trackedPath) + if trackedPath == "" || seen[trackedPath] { + continue + } + seen[trackedPath] = true + trackedPaths = append(trackedPaths, trackedPath) + } + } + sort.Strings(trackedPaths) + return trackedPaths, nil +} + +func markGitPathsSkipWorktree(repoCopyPath string, trackedPaths []string) error { + if len(trackedPaths) == 0 { + return nil + } + input := strings.Join(trackedPaths, "\x00") + "\x00" + cmd := exec.Command("git", "update-index", "--skip-worktree", "-z", "--stdin") + cmd.Dir = repoCopyPath + cmd.Stdin = strings.NewReader(input) + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return err + } + return fmt.Errorf("mark skip-worktree: %w: %s", err, message) + } + return nil +} + +func removeLegacyRuntimeProject(workspaceRoot string) error { + return os.RemoveAll(filepath.Join(workspaceRoot, "runtime")) +} + +func createFeatureBranch(repoCopyPath, branch, sourceBranch string, preservePaths []string) (string, error) { + sourceBranch = strings.TrimSpace(sourceBranch) + if sourceBranch == "" { + sourceBranch = detectDefaultBaseBranch(repoCopyPath) + } + resetHeadCmd := exec.Command("git", "reset", "--hard", "HEAD") + resetHeadCmd.Dir = repoCopyPath + resetHeadCmd.Stdout = io.Discard + resetHeadCmd.Stderr = io.Discard + if err := resetHeadCmd.Run(); err != nil { + return "", err + } + cleanArgs := []string{"clean", "-fdx"} + for _, preservePath := range normalizeIgnoreValues(preservePaths) { + preservePath = filepath.Clean(filepath.FromSlash(preservePath)) + if preservePath == "." || strings.HasPrefix(preservePath, ".."+string(filepath.Separator)) { + continue + } + cleanArgs = append(cleanArgs, "-e", preservePath) + if dirExists(filepath.Join(repoCopyPath, preservePath)) { + cleanArgs = append(cleanArgs, "-e", filepath.ToSlash(preservePath)+"/**") + } + } + cleanCmd := exec.Command("git", cleanArgs...) + cleanCmd.Dir = repoCopyPath + cleanCmd.Stdout = io.Discard + cleanCmd.Stderr = io.Discard + if err := cleanCmd.Run(); err != nil { + return "", err + } + fetchCmd := exec.Command("git", "remote", "update", "-p") + fetchCmd.Dir = repoCopyPath + _ = fetchCmd.Run() + if remoteExists(repoCopyPath, "origin/"+sourceBranch) { + cmd := exec.Command("git", "checkout", "-B", sourceBranch, "origin/"+sourceBranch) + cmd.Dir = repoCopyPath + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Run(); err == nil { + resetCmd := exec.Command("git", "reset", "--hard", "origin/"+sourceBranch) + resetCmd.Dir = repoCopyPath + resetCmd.Stdout = io.Discard + resetCmd.Stderr = io.Discard + if err := resetCmd.Run(); err != nil { + return "", err + } + } else { + return "", err + } + } else if localExists(repoCopyPath, sourceBranch) { + cmd := exec.Command("git", "checkout", "-B", sourceBranch) + cmd.Dir = repoCopyPath + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return "", err + } + } else { + cmd := exec.Command("git", "checkout", "-B", sourceBranch) + cmd.Dir = repoCopyPath + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return "", err + } + } + cmd := exec.Command("git", "checkout", "-B", branch, sourceBranch) + cmd.Dir = repoCopyPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", err + } + return branch, nil +} + +func loadRepoConfig(repoRoot string) (*repoConfig, error) { + path := repoConfigPath(repoRoot) + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("missing %s; run `agent init` first", path) + } + if err != nil { + return nil, err + } + cfg := defaultRepoConfig() + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + normalizeRepoConfig(cfg) + return cfg, nil +} + +func loadRepoConfigOrDefault(repoRoot string) (*repoConfig, error) { + path := repoConfigPath(repoRoot) + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return defaultRepoConfig(), nil + } + if err != nil { + return nil, err + } + cfg := defaultRepoConfig() + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + normalizeRepoConfig(cfg) + return cfg, nil +} + +func repoConfigPath(repoRoot string) string { + return filepath.Join(repoRoot, ".agent.yaml") +} + +func defaultRepoConfig() *repoConfig { + return &repoConfig{ + CopyIgnore: []string{"build", ".dart_tool"}, + AgentKeyPaths: defaultAgentKeyPaths(), + } +} + +func defaultCopyIgnoreExcludes() []string { + return []string{".agents", ".DS_Store"} +} + +func normalizeRepoConfig(cfg *repoConfig) { + if cfg == nil { + return + } + cfg.BaseBranch = strings.TrimSpace(cfg.BaseBranch) + cfg.CopyIgnore = normalizeIgnoreValues(cfg.CopyIgnore) + cfg.AgentKeyPaths = normalizeIgnoreValues(cfg.AgentKeyPaths) +} + +func normalizeIgnoreValues(values []string) []string { + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + value = strings.Trim(value, ",") + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func containsString(values []string, target string) bool { + target = strings.TrimSpace(target) + for _, value := range values { + if strings.TrimSpace(value) == target { + return true + } + } + return false +} + +func saveRepoConfig(repoRoot string, cfg *repoConfig) error { + normalizeRepoConfig(cfg) + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + path := repoConfigPath(repoRoot) + return os.WriteFile(path, data, 0o644) +} + +func loadFeatureConfig(path string) (*featureConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + var cfg featureConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg.IsFlutter { + if _, ok := raw["device"]; !ok { + cfg.Device = defaultManagedDeviceID + } + } + return &cfg, nil +} + +func saveFeatureConfig(path string, cfg featureConfig) error { + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmpPath := fmt.Sprintf("%s.tmp-%d", path, os.Getpid()) + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func updateFeatureConfig(path string, update func(*featureConfig) error) error { + lockPath := path + ".lock" + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return err + } + defer lockFile.Close() + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { + return err + } + defer func() { _ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) }() + + cfg, err := loadFeatureConfig(path) + if err != nil { + return err + } + if err := update(cfg); err != nil { + return err + } + return saveFeatureConfig(path, *cfg) +} + +func configureFlutterWebConfig(repoRoot, repoCopyPath string, port int) error { + if port <= 0 { + return nil + } + templatePath := filepath.Join(repoRoot, "agents", "web_dev_config.template.yaml") + configPath := filepath.Join(repoCopyPath, "web_dev_config.yaml") + if fileExists(templatePath) && !fileExists(configPath) { + data, err := os.ReadFile(templatePath) + if err == nil { + if err := os.WriteFile(configPath, data, 0o644); err != nil { + return err + } + } + } + content := fmt.Sprintf("server:\n host: \"localhost\"\n port: %d\n headers:\n - name: \"Cache-Control\"\n value: \"no-cache, no-store, must-revalidate\"\n", port) + return os.WriteFile(configPath, []byte(content), 0o644) +} + +func ensureGeneratedRepoPathIgnored(repoCopyPath, relPath string) error { + relPath = filepath.ToSlash(filepath.Clean(strings.TrimSpace(relPath))) + if relPath == "" || relPath == "." { + return fmt.Errorf("relative path is required") + } + trackedPaths, err := trackedPathsForRepoCopy(repoCopyPath, []string{relPath}) + if err != nil { + return err + } + if len(trackedPaths) > 0 { + return markGitPathsSkipWorktree(repoCopyPath, trackedPaths) + } + return ensureGitExcludeEntries(repoCopyPath, []string{relPath}) +} + +func writeFlutterHelperScripts(workspaceRoot, repoCopyPath, url, device string) error { + if err := os.MkdirAll(filepath.Join(workspaceRoot, "logs"), 0o755); err != nil { + return err + } + ensureServer := `#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" +INFO="$DIR/agent.json" +AGENT_BIN="${AGENT_BIN:-$HOME/.config/agent-tracker/bin/agent}" + +new_device="${1-}" +if [[ -n "$new_device" ]]; then + "$AGENT_BIN" feature --workspace "$DIR" --device "$new_device" +fi + +port=$(python3 - "$INFO" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print(data.get('port', '')) +PY +) +device=$(python3 - "$INFO" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +value = data.get('device') +if value is None: + value = 'web-server' +print(value) +PY +) +logfile="$DIR/logs/flutter-$port.log" +: > "$logfile" 2>/dev/null || true + +flutter_ready_seen() { + if [[ -n "${TMUX_PANE-}" ]]; then + if tmux capture-pane -p -S -200 -t "$TMUX_PANE" 2>/dev/null | grep -qi 'Flutter run key commands\.'; then + return 0 + fi + fi + if [[ -f "$logfile" ]] && grep -qi 'Flutter run key commands\.' "$logfile" 2>/dev/null; then + return 0 + fi + return 1 +} + +if [[ -z "$device" ]]; then + echo "No launch device selected. Run ./ensure-server.sh to start Flutter." + exit 0 +fi + +if [[ "$device" == "web-server" ]]; then + ( + deadline=$((SECONDS+300)) + while [ $SECONDS -lt $deadline ]; do + if flutter_ready_seen; then + "$AGENT_BIN" feature --workspace "$DIR" --ready true + sleep 2 + "$AGENT_BIN" browser refresh --workspace "$DIR" --preserve-focus >/dev/null 2>&1 || true + exit 0 + fi + sleep 0.1 + done + ) & +fi + +cd "$DIR" +exec script -q "$logfile" bash -lc "cd \"$DIR/repo\" && exec flutter run -d \"$device\"" +` + ensurePath := filepath.Join(workspaceRoot, "ensure-server.sh") + if err := os.WriteFile(ensurePath, []byte(ensureServer), 0o755); err != nil { + return err + } + hotReload := `#!/usr/bin/env bash +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_DIR="$(dirname "$REPO_DIR")" +INFO="$WORKSPACE_DIR/agent.json" +AGENT_BIN="${AGENT_BIN:-$HOME/.config/agent-tracker/bin/agent}" + +port=$(python3 - "$INFO" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print(data.get('port', '')) +PY +) +device=$(python3 - "$INFO" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +value = data.get('device') +if value is None: + value = 'web-server' +print(value) +PY +) +logfile="$WORKSPACE_DIR/logs/flutter-$port.log" + +if [[ -z "$device" ]]; then + echo "No launch device selected" + exit 1 +fi + +set +e +analyze_output=$(cd "$REPO_DIR" && flutter analyze lib --no-fatal-infos --no-fatal-warnings 2>&1) +analyze_exit=$? +set -e + +filtered=$(printf "%s\n" "$analyze_output" | awk '/^Analyzing/ {found=1} found {print}') +[[ -n "$filtered" ]] && printf "%s\n" "$filtered" +if [[ $analyze_exit -ne 0 ]]; then + echo "Analysis failed." + [[ -z "$filtered" ]] && printf "%s\n" "$analyze_output" + exit 1 +fi + +if [[ ! -f "$logfile" ]] || ! grep -qiE 'Flutter run key commands\.|is being served at|serving at|lib/main\.dart is being served' "$logfile" 2>/dev/null; then + echo "Flutter server not ready" + exit 1 +fi + +find_flutter_pane() { + [[ -z "${TMUX-}" ]] && return 1 + + has_flutter_run() { + local pid=$1 depth=${2:-0} + [[ $depth -gt 12 ]] && return 1 + local child + 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 + return 0 + fi + if has_flutter_run "$child" $((depth + 1)); then + return 0 + fi + done < <(pgrep -P "$pid" 2>/dev/null || true) + return 1 + } + + local pane_id pane_pid pane_path + while read -r pane_id pane_pid pane_path; do + [[ -z "$pane_id" || -z "$pane_pid" ]] && continue + [[ "$pane_path" != "$WORKSPACE_DIR" && "$pane_path" != "$REPO_DIR" ]] && continue + if has_flutter_run "$pane_pid"; 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) + + return 1 +} + +target_pane=$(find_flutter_pane) || { + echo "Flutter pane not found" + exit 1 +} +lines_before=$(wc -l < "$logfile") +tmux send-keys -t "$target_pane" r 2>/dev/null + +( + restart_server() { + tmux send-keys -t "$target_pane" C-c 2>/dev/null || true + sleep 0.5 + tmux send-keys -t "$target_pane" "cd '$WORKSPACE_DIR' && ./ensure-server.sh" Enter 2>/dev/null || true + } + + for _ in $(seq 1 100); do + newlines=$(python3 - "$logfile" "$lines_before" <<'PY' +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +start = int(sys.argv[2]) +if not path.exists(): + sys.exit(0) +with path.open('r', errors='ignore') as handle: + lines = handle.readlines() +sys.stdout.write(''.join(lines[start:])) +PY +) + if printf "%s\n" "$newlines" | grep -qiE 'Reloaded [0-9]+ libraries|Reloaded 1 of [0-9]+ libraries|Restarted application in'; then + exit 0 + fi + if printf "%s\n" "$newlines" | grep -qi 'Page requires refresh'; then + "$AGENT_BIN" browser open --workspace "$WORKSPACE_DIR" --allow-open --preserve-focus >/dev/null 2>&1 || true + "$AGENT_BIN" browser refresh --workspace "$WORKSPACE_DIR" --preserve-focus >/dev/null 2>&1 || true + exit 0 + fi + if printf "%s\n" "$newlines" | grep -qiE 'no client connected|no connected devices|Hot reload rejected'; then + if [[ "$device" == "web-server" ]]; then + "$AGENT_BIN" browser open --workspace "$WORKSPACE_DIR" --allow-open --preserve-focus >/dev/null 2>&1 || true + "$AGENT_BIN" browser refresh --workspace "$WORKSPACE_DIR" --preserve-focus >/dev/null 2>&1 || true + restart_server + fi + exit 0 + fi + sleep 0.3 + done +exit 0 +) >/dev/null 2>&1 & + +echo "Hot reload triggered" +` + 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 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 detectDefaultBaseBranch(repoRoot string) string { + for _, candidate := range []string{"develop", "main", "master"} { + if remoteExists(repoRoot, "origin/"+candidate) || localExists(repoRoot, candidate) { + return candidate + } + } + return "main" +} + +func currentLocalBranch(repoRoot string) string { + cmd := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD") + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil { + return "" + } + branch := strings.TrimSpace(string(out)) + if branch == "" || branch == "HEAD" { + return "" + } + return branch +} + +func remoteExists(repoRoot, ref string) bool { + cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/remotes/"+ref) + cmd.Dir = repoRoot + return cmd.Run() == nil +} + +func localExists(repoRoot, ref string) bool { + cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+ref) + cmd.Dir = repoRoot + return cmd.Run() == nil +} + +func allocatePort(repoRoot string, start int) (int, error) { + for port := start; port < start+500; port++ { + if !portClaimedByRegistry(port) && !portClaimedByFeatureConfigs(repoRoot, port) && portFree(port) { + return port, nil + } + } + return 0, fmt.Errorf("failed to allocate port") +} + +func portClaimedByFeatureConfigs(repoRoot string, port int) bool { + paths, err := filepath.Glob(filepath.Join(repoRoot, ".agents", "*", "agent.json")) + if err != nil { + return false + } + for _, path := range paths { + cfg, err := loadFeatureConfig(path) + if err == nil && cfg.Port == port { + return true + } + } + return false +} + +func portClaimedByRegistry(port int) bool { + reg, err := loadRegistry() + if err != nil { + return false + } + for _, record := range reg.Agents { + if record.Port == port { + return true + } + } + return false +} + +func portFree(port int) bool { + cmd := exec.Command("lsof", "-PiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN", "-n") + if err := cmd.Run(); err == nil { + return false + } + return true +} + +func loadRegistry() (*registry, error) { + path := registryPath() + reg := ®istry{Agents: map[string]*agentRecord{}} + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return reg, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, reg); err != nil { + return nil, err + } + if reg.Agents == nil { + reg.Agents = map[string]*agentRecord{} + } + return reg, nil +} + +func saveRegistry(reg *registry) error { + path := registryPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(reg, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func registryPath() string { + return filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "run", "agents.json") +} + +func configPath() string { + return filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "agent-config.json") +} + +func loadAppConfig() appConfig { + cfg := appConfig{Keys: keyConfig{ + MoveLeft: "n", + MoveRight: "i", + MoveUp: "u", + MoveDown: "e", + Edit: "Enter", + Cancel: "Escape", + AddTodo: "a", + ToggleTodo: "x", + Destroy: "D", + Confirm: "y", + Back: "Escape", + DeleteTodo: "d", + Help: "?", + FocusAI: "M-a", + FocusGit: "M-g", + FocusDash: "M-s", + FocusRun: "M-r", + }} + data, err := os.ReadFile(configPath()) + if err != nil { + return cfg + } + _ = json.Unmarshal(data, &cfg) + return cfg +} + +func sortedAgentIDs(reg *registry) []string { + ids := make([]string, 0, len(reg.Agents)) + for id := range reg.Agents { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +func promptInput(prompt string) (string, error) { + fmt.Print(prompt) + reader := bufio.NewReader(os.Stdin) + text, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(text), nil +} + +func promptInputWithDefault(label, defaultValue string) (string, error) { + if strings.TrimSpace(defaultValue) == "" { + return promptInput(label + ": ") + } + value, err := promptInput(fmt.Sprintf("%s [%s]: ", label, defaultValue)) + if err != nil { + return "", err + } + if strings.TrimSpace(value) == "" { + return defaultValue, nil + } + return value, nil +} + +func sanitizeFeatureName(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, " ", "-") + value = featureNamePattern.ReplaceAllString(value, "-") + value = strings.Trim(value, "-._") + return value +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func runTmux(args ...string) error { + cmd := exec.Command("tmux", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runTmuxOutput(args ...string) (string, error) { + cmd := exec.Command("tmux", args...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +func syncChromeForFeature(featurePath string, allowOpen bool, preserveFocus bool) error { + cfg, err := loadFeatureConfig(featurePath) + if err != nil { + return err + } + if strings.TrimSpace(cfg.URL) == "" || strings.TrimSpace(cfg.Device) != "web-server" { + return nil + } + if !allowOpen && !preserveFocus { + script := `on run argv +set targetUrl to item 1 of argv +set windowText to item 2 of argv +set tabText to item 3 of argv +tell application "Google Chrome" + if windowText is not "" and tabText is not "" then + try + set targetWindow to window (windowText as integer) + set targetTab to tab (tabText as integer) of targetWindow + if (URL of targetTab starts with targetUrl) then + set active tab index of targetWindow to (tabText as integer) + return "reused\n" & windowText & "\n" & tabText + end if + end try + end if + set windowCounter to 0 + repeat with w in windows + if mode of w is "normal" then + set windowCounter to windowCounter + 1 + set i to 1 + repeat with t in tabs of w + if (URL of t starts with targetUrl) then + set active tab index of w to i + return "switched\n" & windowCounter & "\n" & i + end if + set i to i + 1 + end repeat + end if + end repeat +end tell +return "none" +end run` + out, err := runAppleScript(script, cfg.URL, strconv.Itoa(cfg.ChromeWindow), strconv.Itoa(cfg.ChromeTab)) + if err != nil { + return err + } + parts := strings.Split(strings.TrimSpace(out), "\n") + if len(parts) >= 3 && parts[0] != "none" { + return updateFeatureConfig(featurePath, func(cfg *featureConfig) error { + if v, convErr := strconv.Atoi(strings.TrimSpace(parts[1])); convErr == nil { + cfg.ChromeWindow = v + } + if v, convErr := strconv.Atoi(strings.TrimSpace(parts[2])); convErr == nil { + cfg.ChromeTab = v + } + return nil + }) + } + return nil + } + appID, appName := currentFrontApp() + script := `on run argv +set targetUrl to item 1 of argv +set shouldOpen to item 2 of argv +set windowText to item 3 of argv +set tabText to item 4 of argv + +tell application "Google Chrome" + if windowText is not "" and tabText is not "" then + try + set targetWindow to window (windowText as integer) + set targetTab to tab (tabText as integer) of targetWindow + set tabUrl to URL of targetTab + if (tabUrl starts with targetUrl) then + set active tab index of targetWindow to (tabText as integer) + return "reused\n" & windowText & "\n" & tabText + end if + if shouldOpen is "true" and tabUrl is "chrome-error://chromewebdata/" then + set URL of targetTab to targetUrl + set active tab index of targetWindow to (tabText as integer) + return "reused\n" & windowText & "\n" & tabText + end if + end try + end if + set windowCounter to 0 + repeat with w in windows + if mode of w is "normal" then + set windowCounter to windowCounter + 1 + set i to 1 + repeat with t in tabs of w + if (URL of t starts with targetUrl) then + set active tab index of w to i + return "switched\n" & windowCounter & "\n" & i + end if + set i to i + 1 + end repeat + end if + end repeat + if shouldOpen is "true" then + set targetWindow to missing value + set targetWindowIndex to 0 + set windowCounter to 0 + repeat with w in windows + if mode of w is "normal" then + set windowCounter to windowCounter + 1 + set targetWindow to w + set targetWindowIndex to windowCounter + exit repeat + end if + end repeat + if targetWindow is missing value then + make new window + set targetWindow to window 1 + set targetWindowIndex to 1 + end if + tell targetWindow to make new tab with properties {URL:targetUrl} + tell targetWindow to set active tab index to (count of tabs) + return "opened\n" & targetWindowIndex & "\n" & (active tab index of targetWindow) + end if +end tell +return "none" +end run` + out, err := runAppleScript(script, cfg.URL, strconv.FormatBool(allowOpen), strconv.Itoa(cfg.ChromeWindow), strconv.Itoa(cfg.ChromeTab)) + if err != nil { + return err + } + parts := strings.Split(strings.TrimSpace(out), "\n") + if preserveFocus && len(parts) >= 1 && strings.TrimSpace(parts[0]) == "opened" && strings.TrimSpace(appName) != "" && appName != "Google Chrome" { + _ = restoreFrontApp(appID, appName) + } + if len(parts) >= 3 && parts[0] != "none" { + return updateFeatureConfig(featurePath, func(cfg *featureConfig) error { + if v, convErr := strconv.Atoi(strings.TrimSpace(parts[1])); convErr == nil { + cfg.ChromeWindow = v + } + if v, convErr := strconv.Atoi(strings.TrimSpace(parts[2])); convErr == nil { + cfg.ChromeTab = v + } + return nil + }) + } + return nil +} + +func currentFrontApp() (string, string) { + nameOut, nameErr := exec.Command("/usr/bin/osascript", "-e", `tell application "System Events" to name of first application process whose frontmost is true`).Output() + idOut, idErr := exec.Command("/usr/bin/osascript", "-e", `tell application "System Events" to bundle identifier of first application process whose frontmost is true`).Output() + if nameErr != nil && idErr != nil { + return "", "" + } + return strings.TrimSpace(string(idOut)), strings.TrimSpace(string(nameOut)) +} + +func restoreFrontApp(appID, appName string) error { + if strings.TrimSpace(appName) == "" { + return nil + } + script := `on run argv +set appId to item 1 of argv +set appName to item 2 of argv +delay 0.2 +if appId is not "" then + try + tell application id appId to activate + end try + try + tell application "System Events" + set frontmost of first application process whose bundle identifier is appId to true + end tell + return + end try +end if +if appName is not "" then + try + tell application appName to activate + end try + try + tell application "System Events" + set frontmost of first application process whose name is appName to true + end tell + end try +end if +end run` + cmd := exec.Command("/usr/bin/osascript", "-e", script, appID, appName) + return cmd.Run() +} + +func refreshChromeForFeature(featurePath string, preserveFocus bool) error { + cfg, err := loadFeatureConfig(featurePath) + if err != nil { + return err + } + if strings.TrimSpace(cfg.URL) == "" || strings.TrimSpace(cfg.Device) != "web-server" { + return nil + } + script := `on run argv +set targetUrl to item 1 of argv +set portText to item 2 of argv +set windowText to item 3 of argv +set tabText to item 4 of argv +set preserveFocus to item 5 of argv + +tell application "Google Chrome" + if windowText is not "" and tabText is not "" then + try + set targetWindow to window (windowText as integer) + set targetTab to tab (tabText as integer) of targetWindow + set tabUrl to URL of targetTab + if preserveFocus is "true" and tabUrl is not "chrome-error://chromewebdata/" then + tell targetTab to execute javascript "window.location.reload()" + else + set URL of targetTab to targetUrl + end if + if preserveFocus is not "true" then + try + set active tab index of targetWindow to (tabText as integer) + end try + end if + return + end try + end if + repeat with w in windows + if mode of w is "normal" then + repeat with t in tabs of w + set tabUrl to URL of t + set tabTitle to title of t + if (tabUrl starts with targetUrl) or (portText is not "" and tabTitle contains ("localhost:" & portText)) or (tabUrl is "chrome-error://chromewebdata/" and active tab index of w is (index of t)) then + if preserveFocus is "true" and tabUrl is not "chrome-error://chromewebdata/" then + tell t to execute javascript "window.location.reload()" + else + set URL of t to targetUrl + end if + if preserveFocus is not "true" then + set active tab index of w to (index of t) + end if + return + end if + end repeat + end if + end repeat +end tell +end run` + cmd := exec.Command("/usr/bin/osascript", "-e", script, cfg.URL, strconv.Itoa(cfg.Port), strconv.Itoa(cfg.ChromeWindow), strconv.Itoa(cfg.ChromeTab), strconv.FormatBool(preserveFocus)) + if err := cmd.Run(); err != nil { + return err + } + return nil +} + +type chromePreferences struct { + Browser struct { + AllowJavaScriptAppleEvents bool `json:"allow_javascript_apple_events"` + } `json:"browser"` +} + +func chromePreferencesPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Preferences"), nil +} + +func chromeAppleEventsEnabled() (bool, error) { + if runtime.GOOS != "darwin" { + return true, nil + } + path, err := chromePreferencesPath() + if err != nil { + return false, err + } + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + var prefs chromePreferences + if err := json.Unmarshal(data, &prefs); err != nil { + return false, err + } + return prefs.Browser.AllowJavaScriptAppleEvents, nil +} + +func ensureChromeAppleEventsEnabled() error { + enabled, err := chromeAppleEventsEnabled() + if err != nil { + return fmt.Errorf("unable to verify Chrome Apple Events JavaScript permission: %w", err) + } + if enabled { + return nil + } + return fmt.Errorf("Chrome 'Allow JavaScript from Apple Events' is disabled; enable it in Chrome View > Developer > Allow JavaScript from Apple Events before starting a web-server agent") +} + +func runAppleScript(script string, args ...string) (string, error) { + cmdArgs := append([]string{"-e", script}, args...) + cmd := exec.Command("/usr/bin/osascript", cmdArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(out)) + if message == "" { + return "", err + } + return "", fmt.Errorf("%w: %s", err, message) + } + return string(out), nil +} + +func focusChromeTab(url string, openIfMissing bool) error { + if strings.TrimSpace(url) == "" { + return nil + } + script := `on run argv +set targetUrl to item 1 of argv +set shouldOpen to item 2 of argv +tell application "System Events" + set frontAppName to "" + set frontAppID to "" + try + set frontAppName to name of first application process whose frontmost is true + set frontAppID to bundle identifier of first application process whose frontmost is true + end try +end tell +tell application "Google Chrome" + set matchedTab to missing value + set matchedWindow to missing value + set matchedIndex to 0 + repeat with w in windows + if mode of w is "normal" then + set i to 1 + repeat with t in tabs of w + if (URL of t starts with targetUrl) then + set matchedTab to t + set matchedWindow to w + set matchedIndex to i + exit repeat + end if + set i to i + 1 + end repeat + end if + if matchedTab is not missing value then exit repeat + end repeat + if matchedTab is not missing value then + set active tab index of matchedWindow to matchedIndex + else if shouldOpen is "true" then + set targetWindow to missing value + repeat with w in windows + if mode of w is "normal" then + set targetWindow to w + exit repeat + end if + end repeat + if targetWindow is missing value then + make new window + set targetWindow to window 1 + end if + tell targetWindow + make new tab with properties {URL:targetUrl} + set active tab index to (count of tabs) + end tell + if frontAppName is not "" and frontAppName is not "Google Chrome" then + if frontAppID is not "" then + try + tell application id frontAppID to activate + return + end try + end if + try + tell application frontAppName to activate + end try + end if + end if +end tell +end run` + cmd := exec.Command("osascript", "-e", script, url, strconv.FormatBool(openIfMissing)) + return cmd.Run() +} + +func closeChromeTab(url string) error { + if strings.TrimSpace(url) == "" { + return nil + } + script := `on run argv +set targetUrl to item 1 of argv +tell application "Google Chrome" + if not running then return + repeat with w in windows + set i to (count of tabs of w) + repeat while i > 0 + set t to tab i of w + if (URL of t starts with targetUrl) then close t + set i to i - 1 + end repeat + end repeat +end tell +end run` + cmd := exec.Command("osascript", "-e", script, url) + return cmd.Run() +} diff --git a/agent-tracker/cmd/agent/palette.go b/agent-tracker/cmd/agent/palette.go new file mode 100644 index 0000000..4da54c6 --- /dev/null +++ b/agent-tracker/cmd/agent/palette.go @@ -0,0 +1,260 @@ +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 + 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 + paletteActionToggleTodo + paletteActionReloadTmuxConfig + paletteActionToggleMemoryDisplay + paletteActionOpenSnippets + paletteActionOpenTodos + paletteActionOpenDevices + paletteActionOpenTracker +) + +type paletteAction struct { + Section string + Title string + Subtitle string + Keywords []string + Kind paletteActionKind + RepoRoot string + TodoIndex int +} + +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) +} diff --git a/agent-tracker/cmd/agent/palette_bubbletea.go b/agent-tracker/cmd/agent/palette_bubbletea.go new file mode 100644 index 0000000..6017f02 --- /dev/null +++ b/agent-tracker/cmd/agent/palette_bubbletea.go @@ -0,0 +1,2264 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const paletteNoDeviceOption = "__no_device__" + +var paletteModalBorder = lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "┌", + TopRight: "┐", + BottomLeft: "└", + BottomRight: "┘", +} + +var paletteDestroyLauncher = launchPaletteDestroyWithConfirm +var paletteTmuxRunner = runTmux +var paletteTmuxOutput = runTmuxOutput + +type paletteRuntime struct { + windowID string + agentID string + reg *registry + record *agentRecord + startupMessage string + currentPath string + currentSessionName string + currentWindowName string + mainRepoRoot string +} + +type paletteModel struct { + runtime *paletteRuntime + state paletteUIState + actions []paletteAction + openedAt time.Time + width int + height int + result paletteResult + todo *todoPanelModel + activity *activityMonitorBT + devices *devicePanelModel + tracker *trackerPanelModel +} + +type paletteStyles struct { + title lipgloss.Style + meta lipgloss.Style + searchBox lipgloss.Style + searchPrompt lipgloss.Style + input lipgloss.Style + inputCursor lipgloss.Style + item lipgloss.Style + selectedItem lipgloss.Style + sectionLabel lipgloss.Style + selectedLabel lipgloss.Style + itemTitle lipgloss.Style + itemSubtitle lipgloss.Style + selectedSubtle lipgloss.Style + panelTitle lipgloss.Style + panelText lipgloss.Style + muted lipgloss.Style + footer lipgloss.Style + keyword lipgloss.Style + modal lipgloss.Style + modalTitle lipgloss.Style + modalBody lipgloss.Style + modalHint lipgloss.Style + statusBad lipgloss.Style + statLabel lipgloss.Style + statValue lipgloss.Style + todoCheck lipgloss.Style + todoCheckDone lipgloss.Style + panelTextDone lipgloss.Style + shortcutKey lipgloss.Style + shortcutText lipgloss.Style +} + +type paletteTodoPreviewItem struct { + Title string + Done bool +} + +type paletteTodoPreviewSection struct { + Title string + Lead string + Items []paletteTodoPreviewItem + Empty string +} + +func runBubbleTeaPalette(args []string) error { + runtime, err := loadPaletteRuntime(args) + if err != nil { + return err + } + state := paletteUIState{Mode: paletteModeList, Message: runtime.startupMessage} + for { + model := newPaletteModel(runtime, state) + finalModel, err := tea.NewProgram(model).Run() + if err != nil { + return err + } + final, ok := finalModel.(*paletteModel) + if !ok { + return fmt.Errorf("unexpected palette model type") + } + state = final.result.State + switch final.result.Kind { + case paletteResultClose: + return nil + case paletteResultOpenActivityMonitor: + err := runtime.runActivityMonitor() + if errors.Is(err, errClosePalette) { + return nil + } + state.Mode = paletteModeList + state.Message = paletteMessageForError(err) + continue + case paletteResultOpenSnippets: + state.Mode = paletteModeSnippets + state.Filter = nil + state.FilterCursor = 0 + state.Selected = 0 + state.Message = "" + continue + case paletteResultRunAction: + reopen, message, err := runtime.execute(final.result) + if err != nil { + if reopen { + state.Mode = paletteModeList + state.Message = err.Error() + continue + } + return err + } + if !reopen { + return nil + } + state.Mode = paletteModeList + state.Message = message + continue + default: + return nil + } + } +} + +func loadPaletteRuntime(args []string) (*paletteRuntime, error) { + fs := flag.NewFlagSet("agent palette", flag.ContinueOnError) + var windowID string + var agentID string + var currentPath string + var currentSessionName string + var currentWindowName 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.SetOutput(nil) + if err := fs.Parse(args); err != nil { + return nil, err + } + + runtime := &paletteRuntime{ + windowID: firstNonEmpty(windowID, os.Getenv("AGENT_PALETTE_WINDOW_ID")), + agentID: firstNonEmpty(agentID, os.Getenv("AGENT_PALETTE_AGENT_ID")), + currentPath: firstNonEmpty(currentPath, os.Getenv("AGENT_PALETTE_PATH")), + currentSessionName: firstNonEmpty(currentSessionName, os.Getenv("AGENT_PALETTE_SESSION_NAME")), + currentWindowName: firstNonEmpty(currentWindowName, os.Getenv("AGENT_PALETTE_WINDOW_NAME")), + } + logPaletteLaunchIfMalformed(runtime) + if looksLikeTmuxFormatLiteral(runtime.agentID) { + runtime.agentID = "" + } + if runtime.agentID == "" && runtime.windowID != "" { + if ctx, err := detectCurrentAgentFromTmux(runtime.windowID); err == nil { + runtime.agentID = ctx.ID + } + } else if runtime.agentID == "" { + if ctx, err := detectCurrentAgentFromTmux(""); err == nil { + runtime.agentID = ctx.ID + } + } + if err := runtime.reload(); err != nil { + return nil, err + } + return runtime, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func logPaletteLaunchIfMalformed(runtime *paletteRuntime) { + if runtime == nil { + return + } + values := []string{ + runtime.windowID, + runtime.agentID, + runtime.currentPath, + runtime.currentSessionName, + runtime.currentWindowName, + } + for _, value := range values { + if strings.Contains(value, "#{") { + file, err := os.OpenFile("/tmp/agent-palette-launch.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer file.Close() + _, _ = fmt.Fprintf(file, "%s window=%q agent=%q path=%q session=%q window_name=%q args=%q\n", + time.Now().Format(time.RFC3339Nano), + runtime.windowID, + runtime.agentID, + runtime.currentPath, + runtime.currentSessionName, + runtime.currentWindowName, + os.Args, + ) + return + } + } +} + +func (r *paletteRuntime) reload() error { + reg, err := loadRegistry() + if err != nil { + r.startupMessage = fmt.Sprintf("Ignoring malformed registry: %v", err) + reg = ®istry{Agents: map[string]*agentRecord{}} + } else { + r.startupMessage = "" + } + r.reg = reg + r.record = nil + if looksLikeTmuxFormatLiteral(r.agentID) { + r.agentID = "" + } + if r.agentID != "" { + r.record = reg.Agents[r.agentID] + } + tmuxValue := func(target string, format string) string { + args := []string{"display-message", "-p"} + if strings.TrimSpace(target) != "" { + args = append(args, "-t", strings.TrimSpace(target)) + } + args = append(args, format) + out, err := runTmuxOutput(args...) + if err != nil { + return "" + } + return strings.TrimSpace(out) + } + if strings.TrimSpace(r.windowID) == "" { + r.windowID = tmuxValue("", "#{window_id}") + } + if strings.TrimSpace(r.currentPath) == "" { + r.currentPath = tmuxValue(r.windowID, "#{pane_current_path}") + } + if strings.TrimSpace(r.currentSessionName) == "" { + r.currentSessionName = tmuxValue(r.windowID, "#{session_name}") + } + if strings.TrimSpace(r.currentWindowName) == "" { + r.currentWindowName = tmuxValue(r.windowID, "#{window_name}") + } + if inferredAgentID := detectPaletteAgentIDFromPath(r.currentPath); inferredAgentID != "" { + if strings.TrimSpace(r.agentID) == "" || r.record == nil { + r.agentID = inferredAgentID + } + if r.record == nil { + r.record = reg.Agents[inferredAgentID] + } + } + if r.record != nil { + r.agentID = r.record.ID + } + r.mainRepoRoot = detectPaletteMainRepoRoot(r.currentPath, r.record) + return nil +} + +func (r *paletteRuntime) effectiveAgentID() string { + if r.record != nil && strings.TrimSpace(r.record.ID) != "" { + return strings.TrimSpace(r.record.ID) + } + if inferred := detectPaletteAgentIDFromPath(r.currentPath); inferred != "" { + return inferred + } + if looksLikeTmuxFormatLiteral(r.agentID) { + return "" + } + return sanitizeFeatureName(r.agentID) +} + +func (r *paletteRuntime) persistRecord(update func(*agentRecord) error) error { + if r.record == nil { + return fmt.Errorf("no agent found for this tmux window") + } + if err := update(r.record); err != nil { + return err + } + r.record.UpdatedAt = time.Now() + r.reg.Agents[r.record.ID] = r.record + if err := saveRegistry(r.reg); err != nil { + return err + } + return r.reload() +} + +func (r *paletteRuntime) buildActions() []paletteAction { + memoryDisplayEnabled := paletteMemoryDisplayEnabled() + memoryTitle := "Hide memory display" + memorySubtitle := "Hide bottom-right tmux memory stats" + memoryKeywords := []string{"tmux", "memory", "status", "status-right", "hide", "bottom-right"} + if !memoryDisplayEnabled { + memoryTitle = "Show memory display" + memorySubtitle = "Show bottom-right tmux memory stats" + memoryKeywords = []string{"tmux", "memory", "status", "status-right", "show", "bottom-right"} + } + actions := []paletteAction{ + { + Section: "Agent", + Title: "Start agent", + Subtitle: startAgentSubtitle(r.mainRepoRoot, r.currentPath), + Keywords: []string{"agent", "start", "new", "feature", "repo"}, + Kind: paletteActionPromptStartAgent, + RepoRoot: r.mainRepoRoot, + }, + } + if strings.TrimSpace(r.agentID) != "" { + actions = append(actions, paletteAction{ + Section: "Agent", + Title: "Destroy agent", + Subtitle: "Delete the workspace and close its tmux window", + Keywords: []string{"agent", "destroy", "remove", "delete"}, + Kind: paletteActionConfirmDestroy, + }) + } + actions = append(actions, + paletteAction{ + Section: "System", + Title: "Tracker", + Subtitle: "Live tasks and completion status", + Keywords: []string{"tracker", "tasks", "activity", "status"}, + Kind: paletteActionOpenTracker, + }, + paletteAction{ + Section: "System", + Title: "Activity Monitor", + Subtitle: "View CPU, memory and process usage", + Keywords: []string{"activity", "monitor", "cpu", "memory", "processes", "top", "ps"}, + Kind: paletteActionOpenActivityMonitor, + }, + paletteAction{ + Section: "System", + Title: "Paste snippet", + Subtitle: "Search and paste a snippet into the current pane", + Keywords: []string{"snippet", "paste", "template", "text", "insert"}, + Kind: paletteActionOpenSnippets, + }, + paletteAction{ + Section: "System", + Title: "Todos", + Subtitle: "Manage window/global todos", + Keywords: []string{"todo", "task", "checklist", "manage"}, + Kind: paletteActionOpenTodos, + }, + paletteAction{ + Section: "System", + Title: "Edit devices", + Subtitle: "Add or remove global launch devices", + Keywords: []string{"devices", "device", "edit", "manage", "web-server"}, + Kind: paletteActionOpenDevices, + }, + paletteAction{ + Section: "System", + Title: "Reload tmux config", + Subtitle: "Source ~/.config/.tmux.conf", + Keywords: []string{"tmux", "reload", "config", "source", "refresh"}, + Kind: paletteActionReloadTmuxConfig, + }, + paletteAction{ + Section: "System", + Title: memoryTitle, + Subtitle: memorySubtitle, + Keywords: memoryKeywords, + Kind: paletteActionToggleMemoryDisplay, + }, + ) + if strings.TrimSpace(r.agentID) == "" { + return actions + } + if r.record == nil { + return actions + } + for idx, todo := range r.record.Dashboard.Todos { + status := "open" + if todo.Done { + status = "done" + } + actions = append(actions, paletteAction{ + Section: "Agent", + Title: fmt.Sprintf("Toggle todo: %s", todo.Title), + Subtitle: fmt.Sprintf("Mark todo %s", status), + Keywords: []string{"agent", "todo", "toggle", status, todo.Title}, + Kind: paletteActionToggleTodo, + TodoIndex: idx, + }) + } + return actions +} + +func (r *paletteRuntime) runAgentStart(repoRoot, feature, device string, keepWorktree bool) error { + repoRoot = r.resolveStartRepoRoot(repoRoot) + feature = sanitizeFeatureName(feature) + if !isPaletteNoDeviceOption(device) { + device = normalizeManagedDeviceID(device) + } + if repoRoot == "" { + return fmt.Errorf("main repo not found") + } + if feature == "" { + return fmt.Errorf("feature name is required") + } + agentBin := filepath.Join(os.Getenv("HOME"), ".config", "bin", "agent") + args := buildAgentStartArgs(feature, device, keepWorktree) + cmd := exec.Command(agentBin, args...) + cmd.Dir = repoRoot + cmd.Stdin = os.Stdin + cmd.Env = os.Environ() + if strings.TrimSpace(r.windowID) != "" { + cmd.Env = append(cmd.Env, "AGENT_TMUX_TARGET_WINDOW="+strings.TrimSpace(r.windowID)) + } + output, err := cmd.CombinedOutput() + if err == nil { + return nil + } + message := strings.TrimSpace(string(output)) + if message != "" { + return fmt.Errorf("%s", message) + } + return err +} + +func launchPaletteDestroy(agentID string) error { + return launchPaletteDestroyWithConfirm(agentID, "") +} + +func launchPaletteDestroyWithConfirm(agentID string, confirmText string) error { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return fmt.Errorf("no agent found for this tmux window") + } + if _, err := loadDestroyTarget(agentID); err != nil { + return err + } + extraArgs := "" + if strings.TrimSpace(confirmText) != "" { + extraArgs = fmt.Sprintf(" --confirm %s", shellQuote(strings.TrimSpace(confirmText))) + } + if os.Getenv("TMUX") != "" { + exe, err := os.Executable() + if err != nil { + return err + } + return runTmux("run-shell", "-b", fmt.Sprintf("%s destroy --id %s%s", shellQuote(exe), shellQuote(agentID), extraArgs)) + } + args := []string{"destroy", "--id", agentID} + if strings.TrimSpace(confirmText) != "" { + args = append(args, "--confirm", strings.TrimSpace(confirmText)) + } + return spawnDetachedAgentCommand(args...) +} + +func buildAgentStartArgs(feature, device string, keepWorktree bool) []string { + args := []string{"start"} + if keepWorktree { + args = append(args, "--keep-worktree") + } + if isPaletteNoDeviceOption(device) { + args = append(args, "--no-device") + } else if device != "" { + args = append(args, "-d", device) + } + return append(args, feature) +} + +func isPaletteNoDeviceOption(device string) bool { + return strings.TrimSpace(device) == paletteNoDeviceOption +} + +func startAgentPromptDevices(repoRoot string) ([]string, int) { + repoRoot = strings.TrimSpace(repoRoot) + devices := loadManagedDevices() + if len(devices) == 0 { + devices = []string{defaultManagedDeviceID} + } + if repoRoot != "" && fileExists(filepath.Join(repoRoot, "pubspec.yaml")) { + return append([]string{paletteNoDeviceOption}, devices...), 1 + } + return devices, 0 +} + +func (r *paletteRuntime) resolveStartRepoRoot(repoRoot string) string { + tryPaths := []string{ + strings.TrimSpace(repoRoot), + strings.TrimSpace(r.mainRepoRoot), + strings.TrimSpace(r.currentPath), + } + if strings.TrimSpace(r.windowID) != "" { + if out, err := runTmuxOutput("display-message", "-p", "-t", strings.TrimSpace(r.windowID), "#{pane_current_path}"); err == nil { + tryPaths = append(tryPaths, strings.TrimSpace(out)) + } + } + if cwd, err := os.Getwd(); err == nil { + tryPaths = append(tryPaths, strings.TrimSpace(cwd)) + } + for _, path := range tryPaths { + if resolved := detectPaletteMainRepoRoot(path, r.record); strings.TrimSpace(resolved) != "" { + return strings.TrimSpace(resolved) + } + } + return "" +} + +func (r *paletteRuntime) startSourceBranch(repoRoot string) string { + repoRoot = r.resolveStartRepoRoot(repoRoot) + if repoRoot == "" { + return "" + } + repoCfg, err := loadRepoConfigOrDefault(repoRoot) + if err != nil { + return detectDefaultBaseBranch(repoRoot) + } + return resolveStartSourceBranch(repoRoot, repoCfg) +} + +func (r *paletteRuntime) canStartAgent(repoRoot string) bool { + return strings.TrimSpace(r.resolveStartRepoRoot(repoRoot)) != "" +} + +func (r *paletteRuntime) runActivityMonitor() error { + return runBubbleTeaActivityMonitor(r.windowID) +} + +func (r *paletteRuntime) execute(result paletteResult) (bool, string, error) { + action := result.Action + text := strings.TrimSpace(result.Input) + switch action.Kind { + case paletteActionToggleTodo: + err := r.persistRecord(func(record *agentRecord) error { + if action.TodoIndex < 0 || action.TodoIndex >= len(record.Dashboard.Todos) { + return fmt.Errorf("todo no longer exists") + } + record.Dashboard.Todos[action.TodoIndex].Done = !record.Dashboard.Todos[action.TodoIndex].Done + return nil + }) + return false, "", err + case paletteActionPromptStartAgent: + if err := r.runAgentStart(action.RepoRoot, text, result.Device, result.KeepWorktree); err != nil { + return true, "", err + } + return false, "", nil + case paletteActionConfirmDestroy: + agentID := r.effectiveAgentID() + if agentID == "" { + return true, "", fmt.Errorf("no agent found for this tmux window") + } + confirmText := "" + if result.State.ConfirmRequiresText { + confirmText = strings.TrimSpace(result.Input) + } + err := paletteDestroyLauncher(agentID, confirmText) + if err != nil { + return true, "", err + } + return false, "", nil + case paletteActionReloadTmuxConfig: + return false, "", paletteTmuxRunner("source-file", os.Getenv("HOME")+"/.config/.tmux.conf") + case paletteActionToggleMemoryDisplay: + return false, "", togglePaletteMemoryDisplay() + default: + return false, "", nil + } +} + +func paletteMemoryDisplayEnabled() bool { + out, err := paletteTmuxOutput("show-environment", "-g", "TMUX_STATUS_MEMORY") + if err != nil { + return true + } + line := strings.TrimSpace(out) + if strings.HasPrefix(line, "TMUX_STATUS_MEMORY=") { + value := strings.TrimSpace(strings.TrimPrefix(line, "TMUX_STATUS_MEMORY=")) + switch strings.ToLower(value) { + case "0", "false", "off", "no": + return false + } + } + return true +} + +func togglePaletteMemoryDisplay() error { + value := "0" + if !paletteMemoryDisplayEnabled() { + value = "1" + } + if err := paletteTmuxRunner("set-environment", "-g", "TMUX_STATUS_MEMORY", value); err != nil { + return err + } + return paletteTmuxRunner("refresh-client", "-S") +} + +func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteModel { + if state.Mode == 0 { + state.Mode = paletteModeList + } + state.FilterCursor = clampInt(state.FilterCursor, 0, len(state.Filter)) + state.PromptCursor = clampInt(state.PromptCursor, 0, len(state.PromptText)) + 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()} + if state.Mode == paletteModeTodos { + _ = model.openTodosPanel() + } + if state.Mode == paletteModeActivity { + _, _ = model.openActivityPanel() + } + if state.Mode == paletteModeDevices { + model.openDevicesPanel() + } + if state.Mode == paletteModeTracker { + _, _ = model.openTrackerPanel() + } + return model +} + +func (m *paletteModel) Init() tea.Cmd { + return nil +} + +func (m *paletteModel) openTodosPanel() error { + sessionID, windowID := getCurrentTmuxScopeInfo() + if m.todo == nil { + panel, err := newTodoPanelModel(sessionID, windowID) + if err != nil { + return err + } + m.todo = panel + } else { + m.todo.sessionID = strings.TrimSpace(sessionID) + m.todo.windowID = strings.TrimSpace(windowID) + m.todo.reloadEntries() + m.todo.clampSelections() + m.todo.focusedScope = todoScopeWindow + m.todo.mode = todoPanelModeList + } + m.todo.showAltHints = false + m.state.Mode = paletteModeTodos + m.state.Message = "" + m.state.ShowAltHints = false + return nil +} + +func (m *paletteModel) openSnippetsPanel() { + m.state.Mode = paletteModeSnippets + m.state.Filter = nil + m.state.FilterCursor = 0 + m.state.Selected = 0 + m.state.SnippetOffset = 0 + m.state.Message = "" + m.state.ShowAltHints = false +} + +func (m *paletteModel) openActivityPanel() (tea.Cmd, error) { + if m.activity == nil { + m.activity = newActivityMonitorModel(m.runtime.windowID, true) + } else { + m.activity.windowID = strings.TrimSpace(m.runtime.windowID) + m.activity.requestBack = false + m.activity.requestClose = false + } + m.activity.width = m.width + m.activity.height = m.height + m.activity.showAltHints = false + m.state.Mode = paletteModeActivity + m.state.Message = "" + m.state.ShowAltHints = false + if !m.activity.refreshInFlight { + return tea.Batch( + activityRequestRefreshBT(true, m.activity.refreshedAt.IsZero(), m.activity), + activityTickCmd(), + ), nil + } + return nil, nil +} + +func (m *paletteModel) openDevicesPanel() { + if m.devices == nil { + m.devices = newDevicePanelModel() + } else { + m.devices.reload() + m.devices.mode = devicePanelModeList + m.devices.requestBack = false + } + m.devices.showAltHints = false + m.state.Mode = paletteModeDevices + m.state.Message = "" + m.state.ShowAltHints = false +} + +func (m *paletteModel) openTrackerPanel() (tea.Cmd, error) { + if m.tracker == nil { + m.tracker = newTrackerPanelModel(m.runtime) + } else { + m.tracker.runtime = m.runtime + m.tracker.requestBack = false + m.tracker.requestClose = false + } + m.tracker.width = m.width + m.tracker.height = m.height + m.tracker.showAltHints = false + m.state.Mode = paletteModeTracker + m.state.Message = "" + m.state.ShowAltHints = false + return m.tracker.activate(), nil +} + +func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if m.todo != nil { + m.todo.width = msg.Width + m.todo.height = msg.Height + } + if m.activity != nil { + m.activity.width = msg.Width + m.activity.height = msg.Height + } + if m.tracker != nil { + m.tracker.width = msg.Width + m.tracker.height = msg.Height + } + case tea.KeyMsg: + if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeTracker { + if isAltFooterToggleKey(msg) { + m.state.ShowAltHints = !m.state.ShowAltHints + return m, nil + } + m.state.ShowAltHints = false + } + key := msg.String() + if key == "alt+s" { + if time.Since(m.openedAt) < 250*time.Millisecond { + return m, nil + } + m.result = paletteResult{Kind: paletteResultClose, State: m.state} + return m, tea.Quit + } + if m.state.Mode == paletteModeActivity { + if m.activity == nil { + cmd, err := m.openActivityPanel() + if err != nil { + m.state.Mode = paletteModeList + m.state.Message = err.Error() + return m, nil + } + return m, cmd + } + model, cmd := m.activity.Update(msg) + if updated, ok := model.(*activityMonitorBT); ok { + m.activity = updated + } + if m.activity.requestClose { + m.result = paletteResult{Kind: paletteResultClose, State: m.state} + return m, tea.Quit + } + if m.activity.requestBack { + m.activity.requestBack = false + m.state.Mode = paletteModeList + m.state.Message = m.activity.currentStatus() + return m, nil + } + return m, cmd + } + if m.state.Mode == paletteModeTodos { + if key == "esc" && m.todo != nil && m.todo.mode == todoPanelModeList { + m.state.Mode = paletteModeList + m.state.Message = m.todo.currentStatus() + return m, nil + } + if m.todo == nil { + if err := m.openTodosPanel(); err != nil { + m.state.Mode = paletteModeList + m.state.Message = err.Error() + return m, nil + } + } + model, cmd := m.todo.Update(msg) + if updated, ok := model.(*todoPanelModel); ok { + m.todo = updated + } + return m, cmd + } + if m.state.Mode == paletteModeDevices { + if m.devices == nil { + m.openDevicesPanel() + } + model, cmd := m.devices.Update(msg) + if updated, ok := model.(*devicePanelModel); ok { + m.devices = updated + } + if m.devices.requestBack { + m.devices.requestBack = false + m.state.Mode = paletteModeList + m.state.Message = m.devices.currentStatus() + return m, nil + } + return m, cmd + } + if m.state.Mode == paletteModeTracker { + if m.tracker == nil { + cmd, err := m.openTrackerPanel() + if err != nil { + m.state.Mode = paletteModeList + m.state.Message = err.Error() + return m, nil + } + return m, cmd + } + model, cmd := m.tracker.Update(msg) + if updated, ok := model.(*trackerPanelModel); ok { + m.tracker = updated + } + if m.tracker.requestClose { + m.result = paletteResult{Kind: paletteResultClose, State: m.state} + return m, tea.Quit + } + if m.tracker.requestBack { + m.tracker.requestBack = false + m.state.Mode = paletteModeList + m.state.Message = m.tracker.currentStatus() + return m, nil + } + return m, cmd + } + switch m.state.Mode { + case paletteModePrompt: + return m.updatePrompt(key) + case paletteModeConfirmDestroy: + return m.updateConfirm(key) + case paletteModeSnippets: + return m.updateSnippets(key) + case paletteModeSnippetVars: + return m.updateSnippetVars(key) + default: + return m.updateList(key) + } + } + if m.state.Mode == paletteModeActivity && m.activity != nil { + model, cmd := m.activity.Update(msg) + if updated, ok := model.(*activityMonitorBT); ok { + m.activity = updated + } + if m.activity.requestClose { + m.result = paletteResult{Kind: paletteResultClose, State: m.state} + return m, tea.Quit + } + if m.activity.requestBack { + m.activity.requestBack = false + m.state.Mode = paletteModeList + m.state.Message = m.activity.currentStatus() + return m, nil + } + return m, cmd + } + if m.state.Mode == paletteModeTodos && m.todo != nil { + model, cmd := m.todo.Update(msg) + if updated, ok := model.(*todoPanelModel); ok { + m.todo = updated + } + return m, cmd + } + if m.state.Mode == paletteModeDevices && m.devices != nil { + model, cmd := m.devices.Update(msg) + if updated, ok := model.(*devicePanelModel); ok { + m.devices = updated + } + if m.devices.requestBack { + m.devices.requestBack = false + m.state.Mode = paletteModeList + m.state.Message = m.devices.currentStatus() + return m, nil + } + return m, cmd + } + if m.state.Mode == paletteModeTracker && m.tracker != nil { + model, cmd := m.tracker.Update(msg) + if updated, ok := model.(*trackerPanelModel); ok { + m.tracker = updated + } + if m.tracker.requestClose { + m.result = paletteResult{Kind: paletteResultClose, State: m.state} + return m, tea.Quit + } + if m.tracker.requestBack { + m.tracker.requestBack = false + m.state.Mode = paletteModeList + m.state.Message = m.tracker.currentStatus() + return m, nil + } + return m, cmd + } + return m, nil +} + +func (m *paletteModel) updateList(key string) (tea.Model, tea.Cmd) { + if key == "esc" || key == "ctrl+c" || key == "alt+n" { + m.result = paletteResult{Kind: paletteResultClose, State: m.state} + return m, tea.Quit + } + if key == "alt+a" { + cmd, err := m.openActivityPanel() + if err != nil { + m.state.Message = err.Error() + return m, nil + } + return m, cmd + } + if key == "alt+p" { + m.openSnippetsPanel() + return m, nil + } + if key == "alt+r" { + cmd, err := m.openTrackerPanel() + if err != nil { + m.state.Message = err.Error() + return m, nil + } + return m, cmd + } + if key == "alt+t" { + if err := m.openTodosPanel(); err != nil { + m.state.Message = err.Error() + } + return m, nil + } + if key == "alt+c" { + m.openPrompt(palettePromptStartAgent, "", m.runtime.mainRepoRoot) + return m, nil + } + actions := m.filteredActions() + navigate := func(delta int) { + if len(actions) == 0 { + m.state.Selected = 0 + return + } + m.state.Selected = clampInt(m.state.Selected+delta, 0, len(actions)-1) + } + switch key { + case "ctrl+u", "alt+u", "up": + navigate(-1) + return m, nil + case "ctrl+e", "alt+e", "down": + navigate(1) + return m, nil + case "ctrl+n", "left": + m.state.FilterCursor = clampInt(m.state.FilterCursor-1, 0, len(m.state.Filter)) + return m, nil + case "ctrl+i", "tab", "right": + m.state.FilterCursor = clampInt(m.state.FilterCursor+1, 0, len(m.state.Filter)) + return m, nil + case "enter", "alt+i": + if len(actions) == 0 || m.state.Selected < 0 || m.state.Selected >= len(actions) { + return m, nil + } + return m.selectAction(actions[m.state.Selected]) + } + if applyPaletteInputKey(key, &m.state.Filter, &m.state.FilterCursor, false) { + m.state.Selected = 0 + m.state.ActionOffset = 0 + m.state.Message = "" + } + return m, nil +} + +func (m *paletteModel) selectAction(action paletteAction) (tea.Model, tea.Cmd) { + switch action.Kind { + case paletteActionPromptStartAgent: + m.openPrompt(palettePromptStartAgent, "", action.RepoRoot) + return m, nil + case paletteActionConfirmDestroy: + target, err := loadDestroyTarget(m.runtime.effectiveAgentID()) + if err != nil { + m.state.Message = err.Error() + m.state.Mode = paletteModeList + return m, nil + } + m.state.Mode = paletteModeConfirmDestroy + m.state.Message = "" + m.state.ShowAltHints = false + m.state.ConfirmRequiresText = target.RequiresExplicitConfirm + m.state.PromptText = nil + m.state.PromptCursor = 0 + return m, nil + case paletteActionOpenActivityMonitor: + cmd, err := m.openActivityPanel() + if err != nil { + m.state.Message = err.Error() + return m, nil + } + return m, cmd + case paletteActionOpenSnippets: + m.openSnippetsPanel() + return m, nil + case paletteActionOpenTracker: + cmd, err := m.openTrackerPanel() + 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() + } + return m, nil + case paletteActionOpenDevices: + m.openDevicesPanel() + return m, nil + default: + m.state.Mode = paletteModeList + m.result = paletteResult{Kind: paletteResultRunAction, Action: action, State: m.state} + return m, tea.Quit + } +} + +func (m *paletteModel) openPrompt(kind palettePromptKind, initial string, repoRoot string) { + devices := []string(nil) + deviceIndex := 0 + if kind == palettePromptStartAgent { + resolvedRepoRoot := strings.TrimSpace(m.runtime.resolveStartRepoRoot(repoRoot)) + devices, deviceIndex = startAgentPromptDevices(resolvedRepoRoot) + } + m.state.Mode = paletteModePrompt + m.state.PromptKind = kind + m.state.PromptField = palettePromptFieldName + m.state.PromptText = []rune(initial) + m.state.PromptCursor = len(m.state.PromptText) + m.state.PromptRepoRoot = strings.TrimSpace(repoRoot) + m.state.PromptDevices = devices + m.state.PromptDeviceIndex = deviceIndex + m.state.PromptKeepWorktree = false + m.state.ShowAltHints = false + m.state.Message = "" +} + +func (m *paletteModel) updatePrompt(key string) (tea.Model, tea.Cmd) { + if key == "esc" { + m.state.Mode = paletteModeList + m.state.Message = "" + return m, nil + } + if m.state.PromptKind == palettePromptStartAgent { + if !m.runtime.canStartAgent(m.state.PromptRepoRoot) { + return m, nil + } + if key == "alt+d" || key == "alt+D" || key == "alt+shift+d" { + deviceCount := len(m.state.PromptDevices) + if deviceCount == 0 { + resolvedRepoRoot := strings.TrimSpace(m.runtime.resolveStartRepoRoot(m.state.PromptRepoRoot)) + m.state.PromptDevices, m.state.PromptDeviceIndex = startAgentPromptDevices(resolvedRepoRoot) + deviceCount = len(m.state.PromptDevices) + } + if deviceCount > 0 { + selected := clampInt(m.state.PromptDeviceIndex, 0, deviceCount-1) + if key == "alt+d" { + m.state.PromptDeviceIndex = (selected + 1) % deviceCount + } else { + m.state.PromptDeviceIndex = (selected - 1 + deviceCount) % deviceCount + } + } + return m, nil + } + switch key { + case "tab", "ctrl+i": + switch m.state.PromptField { + case palettePromptFieldName: + m.state.PromptField = palettePromptFieldDevice + case palettePromptFieldDevice: + m.state.PromptField = palettePromptFieldWorktree + default: + m.state.PromptField = palettePromptFieldName + } + return m, nil + case "shift+tab": + switch m.state.PromptField { + case palettePromptFieldWorktree: + m.state.PromptField = palettePromptFieldDevice + case palettePromptFieldDevice: + m.state.PromptField = palettePromptFieldName + default: + m.state.PromptField = palettePromptFieldWorktree + } + return m, nil + } + if m.state.PromptField == palettePromptFieldDevice { + deviceCount := len(m.state.PromptDevices) + if deviceCount == 0 { + m.state.PromptDevices = []string{defaultManagedDeviceID} + deviceCount = 1 + } + switch key { + case "ctrl+n", "left", "n": + m.state.PromptDeviceIndex = clampInt(m.state.PromptDeviceIndex-1, 0, deviceCount-1) + return m, nil + case "right", "i": + m.state.PromptDeviceIndex = clampInt(m.state.PromptDeviceIndex+1, 0, deviceCount-1) + return m, nil + } + } + if m.state.PromptField == palettePromptFieldWorktree { + switch key { + case "ctrl+n", "left", "n": + m.state.PromptKeepWorktree = false + return m, nil + case "right", "i": + m.state.PromptKeepWorktree = true + return m, nil + case " ", "space": + m.state.PromptKeepWorktree = !m.state.PromptKeepWorktree + return m, nil + } + } + } + if key == "enter" { + text := strings.TrimSpace(string(m.state.PromptText)) + if m.state.PromptKind == palettePromptStartAgent && text == "" { + m.state.Message = "Feature name is required" + m.state.Mode = paletteModeList + return m, nil + } + action := paletteAction{} + switch m.state.PromptKind { + case palettePromptStartAgent: + action = paletteAction{Kind: paletteActionPromptStartAgent, RepoRoot: m.state.PromptRepoRoot} + } + device := "" + if m.state.PromptKind == palettePromptStartAgent && m.state.PromptDeviceIndex >= 0 && m.state.PromptDeviceIndex < len(m.state.PromptDevices) { + device = m.state.PromptDevices[m.state.PromptDeviceIndex] + } + m.state.Mode = paletteModeList + m.result = paletteResult{Kind: paletteResultRunAction, Action: action, Input: text, Device: device, KeepWorktree: m.state.PromptKeepWorktree, State: m.state} + return m, tea.Quit + } + if m.state.PromptKind == palettePromptStartAgent && m.state.PromptField != palettePromptFieldName { + return m, nil + } + applyPaletteInputKey(key, &m.state.PromptText, &m.state.PromptCursor, true) + return m, nil +} + +func (m *paletteModel) updateConfirm(key string) (tea.Model, tea.Cmd) { + if key == "esc" { + m.state.Mode = paletteModeList + m.state.ConfirmRequiresText = false + m.state.PromptText = nil + m.state.PromptCursor = 0 + return m, nil + } + if m.state.ConfirmRequiresText { + if key == "enter" { + if strings.TrimSpace(string(m.state.PromptText)) != "destroy" { + m.state.Message = "Type destroy to confirm" + return m, nil + } + m.state.Mode = paletteModeList + m.result = paletteResult{Kind: paletteResultRunAction, Action: paletteAction{Kind: paletteActionConfirmDestroy}, Input: strings.TrimSpace(string(m.state.PromptText)), State: m.state} + return m, tea.Quit + } + applyPaletteInputKey(key, &m.state.PromptText, &m.state.PromptCursor, true) + return m, nil + } + if key == "y" || key == "Y" { + m.state.Mode = paletteModeList + m.state.ConfirmRequiresText = false + m.result = paletteResult{Kind: paletteResultRunAction, Action: paletteAction{Kind: paletteActionConfirmDestroy}, State: m.state} + return m, tea.Quit + } + m.state.Mode = paletteModeList + m.state.ConfirmRequiresText = false + return m, nil +} + +func (m *paletteModel) updateSnippets(key string) (tea.Model, tea.Cmd) { + if key == "esc" || key == "ctrl+c" { + m.state.Mode = paletteModeList + m.state.Message = "" + return m, nil + } + snippets := m.filteredSnippets() + navigate := func(delta int) { + if len(snippets) == 0 { + m.state.Selected = 0 + return + } + m.state.Selected = clampInt(m.state.Selected+delta, 0, len(snippets)-1) + } + switch key { + case "ctrl+u", "up": + navigate(-1) + return m, nil + case "ctrl+e", "down": + navigate(1) + return m, nil + case "ctrl+n", "left": + m.state.FilterCursor = clampInt(m.state.FilterCursor-1, 0, len(m.state.Filter)) + return m, nil + case "ctrl+i", "tab", "right": + m.state.FilterCursor = clampInt(m.state.FilterCursor+1, 0, len(m.state.Filter)) + return m, nil + case "enter": + if len(snippets) == 0 || m.state.Selected < 0 || m.state.Selected >= len(snippets) { + return m, nil + } + snippet := snippets[m.state.Selected] + if len(snippet.Vars) > 0 { + m.state.SnippetName = snippet.Name + m.state.SnippetContent = snippet.Content + m.state.SnippetVars = snippet.Vars + m.state.SnippetVarIndex = 0 + m.state.SnippetVarValues = make(map[string]string) + m.state.PromptText = nil + m.state.PromptCursor = 0 + m.state.Mode = paletteModeSnippetVars + return m, nil + } + if err := pasteToTmuxPane(snippet.Content); err != nil { + m.state.Mode = paletteModeList + m.state.Message = err.Error() + return m, nil + } + m.result = paletteResult{Kind: paletteResultClose, State: m.state} + return m, tea.Quit + } + if applyPaletteInputKey(key, &m.state.Filter, &m.state.FilterCursor, false) { + m.state.Selected = 0 + m.state.SnippetOffset = 0 + m.state.Message = "" + } + return m, nil +} + +func (m *paletteModel) updateSnippetVars(key string) (tea.Model, tea.Cmd) { + if key == "esc" { + m.state.Mode = paletteModeSnippets + m.state.Message = "" + return m, nil + } + if key == "enter" { + varName := m.state.SnippetVars[m.state.SnippetVarIndex] + m.state.SnippetVarValues[varName] = string(m.state.PromptText) + m.state.SnippetVarIndex++ + if m.state.SnippetVarIndex >= len(m.state.SnippetVars) { + rendered := renderSnippet(m.state.SnippetContent, m.state.SnippetVarValues) + if err := pasteToTmuxPane(rendered); err != nil { + m.state.Mode = paletteModeList + m.state.Message = err.Error() + return m, nil + } + m.result = paletteResult{Kind: paletteResultClose, State: m.state} + return m, tea.Quit + } + m.state.PromptText = nil + m.state.PromptCursor = 0 + return m, nil + } + applyPaletteInputKey(key, &m.state.PromptText, &m.state.PromptCursor, true) + return m, nil +} + +func (m *paletteModel) filteredSnippets() []snippet { + snippets := loadSnippets() + query := strings.ToLower(strings.TrimSpace(string(m.state.Filter))) + if query == "" { + return snippets + } + parts := strings.Fields(query) + filtered := make([]snippet, 0, len(snippets)) + for _, s := range snippets { + haystack := strings.ToLower(s.Name + " " + s.Description + " " + s.Content) + matched := true + for _, part := range parts { + if !strings.Contains(haystack, part) { + matched = false + break + } + } + if matched { + filtered = append(filtered, s) + } + } + return filtered +} + +func (m *paletteModel) View() string { + width := m.width + height := m.height + if width <= 0 { + width = 96 + } + if height <= 0 { + height = 28 + } + if width < 48 || height < 14 { + return "Window too small for command palette" + } + styles := newPaletteStyles() + if m.state.Mode == paletteModePrompt { + return m.renderPrompt(styles, width, height) + } + if m.state.Mode == paletteModeConfirmDestroy { + return m.renderConfirm(styles, width, height) + } + if m.state.Mode == paletteModeActivity { + if m.activity != nil { + m.activity.width = width + m.activity.height = height + return m.activity.View() + } + return styles.muted.Render("Activity monitor unavailable") + } + if m.state.Mode == paletteModeSnippets { + return m.renderSnippets(styles, width, height) + } + if m.state.Mode == paletteModeSnippetVars { + return m.renderSnippetVars(styles, width, height) + } + if m.state.Mode == paletteModeTodos { + if m.todo != nil { + m.todo.width = width + m.todo.height = height + return m.todo.View() + } + return styles.muted.Render("Todo panel unavailable") + } + if m.state.Mode == paletteModeDevices { + if m.devices != nil { + m.devices.width = width + m.devices.height = height + return m.devices.render(styles, width, height) + } + return styles.muted.Render("Device panel unavailable") + } + if m.state.Mode == paletteModeTracker { + if m.tracker != nil { + m.tracker.width = width + m.tracker.height = height + return m.tracker.render(styles, width, height) + } + return styles.muted.Render("Tracker unavailable") + } + return m.renderListView(styles, width, height) +} + +func (m *paletteModel) renderListView(styles paletteStyles, width, height int) string { + actions := m.filteredActions() + if len(actions) == 0 { + m.state.Selected = 0 + } else { + m.state.Selected = clampInt(m.state.Selected, 0, len(actions)-1) + } + title := "Command Palette" + if m.runtime.record != nil { + title = title + " " + styles.keyword.Render(m.runtime.record.ID) + } + metaParts := []string{} + if m.runtime.currentSessionName != "" { + metaParts = append(metaParts, m.runtime.currentSessionName) + } + if m.runtime.currentWindowName != "" { + metaParts = append(metaParts, m.runtime.currentWindowName) + } + if m.runtime.mainRepoRoot != "" { + metaParts = append(metaParts, filepathBaseOrFull(m.runtime.mainRepoRoot)) + } + header := styles.title.Render(title) + if len(metaParts) > 0 { + header = lipgloss.JoinVertical(lipgloss.Left, header, styles.meta.Render(strings.Join(metaParts, " · "))) + } + filterLine := styles.searchBox.Width(width).Render( + lipgloss.JoinHorizontal(lipgloss.Center, + styles.searchPrompt.Render(">"), + " ", + styles.input.Render(renderInputValue(m.state.Filter, m.state.FilterCursor, styles)), + ), + ) + contentHeight := maxInt(8, height-7) + listWidth := maxInt(34, width*48/100) + dashboardWidth := maxInt(28, width-listWidth-3) + list := m.renderActions(styles, actions, listWidth, contentHeight) + dashboard := m.renderDashboard(styles, dashboardWidth, contentHeight) + body := lipgloss.JoinHorizontal(lipgloss.Top, list, strings.Repeat(" ", 3), dashboard) + footer := renderPaletteFooter(styles, width, m.state.Message, m.state.ShowAltHints) + view := lipgloss.JoinVertical(lipgloss.Left, header, "", filterLine, "", body, "", footer) + return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view) +} + +func (m *paletteModel) renderActions(styles paletteStyles, actions []paletteAction, width, height int) string { + entriesPerPage := maxInt(1, (height-2)/3) + selected := clampInt(m.state.Selected, 0, maxInt(0, len(actions)-1)) + offset := stableListOffset(m.state.ActionOffset, selected, entriesPerPage, len(actions)) + m.state.ActionOffset = offset + blocks := []string{styles.meta.Render(fmt.Sprintf("%d commands", len(actions))), ""} + if len(actions) == 0 { + blocks = append(blocks, styles.muted.Width(width).Render("No matching commands")) + } else { + for row := 0; row < entriesPerPage; row++ { + idx := offset + row + if idx >= len(actions) { + break + } + action := actions[idx] + sectionLabel := styles.sectionLabel + subtle := styles.itemSubtitle + titleStyle := styles.itemTitle + box := styles.item + markerText := " " + markerStyle := styles.muted + rowStyle := lipgloss.NewStyle().Width(maxInt(16, width-2)) + fillStyle := lipgloss.NewStyle() + if idx == selected { + selectedBG := lipgloss.Color("238") + sectionLabel = styles.selectedLabel.Background(selectedBG) + subtle = styles.selectedSubtle.Background(selectedBG) + titleStyle = styles.itemTitle.Background(selectedBG).Foreground(lipgloss.Color("230")) + box = styles.selectedItem + markerText = "› " + markerStyle = styles.selectedLabel.Background(selectedBG) + rowStyle = rowStyle.Background(selectedBG).Foreground(lipgloss.Color("230")) + fillStyle = fillStyle.Background(selectedBG).Foreground(lipgloss.Color("230")) + } + innerWidth := maxInt(16, width-2) + labelText := strings.ToUpper(action.Section) + labelWidth := lipgloss.Width(labelText) + markerWidth := lipgloss.Width(markerText) + titleWidth := maxInt(10, innerWidth-markerWidth-labelWidth-1) + titleText := truncate(action.Title, titleWidth) + gapWidth := maxInt(1, innerWidth-markerWidth-lipgloss.Width(titleText)-labelWidth) + titleRow := rowStyle.Render( + markerStyle.Render(markerText) + + titleStyle.Render(titleText) + + fillStyle.Render(strings.Repeat(" ", gapWidth)) + + sectionLabel.Render(labelText), + ) + subtitleRow := rowStyle.Render(fillStyle.Render(strings.Repeat(" ", markerWidth)) + subtle.Render(truncate(action.Subtitle, maxInt(0, innerWidth-markerWidth)))) + block := lipgloss.JoinVertical(lipgloss.Left, titleRow, subtitleRow) + blocks = append(blocks, box.Width(width).Render(block)) + } + } + content := strings.Join(blocks, "\n") + return lipgloss.NewStyle().Width(width).Height(height).Render(content) +} + +func (m *paletteModel) renderDashboard(styles paletteStyles, width, height int) string { + lines := []string{} + trackerContext, trackerAgent, trackerBootstrap := m.runtime.dashboardTrackerStatus() + lines = append(lines, styles.panelTitle.Render("Tracker Status")) + lines = append(lines, renderPaletteStat(styles, "Context", trackerContext, width, 9)) + lines = append(lines, renderPaletteStat(styles, "Agent", trackerAgent, width, 9)) + lines = append(lines, renderPaletteStat(styles, "Bootstrap", trackerBootstrap, width, 9)) + lines = append(lines, "") + lines = append(lines, styles.panelTitle.Render("Todo Preview")) + previewLimit := clampInt((height-6)/4, 1, 3) + sections := m.runtime.dashboardTodoPreviewSections() + for idx, section := range sections { + if idx > 0 { + lines = append(lines, "") + } + lines = append(lines, renderPaletteTodoPreviewSection(styles, section, width, previewLimit)...) + } + content := strings.Join(lines, "\n") + return lipgloss.NewStyle().Width(width).Height(height).Render(content) +} + +func (r *paletteRuntime) dashboardTrackerStatus() (contextSummary, agentSummary, bootstrapSummary string) { + contextParts := []string{} + if r.currentSessionName != "" { + contextParts = append(contextParts, r.currentSessionName) + } + if r.currentWindowName != "" { + contextParts = append(contextParts, r.currentWindowName) + } + if r.mainRepoRoot != "" { + contextParts = append(contextParts, filepathBaseOrFull(r.mainRepoRoot)) + } else if r.currentPath != "" { + contextParts = append(contextParts, filepathBaseOrFull(r.currentPath)) + } + if len(contextParts) == 0 { + contextSummary = "No tmux context detected" + } else { + contextSummary = strings.Join(contextParts, " · ") + } + if r.record == nil { + agentID := r.effectiveAgentID() + if agentID == "" { + return contextSummary, "No active agent", "No active agent" + } + return contextSummary, fmt.Sprintf("%s not loaded", agentID), "No active agent" + } + agentSummary = r.record.ID + if r.record.Branch != "" { + agentSummary = agentSummary + " on " + r.record.Branch + } + bootstrapSummary = paletteBootstrapStatus(r.record) + return contextSummary, agentSummary, bootstrapSummary +} + +func (r *paletteRuntime) dashboardTodoPreviewSections() []paletteTodoPreviewSection { + sections := []paletteTodoPreviewSection{} + store, err := loadTmuxTodoStore() + windowID := strings.TrimSpace(r.windowID) + if err != nil { + sections = append(sections, paletteTodoPreviewSection{Title: "Window", Empty: "Todo store unavailable"}) + sections = append(sections, paletteTodoPreviewSection{Title: "Global", Empty: "Todo store unavailable"}) + } else { + windowSection := paletteTodoPreviewSection{Title: "Window", Empty: "No window todos"} + if windowID == "" { + windowSection.Empty = "No window context" + } else { + windowSection.Items = paletteTmuxTodoPreviewItems(todoItemsForScope(store, todoScopeWindow, windowID)) + } + sections = append(sections, windowSection) + sections = append(sections, paletteTodoPreviewSection{ + Title: "Global", + Items: paletteTmuxTodoPreviewItems(todoItemsForScope(store, todoScopeGlobal, "")), + Empty: "No global todos", + }) + } + if r.record != nil { + section := paletteTodoPreviewSection{ + Title: "Dashboard", + Items: paletteAgentTodoPreviewItems(r.record.Dashboard.Todos), + Empty: "No dashboard todos", + } + if currentTask := firstPaletteLine(r.record.Dashboard.CurrentTask); currentTask != "" { + section.Lead = "Task: " + currentTask + } + sections = append(sections, section) + } + return sections +} + +func paletteBootstrapStatus(record *agentRecord) string { + if record == nil { + return "No active agent" + } + workspaceRoot := strings.TrimSpace(record.WorkspaceRoot) + if workspaceRoot == "" { + return "No workspace" + } + if fileExists(bootstrapRepoReadyPath(workspaceRoot)) { + return paletteBootstrapLabel("ready", paletteBootstrapPID(workspaceRoot)) + } + if fileExists(bootstrapFailedPath(workspaceRoot)) { + message := firstPaletteLine(readPaletteBootstrapFailure(workspaceRoot)) + if message == "" { + return "failed" + } + return "failed: " + message + } + pid := paletteBootstrapPID(workspaceRoot) + if fileExists(bootstrapGitReadyPath(workspaceRoot)) { + return paletteBootstrapLabel("copying repo", pid) + } + if pid > 0 { + return paletteBootstrapLabel("preparing git", pid) + } + return "preparing git" +} + +func paletteBootstrapPID(workspaceRoot string) int { + data, err := os.ReadFile(bootstrapPIDPath(workspaceRoot)) + if err != nil { + return 0 + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || !processRunning(pid) { + return 0 + } + return pid +} + +func paletteBootstrapLabel(status string, pid int) string { + if pid <= 0 { + return status + } + return fmt.Sprintf("%s (pid %d)", status, pid) +} + +func readPaletteBootstrapFailure(workspaceRoot string) string { + data, err := os.ReadFile(bootstrapFailedPath(workspaceRoot)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func (m *paletteModel) renderPrompt(styles paletteStyles, width, height int) string { + title := "Input" + detail := "Enter a value" + if m.state.PromptKind == palettePromptStartAgent { + title = "Start agent" + repoRoot := blankIfEmpty(m.runtime.resolveStartRepoRoot(m.state.PromptRepoRoot), "Main repo not found") + if repoRoot == "Main repo not found" { + body := lipgloss.JoinVertical(lipgloss.Left, + styles.modalTitle.Render(title), + styles.statusBad.Render(repoRoot), + "", + styles.modalHint.Render(renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints, + [][][2]string{{{"Esc", "back"}, {footerHintToggleKey, "more"}}}, + [][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}}, + )), + ) + box := styles.modal.Width(minInt(72, maxInt(36, width-10))).Render(body) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) + } + sourceBranch := blankIfEmpty(m.runtime.startSourceBranch(m.state.PromptRepoRoot), "Unavailable") + devices := m.state.PromptDevices + if len(devices) == 0 { + devices = []string{defaultManagedDeviceID} + } + nameLabel := styles.modalHint.Render("NAME") + deviceLabel := styles.modalHint.Render("DEVICE") + worktreeLabel := styles.modalHint.Render("WORKTREE") + if m.state.PromptField == palettePromptFieldName { + nameLabel = styles.selectedLabel.Render("NAME") + } else if m.state.PromptField == palettePromptFieldDevice { + deviceLabel = styles.selectedLabel.Render("DEVICE") + } else { + worktreeLabel = styles.selectedLabel.Render("WORKTREE") + } + deviceChips := make([]string, 0, len(devices)) + for idx, deviceID := range devices { + deviceChips = append(deviceChips, renderPaletteDeviceChip(styles, deviceID, idx == clampInt(m.state.PromptDeviceIndex, 0, len(devices)-1))) + } + worktreeChips := []string{ + renderPaletteDeviceChip(styles, "CLEAR", !m.state.PromptKeepWorktree), + renderPaletteDeviceChip(styles, "KEEP", m.state.PromptKeepWorktree), + } + body := lipgloss.JoinVertical(lipgloss.Left, + styles.modalTitle.Render(title), + styles.modalBody.Render(repoRoot), + "", + styles.modalHint.Render("BRANCH"), + styles.modalBody.Render(sourceBranch), + "", + nameLabel, + styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)), + "", + deviceLabel, + styles.modalBody.Render(strings.Join(deviceChips, " ")), + "", + worktreeLabel, + styles.modalBody.Render(strings.Join(worktreeChips, " ")), + "", + styles.modalHint.Render(renderPaletteHintLine(styles, minInt(64, maxInt(28, width-18)), m.state.ShowAltHints, + [][][2]string{ + {{"Enter", "create"}, {"Tab", "focus"}, {"n/i", "choose"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + {{"Enter", "create"}, {"n/i", "choose"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + {{"Esc", "back"}, {footerHintToggleKey, "more"}}, + }, + [][][2]string{ + {{"Alt-D", "next"}, {"Alt-Shift-D", "prev"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, + {{"Alt-D", "next"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, + {{"Alt-S", "close"}}, + }, + )), + ) + box := styles.modal.Width(minInt(84, maxInt(40, width-10))).Render(body) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) + } + body := lipgloss.JoinVertical(lipgloss.Left, + styles.modalTitle.Render(title), + styles.modalBody.Render(detail), + "", + styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)), + "", + styles.modalHint.Render(renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints, + [][][2]string{{{"Enter", "save"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, {{"Esc", "back"}, {footerHintToggleKey, "more"}}}, + [][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}}, + )), + ) + box := styles.modal.Width(minInt(72, maxInt(34, width-10))).Render(body) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} + +func (m *paletteModel) renderConfirm(styles paletteStyles, width, height int) string { + agentID := "this agent" + detail := "Remove " + agentID + " and close its tmux window?" + hint := renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints, + [][][2]string{{{"y", "confirm"}, {"Esc", "cancel"}, {footerHintToggleKey, "more"}}, {{"Esc", "cancel"}, {footerHintToggleKey, "more"}}}, + [][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}}, + ) + if m.runtime.record != nil { + agentID = m.runtime.record.ID + detail = "Remove " + agentID + " and close its tmux window?" + if windowID := activeAgentWindowID(m.runtime.record); windowID != "" { + if openTodos, err := countOpenTmuxTodos(todoScopeWindow, windowID); err == nil && openTodos > 0 { + label := "todos" + if openTodos == 1 { + label = "todo" + } + detail = fmt.Sprintf("Close %d open window %s before destroying %s.", openTodos, label, agentID) + hint = renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints, + [][][2]string{{{"Esc", "cancel"}, {footerHintToggleKey, "more"}}}, + [][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}}, + ) + } + } + } + if m.state.ConfirmRequiresText { + detail = detail + " Uncommitted changes detected; type destroy to continue." + hint = renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints, + [][][2]string{{{"Enter", "confirm"}, {"Esc", "cancel"}, {footerHintToggleKey, "more"}}, {{"Esc", "cancel"}, {footerHintToggleKey, "more"}}}, + [][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}}, + ) + } + body := lipgloss.JoinVertical(lipgloss.Left, + styles.modalTitle.Render("Destroy agent"), + styles.modalBody.Render(detail), + func() string { + if !m.state.ConfirmRequiresText { + return "" + } + return styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)) + }(), + "", + styles.modalHint.Render(hint), + ) + box := styles.modal.Width(minInt(72, maxInt(36, width-10))).Render(body) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} + +func (m *paletteModel) renderSnippets(styles paletteStyles, width, height int) string { + snippets := m.filteredSnippets() + if len(snippets) == 0 { + m.state.Selected = 0 + } else { + m.state.Selected = clampInt(m.state.Selected, 0, len(snippets)-1) + } + + title := "Paste Snippet" + header := styles.title.Render(title) + + filterLine := styles.searchBox.Width(width).Render( + lipgloss.JoinHorizontal(lipgloss.Center, + styles.searchPrompt.Render(">"), + " ", + styles.input.Render(renderInputValue(m.state.Filter, m.state.FilterCursor, styles)), + ), + ) + + contentHeight := maxInt(8, height-7) + listWidth := maxInt(34, width*52/100) + previewWidth := maxInt(28, width-listWidth-3) + + list := m.renderSnippetList(styles, snippets, listWidth, contentHeight) + preview := m.renderSnippetPreview(styles, snippets, previewWidth, contentHeight) + body := lipgloss.JoinHorizontal(lipgloss.Top, list, strings.Repeat(" ", 3), preview) + + footer := renderPaletteModeFooter(styles, width, m.state.Message, m.state.ShowAltHints, + [][][2]string{ + {{"Ctrl-U/E", "move"}, {"Ctrl-N/I", "filter"}, {"Enter", "paste"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + {{"Ctrl-U/E", "move"}, {"Enter", "paste"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + {{"Enter", "paste"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + }, + [][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}}, + ) + + view := lipgloss.JoinVertical(lipgloss.Left, header, "", filterLine, "", body, "", footer) + return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view) +} + +func (m *paletteModel) renderSnippetList(styles paletteStyles, snippets []snippet, width, height int) string { + entriesPerPage := maxInt(1, (height-2)/2) + selected := clampInt(m.state.Selected, 0, maxInt(0, len(snippets)-1)) + offset := stableListOffset(m.state.SnippetOffset, selected, entriesPerPage, len(snippets)) + m.state.SnippetOffset = offset + + blocks := []string{styles.meta.Render(fmt.Sprintf("%d snippets", len(snippets))), ""} + if len(snippets) == 0 { + blocks = append(blocks, styles.muted.Width(width).Render("No matching snippets")) + } else { + for row := 0; row < entriesPerPage; row++ { + idx := offset + row + if idx >= len(snippets) { + break + } + snippet := snippets[idx] + selectedBG := lipgloss.Color("238") + titleStyle := styles.itemTitle + subtitleStyle := styles.itemSubtitle + rowStyle := lipgloss.NewStyle().Width(maxInt(16, width-2)) + fillStyle := lipgloss.NewStyle() + varLabelStyle := styles.sectionLabel + if idx == selected { + titleStyle = styles.itemTitle.Background(selectedBG).Foreground(lipgloss.Color("230")) + subtitleStyle = styles.selectedSubtle.Background(selectedBG) + rowStyle = rowStyle.Background(selectedBG).Foreground(lipgloss.Color("230")) + fillStyle = fillStyle.Background(selectedBG).Foreground(lipgloss.Color("230")) + varLabelStyle = styles.selectedLabel.Background(selectedBG) + } + + varLabel := "" + if len(snippet.Vars) > 0 { + varLabel = varLabelStyle.Render(fmt.Sprintf(" %d vars", len(snippet.Vars))) + } + + innerWidth := maxInt(16, width-2) + titleText := truncate(snippet.Name, innerWidth-lipgloss.Width(varLabel)-1) + gapWidth := maxInt(1, innerWidth-lipgloss.Width(titleText)-lipgloss.Width(varLabel)) + + titleRow := rowStyle.Render( + titleStyle.Render(titleText) + + fillStyle.Render(strings.Repeat(" ", gapWidth)) + + varLabel, + ) + desc := snippet.Description + if desc == "" { + desc = truncate(snippet.Content, 40) + } + subtitleRow := rowStyle.Render(fillStyle.Render(" ") + subtitleStyle.Render(truncate(desc, innerWidth-2))) + block := lipgloss.JoinVertical(lipgloss.Left, titleRow, subtitleRow) + blocks = append(blocks, styles.item.Width(width).Render(block)) + } + } + + content := strings.Join(blocks, "\n") + return lipgloss.NewStyle().Width(width).Height(height).Render(content) +} + +func (m *paletteModel) renderSnippetPreview(styles paletteStyles, snippets []snippet, width, height int) string { + lines := []string{} + if len(snippets) > 0 && m.state.Selected >= 0 && m.state.Selected < len(snippets) { + snippet := snippets[m.state.Selected] + lines = append(lines, styles.panelTitle.Render("Preview")) + lines = append(lines, styles.title.Render(snippet.Name)) + if snippet.Description != "" { + lines = append(lines, styles.muted.Render(snippet.Description)) + } + lines = append(lines, "") + if len(snippet.Vars) > 0 { + chips := []string{} + for _, v := range snippet.Vars { + chips = append(chips, styles.keyword.Render("{{"+v+"}}")) + } + lines = append(lines, "Variables: "+strings.Join(chips, " ")) + lines = append(lines, "") + } + lines = append(lines, styles.panelTitle.Render("Content")) + for _, l := range wrapText(snippet.Content, maxInt(10, width-2)) { + lines = append(lines, styles.panelText.Render(truncate(l, width))) + } + } + content := strings.Join(lines, "\n") + return lipgloss.NewStyle().Width(width).Height(height).Render(content) +} + +func (m *paletteModel) renderSnippetVars(styles paletteStyles, width, height int) string { + varName := m.state.SnippetVars[m.state.SnippetVarIndex] + progress := fmt.Sprintf("(%d/%d)", m.state.SnippetVarIndex+1, len(m.state.SnippetVars)) + title := fmt.Sprintf("Enter %s %s", varName, progress) + + body := lipgloss.JoinVertical(lipgloss.Left, + styles.modalTitle.Render(title), + styles.modalBody.Render("Value for {{"+varName+"}}"), + "", + styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)), + "", + styles.modalHint.Render(renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints, + [][][2]string{{{"Enter", "continue"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, {{"Esc", "back"}, {footerHintToggleKey, "more"}}}, + [][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}}, + )), + ) + box := styles.modal.Width(minInt(72, maxInt(34, width-10))).Render(body) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} + +func (m *paletteModel) filteredActions() []paletteAction { + query := strings.ToLower(strings.TrimSpace(string(m.state.Filter))) + if query == "" { + return m.actions + } + parts := strings.Fields(query) + filtered := make([]paletteAction, 0, len(m.actions)) + for _, action := range m.actions { + haystack := strings.ToLower(action.Title + " " + action.Subtitle + " " + strings.Join(action.Keywords, " ")) + matched := true + for _, part := range parts { + if !strings.Contains(haystack, part) { + matched = false + break + } + } + if matched { + filtered = append(filtered, action) + } + } + return filtered +} + +func newPaletteStyles() paletteStyles { + return paletteStyles{ + title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")), + meta: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + searchBox: lipgloss.NewStyle().Background(lipgloss.Color("236")).Padding(0, 1), + searchPrompt: lipgloss.NewStyle().Foreground(lipgloss.Color("223")).Bold(true), + input: lipgloss.NewStyle().Foreground(lipgloss.Color("252")), + inputCursor: lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("223")).Bold(true), + item: lipgloss.NewStyle().Padding(0, 1).MarginBottom(1), + selectedItem: lipgloss.NewStyle().Padding(0, 1).MarginBottom(1).Background(lipgloss.Color("238")).Foreground(lipgloss.Color("230")), + sectionLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("180")).Bold(true), + selectedLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("223")).Bold(true), + itemTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")), + itemSubtitle: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + selectedSubtle: lipgloss.NewStyle().Foreground(lipgloss.Color("251")), + panelTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("223")), + panelText: lipgloss.NewStyle().Foreground(lipgloss.Color("252")), + muted: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + footer: lipgloss.NewStyle().Foreground(lipgloss.Color("216")), + keyword: lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Background(lipgloss.Color("237")).Padding(0, 1), + modal: lipgloss.NewStyle().Border(paletteModalBorder).BorderForeground(lipgloss.Color("223")).Padding(1, 2), + modalTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")), + modalBody: lipgloss.NewStyle().Foreground(lipgloss.Color("252")), + modalHint: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + statusBad: lipgloss.NewStyle().Foreground(lipgloss.Color("203")), + statLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + statValue: lipgloss.NewStyle().Foreground(lipgloss.Color("252")), + todoCheck: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + todoCheckDone: lipgloss.NewStyle().Foreground(lipgloss.Color("150")), + panelTextDone: lipgloss.NewStyle().Foreground(lipgloss.Color("246")), + shortcutKey: lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("223")).Padding(0, 1).Bold(true), + shortcutText: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + } +} + +func renderInputValue(text []rune, cursor int, styles paletteStyles) string { + if cursor < 0 { + cursor = 0 + } + if cursor > len(text) { + cursor = len(text) + } + left := string(text[:cursor]) + right := string(text[cursor:]) + cursorChar := " " + if cursor < len(text) { + cursorChar = string(text[cursor]) + right = string(text[cursor+1:]) + } + if len(text) == 0 && cursor == 0 { + cursorChar = " " + } + return left + styles.inputCursor.Render(cursorChar) + right +} + +func applyPaletteInputKey(key string, text *[]rune, cursor *int, allowEnter bool) bool { + if text == nil || cursor == nil { + return false + } + switch key { + case "left": + *cursor = clampInt(*cursor-1, 0, len(*text)) + return true + case "right": + *cursor = clampInt(*cursor+1, 0, len(*text)) + return true + case "backspace", "ctrl+h": + if *cursor > 0 { + *text = append((*text)[:*cursor-1], (*text)[*cursor:]...) + *cursor-- + } + return true + case "delete": + if *cursor < len(*text) { + *text = append((*text)[:*cursor], (*text)[*cursor+1:]...) + } + return true + case "ctrl+a", "home": + *cursor = 0 + return true + case "ctrl+e", "end": + *cursor = len(*text) + return true + case "ctrl+u": + *text = (*text)[*cursor:] + *cursor = 0 + return true + case "ctrl+w": + start := previousWordBoundary(*text, *cursor) + *text = append((*text)[:start], (*text)[*cursor:]...) + *cursor = start + return true + case "enter": + return allowEnter + } + r, ok := paletteRuneFromKey(key) + if !ok { + return false + } + *text = append((*text)[:*cursor], append([]rune{r}, (*text)[*cursor:]...)...) + *cursor++ + return true +} + +func paletteRuneFromKey(key string) (rune, bool) { + if key == "space" { + return ' ', true + } + runes := []rune(key) + if len(runes) == 1 { + return runes[0], true + } + return 0, false +} + +func renderVerticalDivider(height int) string { + lines := make([]string, maxInt(1, height)) + for i := range lines { + lines[i] = "│" + } + return strings.Join(lines, "\n") +} + +func renderPaletteStat(styles paletteStyles, label, value string, width int, labelWidth int) string { + parts := wrapText(value, maxInt(10, width-labelWidth-3)) + if len(parts) == 0 { + parts = []string{"-"} + } + lines := []string{styles.statLabel.Width(labelWidth).Render(label+":") + " " + styles.statValue.Render(parts[0])} + for _, part := range parts[1:] { + lines = append(lines, strings.Repeat(" ", labelWidth+1)+styles.statValue.Render(part)) + } + return strings.Join(lines, "\n") +} + +func renderPaletteModeFooter(styles paletteStyles, width int, message string, showAltHints bool, normalCandidates [][][2]string, altCandidates [][][2]string) string { + message = strings.TrimSpace(message) + if message != "" { + style := styles.footer + lower := strings.ToLower(message) + if strings.Contains(lower, "error") || strings.Contains(lower, "required") || strings.Contains(lower, "unknown") { + style = styles.statusBad + } + return style.Width(width).Render(truncate(message, width)) + } + renderSegments := func(pairs [][2]string) string { + return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs) + } + candidates := normalCandidates + if showAltHints { + candidates = altCandidates + } + footer := pickRenderedShortcutFooter(width, renderSegments, candidates...) + return lipgloss.NewStyle().Width(width).Render(footer) +} + +func renderPaletteFooter(styles paletteStyles, width int, message string, showAltHints bool) string { + return renderPaletteModeFooter(styles, width, message, showAltHints, + [][][2]string{ + {{"Ctrl-U/E", "move"}, {"Ctrl-N/I", "filter"}, {"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + {{"Ctrl-U/E", "move"}, {"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + {{"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"}}, + }, + ) +} + +func renderPaletteHintLine(styles paletteStyles, width int, showAltHints bool, normalCandidates [][][2]string, altCandidates [][][2]string) string { + return pickRenderedShortcutFooter(width, func(pairs [][2]string) string { + return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs) + }, func() [][][2]string { + if showAltHints { + return altCandidates + } + return normalCandidates + }()...) +} + +func paletteTmuxTodoPreviewItems(items []tmuxTodoItem) []paletteTodoPreviewItem { + rows := make([]paletteTodoPreviewItem, 0, len(items)) + for _, item := range items { + title := firstPaletteLine(item.Title) + if title == "" || item.Done { + continue + } + rows = append(rows, paletteTodoPreviewItem{Title: title, Done: item.Done}) + } + return rows +} + +func paletteAgentTodoPreviewItems(todos []todoItem) []paletteTodoPreviewItem { + rows := make([]paletteTodoPreviewItem, 0, len(todos)) + for _, todo := range todos { + title := firstPaletteLine(todo.Title) + if title == "" || todo.Done { + continue + } + rows = append(rows, paletteTodoPreviewItem{Title: title, Done: todo.Done}) + } + return rows +} + +func renderPaletteTodoPreviewSection(styles paletteStyles, section paletteTodoPreviewSection, width int, previewLimit int) []string { + lines := []string{styles.statLabel.Render(section.Title)} + if section.Lead != "" { + lines = append(lines, renderPalettePreviewValue(styles, section.Lead, width, 2)...) + } + if len(section.Items) == 0 { + if section.Lead == "" { + lines = append(lines, styles.muted.Render(" "+section.Empty)) + } + return lines + } + limit := clampInt(previewLimit, 1, len(section.Items)) + for _, item := range section.Items[:limit] { + lines = append(lines, renderPaletteTodoPreviewItem(styles, item, width, 2)...) + } + hidden := len(section.Items) - limit + if hidden > 0 { + lines = append(lines, styles.muted.Render(fmt.Sprintf(" +%d more", hidden))) + } + return lines +} + +func renderPaletteTodoPreviewItem(styles paletteStyles, item paletteTodoPreviewItem, width int, indent int) []string { + title := strings.TrimSpace(item.Title) + if title == "" { + return nil + } + check := "○" + checkStyle := styles.todoCheck + textStyle := styles.panelText + if item.Done { + check = "●" + checkStyle = styles.todoCheckDone + textStyle = styles.panelTextDone + } + indentPrefix := strings.Repeat(" ", maxInt(0, indent)) + textPrefix := indentPrefix + check + " " + available := maxInt(10, width-lipgloss.Width(textPrefix)) + parts := wrapText(title, available) + if len(parts) == 0 { + parts = []string{title} + } + lines := []string{indentPrefix + checkStyle.Render(check) + " " + textStyle.Render(truncate(parts[0], available))} + continuationPrefix := strings.Repeat(" ", lipgloss.Width(textPrefix)) + for _, part := range parts[1:] { + lines = append(lines, continuationPrefix+textStyle.Render(truncate(part, available))) + } + return lines +} + +func renderPalettePreviewValue(styles paletteStyles, value string, width int, indent int) []string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + prefix := strings.Repeat(" ", maxInt(0, indent)) + available := maxInt(10, width-len([]rune(prefix))) + parts := wrapText(value, available) + if len(parts) == 0 { + parts = []string{value} + } + lines := make([]string, 0, len(parts)) + for _, part := range parts { + lines = append(lines, prefix+styles.panelText.Render(truncate(part, available))) + } + return lines +} + +func renderPaletteDeviceChip(styles paletteStyles, deviceID string, active bool) string { + chipStyle := styles.keyword + if active { + chipStyle = styles.keyword.Copy().Foreground(lipgloss.Color("223")).Background(lipgloss.Color("238")).Bold(true) + } + label := deviceID + if isPaletteNoDeviceOption(deviceID) { + label = "NONE" + } + return chipStyle.Render(label) +} + +func firstPaletteLine(value string) string { + parts := strings.Split(strings.TrimSpace(value), "\n") + if len(parts) == 0 { + return "" + } + return strings.TrimSpace(parts[0]) +} + +func paletteMessageForError(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func paletteSuccessMessage(err error, success string) string { + if err != nil { + return "" + } + return success +} + +func filepathBaseOrFull(path string) string { + base := strings.TrimSpace(filepath.Base(path)) + if base == "" || base == "." || base == string(filepath.Separator) { + return path + } + return base +} + +func clampInt(value, low, high int) int { + if high < low { + return low + } + if value < low { + return low + } + if value > high { + return high + } + return value +} diff --git a/agent-tracker/cmd/agent/palette_dashboard_test.go b/agent-tracker/cmd/agent/palette_dashboard_test.go new file mode 100644 index 0000000..2d79caa --- /dev/null +++ b/agent-tracker/cmd/agent/palette_dashboard_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "os" + "strings" + "testing" + "time" +) + +func compactPaletteWhitespace(value string) string { + return strings.Join(strings.Fields(value), " ") +} + +func TestPaletteViewShowsStatusDashboardWithoutSelectedPreview(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Global: []tmuxTodoItem{{Title: "global todo", CreatedAt: time.Now()}}, + Windows: map[string][]tmuxTodoItem{ + "@9": { + {Title: "window todo 1", CreatedAt: time.Now()}, + {Title: "window todo 2", Done: true, CreatedAt: time.Now()}, + }, + }, + }) + + model := newPaletteModel(&paletteRuntime{ + windowID: "@9", + currentSessionName: "dev", + currentWindowName: "agent", + currentPath: "/tmp/repo", + mainRepoRoot: "/tmp/repo", + }, paletteUIState{Mode: paletteModeList}) + model.width = 96 + model.height = 28 + + view := model.View() + compact := compactPaletteWhitespace(view) + if !strings.Contains(compact, "Tracker Status") { + t.Fatalf("expected tracker status section in view: %q", view) + } + if !strings.Contains(compact, "Todo Preview") { + t.Fatalf("expected todo preview section in view: %q", view) + } + if !strings.Contains(compact, "No active agent") { + t.Fatalf("expected no active agent status in view: %q", view) + } + if !strings.Contains(compact, "window todo 1") || !strings.Contains(compact, "global todo") { + t.Fatalf("expected todo previews in view: %q", view) + } + if strings.Contains(view, "[ ]") || strings.Contains(view, "[x]") { + t.Fatalf("expected todo panel checkbox visuals in view: %q", view) + } + if !strings.Contains(view, "○") { + t.Fatalf("expected open todo checkbox visual in view: %q", view) + } + if strings.Contains(compact, "window todo 2") { + t.Fatalf("expected completed window todo to stay out of preview when open work exists: %q", view) + } + if strings.Contains(compact, "Selected") { + t.Fatalf("unexpected selected-action preview in view: %q", view) + } + if strings.Contains(compact, "Current Task") || strings.Contains(compact, "Next Todos") { + t.Fatalf("unexpected legacy agent preview content in view: %q", view) + } +} + +func TestPaletteViewShowsBootstrapAndDashboardTodoStatus(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Global: []tmuxTodoItem{{Title: "global todo", CreatedAt: time.Now()}}, + Windows: map[string][]tmuxTodoItem{ + "@4": {{Title: "window todo", CreatedAt: time.Now()}}, + }, + }) + + workspaceRoot := t.TempDir() + if err := os.MkdirAll(bootstrapStateDirPath(workspaceRoot), 0o755); err != nil { + t.Fatalf("mkdir bootstrap dir: %v", err) + } + if err := os.WriteFile(bootstrapGitReadyPath(workspaceRoot), []byte(time.Now().Format(time.RFC3339Nano)), 0o644); err != nil { + t.Fatalf("write git-ready marker: %v", err) + } + if err := os.WriteFile(bootstrapPIDPath(workspaceRoot), []byte("1\n"), 0o644); err != nil { + t.Fatalf("write pid marker: %v", err) + } + + model := newPaletteModel(&paletteRuntime{ + windowID: "@4", + currentSessionName: "dev", + currentWindowName: "agent-4", + mainRepoRoot: "/tmp/repo", + record: &agentRecord{ + ID: "icon-pad", + Branch: "feature/icon-pad", + WorkspaceRoot: workspaceRoot, + Dashboard: dashboardDoc{ + CurrentTask: "Ship status dashboard", + Todos: []todoItem{ + {Title: "wire dashboard"}, + {Title: "verify tests", Done: true}, + }, + }, + }, + }, paletteUIState{Mode: paletteModeList}) + model.width = 104 + model.height = 28 + + view := model.View() + compact := compactPaletteWhitespace(view) + if !strings.Contains(compact, "copying repo") { + t.Fatalf("expected bootstrap status in view: %q", view) + } + if !strings.Contains(compact, "icon-pad on feature/icon-pad") { + t.Fatalf("expected agent summary in view: %q", view) + } + if !strings.Contains(compact, "Todo Preview") { + t.Fatalf("expected todo preview section in view: %q", view) + } + if !strings.Contains(compact, "Ship status dashboard") { + t.Fatalf("expected current task summary in view: %q", view) + } + if !strings.Contains(compact, "wire dashboard") || !strings.Contains(compact, "window todo") || !strings.Contains(compact, "global todo") { + t.Fatalf("expected todo previews in view: %q", view) + } + if strings.Contains(view, "[ ]") || strings.Contains(view, "[x]") { + t.Fatalf("expected todo panel checkbox visuals in view: %q", view) + } + if !strings.Contains(view, "○") { + t.Fatalf("expected open todo checkbox visual in view: %q", view) + } + if strings.Contains(compact, "verify tests") { + t.Fatalf("expected completed dashboard todo to stay out of preview when open work exists: %q", view) + } + if strings.Contains(compact, "Selected") { + t.Fatalf("unexpected selected-action preview in view: %q", view) + } +} + +func TestPaletteTodoPreviewItemsHideCompletedTodos(t *testing.T) { + tmuxItems := paletteTmuxTodoPreviewItems([]tmuxTodoItem{ + {Title: "done one", Done: true}, + {Title: "open one"}, + {Title: "done two", Done: true}, + }) + if len(tmuxItems) != 1 || tmuxItems[0].Title != "open one" { + t.Fatalf("expected only open tmux todo in preview, got %#v", tmuxItems) + } + + tmuxDoneOnly := paletteTmuxTodoPreviewItems([]tmuxTodoItem{{Title: "done only", Done: true}}) + if len(tmuxDoneOnly) != 0 { + t.Fatalf("expected completed tmux todos to stay out of preview, got %#v", tmuxDoneOnly) + } + + agentItems := paletteAgentTodoPreviewItems([]todoItem{ + {Title: "done agent", Done: true}, + {Title: "open agent"}, + }) + if len(agentItems) != 1 || agentItems[0].Title != "open agent" { + t.Fatalf("expected only open dashboard todo in preview, got %#v", agentItems) + } + + agentDoneOnly := paletteAgentTodoPreviewItems([]todoItem{{Title: "done only", Done: true}}) + if len(agentDoneOnly) != 0 { + t.Fatalf("expected completed dashboard todos to stay out of preview, got %#v", agentDoneOnly) + } +} + +func TestPaletteBootstrapStatusSummaries(t *testing.T) { + if got := paletteBootstrapStatus(nil); got != "No active agent" { + t.Fatalf("expected no active agent, got %q", got) + } + + workspaceRoot := t.TempDir() + record := &agentRecord{WorkspaceRoot: workspaceRoot} + if err := os.MkdirAll(bootstrapStateDirPath(workspaceRoot), 0o755); err != nil { + t.Fatalf("mkdir bootstrap dir: %v", err) + } + if err := os.WriteFile(bootstrapFailedPath(workspaceRoot), []byte("clone failed\nextra\n"), 0o644); err != nil { + t.Fatalf("write failed marker: %v", err) + } + if got := paletteBootstrapStatus(record); got != "failed: clone failed" { + t.Fatalf("expected failure summary, got %q", got) + } + if err := os.Remove(bootstrapFailedPath(workspaceRoot)); err != nil { + t.Fatalf("remove failed marker: %v", err) + } + if err := os.WriteFile(bootstrapRepoReadyPath(workspaceRoot), []byte(time.Now().Format(time.RFC3339Nano)), 0o644); err != nil { + t.Fatalf("write repo-ready marker: %v", err) + } + if got := paletteBootstrapStatus(record); got != "ready" { + t.Fatalf("expected ready summary, got %q", got) + } +} diff --git a/agent-tracker/cmd/agent/palette_debug_test.go b/agent-tracker/cmd/agent/palette_debug_test.go new file mode 100644 index 0000000..e579d6e --- /dev/null +++ b/agent-tracker/cmd/agent/palette_debug_test.go @@ -0,0 +1,19 @@ +package main + +import "testing" + +func TestPaletteBuildActionsIncludesDestroyForAgentRecord(t *testing.T) { + r := &paletteRuntime{ + agentID: "memory-hack", + mainRepoRoot: "/tmp/repo", + currentPath: "/tmp/repo/.agents/memory-hack/repo", + record: &agentRecord{ID: "memory-hack", RepoRoot: "/tmp/repo"}, + } + actions := r.buildActions() + for _, action := range actions { + if action.Kind == paletteActionConfirmDestroy { + return + } + } + t.Fatalf("destroy action missing: %#v", actions) +} diff --git a/agent-tracker/cmd/agent/palette_path_fallback_test.go b/agent-tracker/cmd/agent/palette_path_fallback_test.go new file mode 100644 index 0000000..6941d9d --- /dev/null +++ b/agent-tracker/cmd/agent/palette_path_fallback_test.go @@ -0,0 +1,35 @@ +package main + +import "testing" + +func TestDetectPaletteAgentIDFromPath(t *testing.T) { + got := detectPaletteAgentIDFromPath("/Users/david/Github/instaboard/.agents/icon-pad/repo") + if got != "icon-pad" { + t.Fatalf("expected icon-pad, got %q", got) + } +} + +func TestPaletteBuildActionsIncludesDestroyWhenAgentIDKnownWithoutRecord(t *testing.T) { + r := &paletteRuntime{ + agentID: "icon-pad", + mainRepoRoot: "/tmp/repo", + currentPath: "/tmp/repo/.agents/icon-pad/repo", + } + actions := r.buildActions() + for _, action := range actions { + if action.Kind == paletteActionConfirmDestroy { + return + } + } + t.Fatalf("destroy action missing: %#v", actions) +} + +func TestPaletteEffectiveAgentIDIgnoresLiteralAndUsesPath(t *testing.T) { + r := &paletteRuntime{ + agentID: "#{q:@agent_id}", + currentPath: "/tmp/repo/.agents/icon-pad/repo", + } + if got := r.effectiveAgentID(); got != "icon-pad" { + t.Fatalf("expected icon-pad, got %q", got) + } +} diff --git a/agent-tracker/cmd/agent/palette_shortcuts_test.go b/agent-tracker/cmd/agent/palette_shortcuts_test.go new file mode 100644 index 0000000..4afb27c --- /dev/null +++ b/agent-tracker/cmd/agent/palette_shortcuts_test.go @@ -0,0 +1,460 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func setupPromptRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, ".agent.yaml"), []byte("base_branch: main\n"), 0o644); err != nil { + t.Fatalf("write .agent.yaml: %v", err) + } + return repo +} + +func TestPaletteAltCOpensStartAgentPrompt(t *testing.T) { + repo := setupPromptRepo(t) + model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList}) + + updated, cmd := model.updateList("alt+c") + if cmd != nil { + t.Fatalf("expected no command when opening start prompt, got %v", cmd) + } + + palette, ok := updated.(*paletteModel) + if !ok { + t.Fatalf("expected palette model, got %T", updated) + } + if palette.state.Mode != paletteModePrompt { + t.Fatalf("expected prompt mode, got %v", palette.state.Mode) + } + if palette.state.PromptKind != palettePromptStartAgent { + t.Fatalf("expected start-agent prompt, got %v", palette.state.PromptKind) + } + if palette.state.PromptRepoRoot != repo { + t.Fatalf("expected prompt repo root %q, got %q", repo, palette.state.PromptRepoRoot) + } +} + +func TestPaletteStartPromptAddsNoDeviceOptionForFlutterRepo(t *testing.T) { + repo := setupPromptRepo(t) + if err := os.WriteFile(filepath.Join(repo, "pubspec.yaml"), []byte("name: demo\n"), 0o644); err != nil { + t.Fatalf("write pubspec: %v", err) + } + model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList}) + model.openPrompt(palettePromptStartAgent, "feature-x", repo) + + if len(model.state.PromptDevices) < 2 { + t.Fatalf("expected flutter prompt devices to include no-device option and launch devices, got %v", model.state.PromptDevices) + } + if model.state.PromptDevices[0] != paletteNoDeviceOption { + t.Fatalf("expected first prompt device to be no-device option, got %q", model.state.PromptDevices[0]) + } + if model.state.PromptDeviceIndex != 1 { + t.Fatalf("expected default device selection to stay on %q, got index %d", defaultManagedDeviceID, model.state.PromptDeviceIndex) + } +} + +func TestPaletteStartPromptDeviceSelectionUsesNI(t *testing.T) { + repo := setupPromptRepo(t) + model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList}) + model.openPrompt(palettePromptStartAgent, "feature-x", repo) + model.state.PromptField = palettePromptFieldDevice + model.state.PromptDevices = []string{"ios", "android", "web-server"} + model.state.PromptDeviceIndex = 1 + + updated, _ := model.updatePrompt("n") + palette := updated.(*paletteModel) + if palette.state.PromptDeviceIndex != 0 { + t.Fatalf("expected n to move device selection left, got %d", palette.state.PromptDeviceIndex) + } + + updated, _ = palette.updatePrompt("i") + palette = updated.(*paletteModel) + if palette.state.PromptDeviceIndex != 1 { + t.Fatalf("expected i to move device selection right, got %d", palette.state.PromptDeviceIndex) + } + + updated, _ = palette.updatePrompt("e") + palette = updated.(*paletteModel) + if palette.state.PromptDeviceIndex != 1 { + t.Fatalf("expected e to leave device selection unchanged, got %d", palette.state.PromptDeviceIndex) + } +} + +func TestPaletteStartPromptAltDTogglesDevices(t *testing.T) { + repo := setupPromptRepo(t) + model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList}) + model.openPrompt(palettePromptStartAgent, "feature-x", repo) + model.state.PromptDevices = []string{"ios", "android", "web-server"} + model.state.PromptDeviceIndex = 1 + model.state.PromptField = palettePromptFieldName + + updated, _ := model.updatePrompt("alt+d") + palette := updated.(*paletteModel) + if palette.state.PromptField != palettePromptFieldName { + t.Fatalf("expected alt+d to keep the current focus field, got %v", palette.state.PromptField) + } + if palette.state.PromptDeviceIndex != 2 { + t.Fatalf("expected alt+d to advance to next device, got %d", palette.state.PromptDeviceIndex) + } + + updated, _ = palette.updatePrompt("alt+d") + palette = updated.(*paletteModel) + if palette.state.PromptDeviceIndex != 0 { + t.Fatalf("expected alt+d to wrap device selection, got %d", palette.state.PromptDeviceIndex) + } + + updated, _ = palette.updatePrompt("alt+D") + palette = updated.(*paletteModel) + if palette.state.PromptDeviceIndex != 2 { + t.Fatalf("expected alt+shift+d to move device selection backward, got %d", palette.state.PromptDeviceIndex) + } +} + +func TestRenderPaletteFooterTogglesAltHints(t *testing.T) { + styles := newPaletteStyles() + defaultFooter := renderPaletteFooter(styles, 140, "", false) + if !strings.Contains(defaultFooter, "more") { + t.Fatalf("expected default footer to advertise more options, got %q", defaultFooter) + } + if !strings.Contains(defaultFooter, footerHintToggleKey) { + t.Fatalf("expected default footer to show %q toggle, got %q", footerHintToggleKey, defaultFooter) + } + if strings.Contains(defaultFooter, "create") { + t.Fatalf("expected default footer to hide alt shortcuts, got %q", defaultFooter) + } + + altFooter := renderPaletteFooter(styles, 140, "", true) + if !strings.Contains(altFooter, "create") || !strings.Contains(altFooter, "tracker") { + t.Fatalf("expected alt footer to show alt shortcuts, got %q", altFooter) + } + if strings.Contains(altFooter, "filter") || strings.Contains(altFooter, "Enter") { + t.Fatalf("expected alt footer to hide default shortcuts, got %q", altFooter) + } +} + +func TestPalettePromptAltToggleShowsAltHints(t *testing.T) { + repo := setupPromptRepo(t) + if err := os.WriteFile(filepath.Join(repo, "pubspec.yaml"), []byte("name: demo\n"), 0o644); err != nil { + t.Fatalf("write pubspec: %v", err) + } + model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList}) + model.openPrompt(palettePromptStartAgent, "feature-x", repo) + + defaultView := model.renderPrompt(newPaletteStyles(), 100, 24) + if !strings.Contains(defaultView, footerHintToggleKey) || !strings.Contains(defaultView, "more") { + t.Fatalf("expected default prompt to advertise alt options, got %q", defaultView) + } + if strings.Contains(defaultView, "Alt-D") { + t.Fatalf("expected default prompt to hide alt device shortcuts, got %q", defaultView) + } + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + palette := updated.(*paletteModel) + altView := palette.renderPrompt(newPaletteStyles(), 100, 24) + if !strings.Contains(altView, "Alt-D") || !strings.Contains(altView, "Alt-S") { + t.Fatalf("expected alt prompt view to show alt shortcuts, got %q", altView) + } + if strings.Contains(altView, "Tab") || strings.Contains(altView, "n/i") { + t.Fatalf("expected alt prompt view to hide default shortcuts, got %q", altView) + } + + updated, _ = palette.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + palette = updated.(*paletteModel) + if palette.state.ShowAltHints { + t.Fatalf("expected normal keypress to hide alt hints") + } +} + +func TestPaletteStartPromptWorktreeToggleUsesNI(t *testing.T) { + repo := setupPromptRepo(t) + model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList}) + model.openPrompt(palettePromptStartAgent, "feature-x", repo) + model.state.PromptField = palettePromptFieldWorktree + + updated, _ := model.updatePrompt("i") + palette := updated.(*paletteModel) + if !palette.state.PromptKeepWorktree { + t.Fatalf("expected i to switch worktree mode to keep") + } + + updated, _ = palette.updatePrompt("n") + palette = updated.(*paletteModel) + if palette.state.PromptKeepWorktree { + t.Fatalf("expected n to switch worktree mode back to clear") + } +} + +func TestPaletteStartPromptEnterCarriesKeepWorktree(t *testing.T) { + repo := setupPromptRepo(t) + model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList}) + model.openPrompt(palettePromptStartAgent, "feature-x", repo) + model.state.PromptKeepWorktree = true + + updated, cmd := model.updatePrompt("enter") + if cmd == nil { + t.Fatalf("expected enter to quit palette for action execution") + } + palette := updated.(*paletteModel) + if !palette.result.KeepWorktree { + t.Fatalf("expected start-agent result to preserve keep-worktree selection") + } +} + +func TestPaletteStartPromptHidesCreateOptionsWithoutRepo(t *testing.T) { + model := newPaletteModel(&paletteRuntime{}, paletteUIState{Mode: paletteModeList}) + model.openPrompt(palettePromptStartAgent, "feature-x", "") + view := model.renderPrompt(newPaletteStyles(), 80, 20) + + if !strings.Contains(view, "Main repo not found") { + t.Fatalf("expected missing repo message in prompt: %q", view) + } + for _, unwanted := range []string{"BRANCH", "DEVICE", "WORKTREE", "Enter create", "CLEAR", "KEEP"} { + if strings.Contains(view, unwanted) { + t.Fatalf("did not expect %q in missing-repo prompt: %q", unwanted, view) + } + } + + updated, cmd := model.updatePrompt("enter") + if cmd != nil { + t.Fatalf("expected no command when repo is missing, got %v", cmd) + } + palette := updated.(*paletteModel) + if palette.state.Mode != paletteModePrompt { + t.Fatalf("expected prompt to remain open when repo is missing, got %v", palette.state.Mode) + } +} + +func TestRenderPaletteDeviceChipKeepsPaddingWhenActive(t *testing.T) { + styles := newPaletteStyles() + inactive := renderPaletteDeviceChip(styles, "ios", false) + active := renderPaletteDeviceChip(styles, "ios", true) + + if lipgloss.Width(active) != lipgloss.Width(inactive) { + t.Fatalf("expected active chip width %d to match inactive chip width %d", lipgloss.Width(active), lipgloss.Width(inactive)) + } +} + +func TestBuildAgentStartArgsIncludesKeepWorktree(t *testing.T) { + got := buildAgentStartArgs("feature-x", "ios", true) + want := []string{"start", "--keep-worktree", "-d", "ios", "feature-x"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected args %v, got %v", want, got) + } + + got = buildAgentStartArgs("feature-x", "", false) + want = []string{"start", "feature-x"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected args %v, got %v", want, got) + } + + got = buildAgentStartArgs("feature-x", paletteNoDeviceOption, false) + want = []string{"start", "--no-device", "feature-x"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected args %v, got %v", want, got) + } +} + +func TestPaletteDestroyQueuesAndClosesImmediately(t *testing.T) { + prev := paletteDestroyLauncher + t.Cleanup(func() { paletteDestroyLauncher = prev }) + + calledWith := "" + calledConfirm := "" + paletteDestroyLauncher = func(agentID string, confirmText string) error { + calledWith = agentID + calledConfirm = confirmText + return nil + } + + runtime := &paletteRuntime{agentID: "feature-x"} + reopen, message, err := runtime.execute(paletteResult{Action: paletteAction{Kind: paletteActionConfirmDestroy}}) + if err != nil { + t.Fatalf("execute destroy: %v", err) + } + if reopen { + t.Fatalf("expected destroy action to close the palette immediately") + } + if message != "" { + t.Fatalf("expected no destroy message, got %q", message) + } + if calledWith != "feature-x" { + t.Fatalf("expected destroy launcher to receive feature-x, got %q", calledWith) + } + if calledConfirm != "" { + t.Fatalf("expected empty destroy confirm text, got %q", calledConfirm) + } +} + +func TestPaletteDestroyRequiresTypedDestroyForDirtyRepo(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + registryDir := filepath.Join(home, ".config", "agent-tracker", "run") + if err := os.MkdirAll(registryDir, 0o755); err != nil { + t.Fatalf("mkdir registry dir: %v", err) + } + repo := filepath.Join(home, "repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + initTestGitRepo(t, repo) + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty repo file: %v", err) + } + reg := ®istry{Agents: map[string]*agentRecord{ + "feature-x": {ID: "feature-x", RepoCopyPath: repo}, + }} + if err := saveRegistry(reg); err != nil { + t.Fatalf("save registry: %v", err) + } + + model := newPaletteModel(&paletteRuntime{agentID: "feature-x", record: &agentRecord{ID: "feature-x", RepoCopyPath: repo}}, paletteUIState{Mode: paletteModeList}) + updated, cmd := model.selectAction(paletteAction{Kind: paletteActionConfirmDestroy}) + if cmd != nil { + t.Fatalf("expected no quit when opening destroy confirm, got %v", cmd) + } + palette := updated.(*paletteModel) + if !palette.state.ConfirmRequiresText { + t.Fatal("expected typed destroy confirmation for dirty repo") + } + + updated, cmd = palette.updateConfirm("enter") + if cmd != nil { + t.Fatalf("expected no quit for missing typed confirmation, got %v", cmd) + } + palette = updated.(*paletteModel) + if palette.state.Message != "Type destroy to confirm" { + t.Fatalf("expected typed destroy message, got %q", palette.state.Message) + } + + for _, key := range []string{"d", "e", "s", "t", "r", "o", "y"} { + updated, _ = palette.updateConfirm(key) + palette = updated.(*paletteModel) + } + updated, cmd = palette.updateConfirm("enter") + if cmd == nil { + t.Fatal("expected quit after typed destroy confirmation") + } + palette = updated.(*paletteModel) + if palette.result.Input != "destroy" { + t.Fatalf("expected destroy confirm input, got %q", palette.result.Input) + } +} + +func TestPaletteBuildActionsIncludesMemoryToggle(t *testing.T) { + prevOutput := paletteTmuxOutput + t.Cleanup(func() { paletteTmuxOutput = prevOutput }) + + paletteTmuxOutput = func(args ...string) (string, error) { + return "TMUX_STATUS_MEMORY=0\n", nil + } + + actions := (&paletteRuntime{mainRepoRoot: "/tmp/repo"}).buildActions() + for _, action := range actions { + if action.Kind != paletteActionToggleMemoryDisplay { + continue + } + if action.Title != "Show memory display" { + t.Fatalf("expected show title, got %q", action.Title) + } + return + } + t.Fatalf("memory toggle action missing: %#v", actions) +} + +func TestPaletteToggleMemoryDisplayTurnsItOffAndRefreshesTmux(t *testing.T) { + prevOutput := paletteTmuxOutput + prevRunner := paletteTmuxRunner + t.Cleanup(func() { + paletteTmuxOutput = prevOutput + paletteTmuxRunner = prevRunner + }) + + paletteTmuxOutput = func(args ...string) (string, error) { + return "TMUX_STATUS_MEMORY=1\n", nil + } + var calls []string + paletteTmuxRunner = func(args ...string) error { + calls = append(calls, strings.Join(args, " ")) + return nil + } + + runtime := &paletteRuntime{} + _, _, err := runtime.execute(paletteResult{Action: paletteAction{Kind: paletteActionToggleMemoryDisplay}}) + if err != nil { + t.Fatalf("execute memory toggle: %v", err) + } + want := []string{ + "set-environment -g TMUX_STATUS_MEMORY 0", + "refresh-client -S", + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("expected calls %v, got %v", want, calls) + } +} + +func TestPaletteIgnoresImmediateAltSCloseOnOpen(t *testing.T) { + model := newPaletteModel(&paletteRuntime{}, paletteUIState{Mode: paletteModeList}) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}, Alt: true}) + if cmd != nil { + t.Fatalf("expected no quit command for immediate alt+s, got %v", cmd) + } + palette := updated.(*paletteModel) + if palette.state.Mode != paletteModeList { + t.Fatalf("expected immediate alt+s to leave palette open, got mode %v", palette.state.Mode) + } + + palette.openedAt = time.Now().Add(-time.Second) + updated, cmd = palette.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}, Alt: true}) + if cmd == nil { + t.Fatalf("expected delayed alt+s to close the palette") + } + palette = updated.(*paletteModel) + if palette.result.Kind != paletteResultClose { + t.Fatalf("expected delayed alt+s to close the palette") + } +} + +func TestLoadPaletteRuntimeSurvivesMalformedRegistry(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + registryDir := filepath.Join(home, ".config", "agent-tracker", "run") + if err := os.MkdirAll(registryDir, 0o755); err != nil { + t.Fatalf("mkdir registry dir: %v", err) + } + if err := os.WriteFile(filepath.Join(registryDir, "agents.json"), []byte("{}\n}"), 0o644); err != nil { + t.Fatalf("write malformed registry: %v", err) + } + + runtime, err := loadPaletteRuntime([]string{"--window=@1", "--path=" + home, "--session-name=test", "--window-name=shell"}) + if err != nil { + t.Fatalf("loadPaletteRuntime returned error: %v", err) + } + if runtime.reg == nil || runtime.reg.Agents == nil { + t.Fatalf("expected fallback empty registry, got %#v", runtime.reg) + } + if got := runtime.startupMessage; !strings.Contains(got, "Ignoring malformed registry") { + t.Fatalf("expected startup warning, got %q", got) + } + + model := newPaletteModel(runtime, paletteUIState{Mode: paletteModeList, Message: runtime.startupMessage}) + view := model.View() + if !strings.Contains(view, "Ignoring malformed registry") { + t.Fatalf("expected warning in palette view, got %s", fmt.Sprintf("%q", view)) + } + if !strings.Contains(view, "Command Palette") { + t.Fatalf("expected palette to still render, got %s", fmt.Sprintf("%q", view)) + } +} diff --git a/agent-tracker/cmd/agent/scroll.go b/agent-tracker/cmd/agent/scroll.go new file mode 100644 index 0000000..38eee02 --- /dev/null +++ b/agent-tracker/cmd/agent/scroll.go @@ -0,0 +1,17 @@ +package main + +func stableListOffset(currentOffset, selectedIndex, visibleRows, totalRows int) int { + if totalRows <= 0 || visibleRows <= 0 { + return 0 + } + maxOffset := maxInt(0, totalRows-visibleRows) + offset := clampInt(currentOffset, 0, maxOffset) + selected := clampInt(selectedIndex, 0, totalRows-1) + if selected < offset { + offset = selected + } + if selected >= offset+visibleRows { + offset = selected - visibleRows + 1 + } + return clampInt(offset, 0, maxOffset) +} diff --git a/agent-tracker/cmd/agent/scroll_test.go b/agent-tracker/cmd/agent/scroll_test.go new file mode 100644 index 0000000..d0a8ef7 --- /dev/null +++ b/agent-tracker/cmd/agent/scroll_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "testing" + "time" +) + +func TestStableListOffsetKeepsViewportStableWhenReversing(t *testing.T) { + offset := 0 + for _, selected := range []int{0, 1, 2, 3, 4, 5, 6, 7} { + offset = stableListOffset(offset, selected, 5, 20) + } + if offset != 3 { + t.Fatalf("expected offset 3 after scrolling down, got %d", offset) + } + + offset = stableListOffset(offset, 6, 5, 20) + if offset != 3 { + t.Fatalf("expected offset to stay 3 when reversing inside viewport, got %d", offset) + } + + offset = stableListOffset(offset, 2, 5, 20) + if offset != 2 { + t.Fatalf("expected offset to move only once selection crosses the top edge, got %d", offset) + } +} + +func TestActivityMonitorViewportDoesNotJumpWhenReversing(t *testing.T) { + m := newActivityMonitorModel("@1", false) + m.processes = map[int]*activityProcess{} + for i := 0; i < 20; i++ { + pid := 100 + i + m.rows = append(m.rows, activityRow{PID: pid}) + m.processes[pid] = &activityProcess{ + PID: pid, + CPU: 1, + ResidentMB: 10, + Command: fmt.Sprintf("proc-%d", i), + ShortCommand: fmt.Sprintf("proc-%d", i), + } + } + m.selectedRow = 0 + m.selectedPID = m.rows[0].PID + + _ = m.renderTable(80, 6) + for i := 0; i < 7; i++ { + m.moveSelection(1) + _ = m.renderTable(80, 6) + } + if m.rowOffset != 3 { + t.Fatalf("expected activity monitor offset 3 after scrolling down, got %d", m.rowOffset) + } + + m.moveSelection(-1) + _ = m.renderTable(80, 6) + if m.rowOffset != 3 { + t.Fatalf("expected activity monitor offset to stay 3 when reversing inside viewport, got %d", m.rowOffset) + } +} + +func TestTodoPanelViewportDoesNotJumpWhenReversing(t *testing.T) { + home := t.TempDir() + windowItems := make([]tmuxTodoItem, 0, 20) + for i := 0; i < 20; i++ { + windowItems = append(windowItems, tmuxTodoItem{Title: fmt.Sprintf("window %d", i), Priority: 1, CreatedAt: time.Now()}) + } + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Windows: map[string][]tmuxTodoItem{"@1": windowItems}, + }) + + m, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + + _ = m.renderColumn(todoScopeWindow, 40, 7) + for i := 0; i < 7; i++ { + m.moveSelection(1) + _ = m.renderColumn(todoScopeWindow, 40, 7) + } + if m.windowOffset != 3 { + t.Fatalf("expected todo panel offset 3 after scrolling down, got %d", m.windowOffset) + } + + m.moveSelection(-1) + _ = m.renderColumn(todoScopeWindow, 40, 7) + if m.windowOffset != 3 { + t.Fatalf("expected todo panel offset to stay 3 when reversing inside viewport, got %d", m.windowOffset) + } +} + +func TestPaletteViewportDoesNotJumpWhenReversing(t *testing.T) { + todos := make([]todoItem, 0, 10) + for i := 0; i < 10; i++ { + todos = append(todos, todoItem{Title: fmt.Sprintf("todo %d", i)}) + } + runtime := &paletteRuntime{ + agentID: "agent-1", + record: &agentRecord{ + ID: "agent-1", + Dashboard: dashboardDoc{Todos: todos}, + }, + } + m := newPaletteModel(runtime, paletteUIState{}) + styles := newPaletteStyles() + + _ = m.renderListView(styles, 96, 20) + for i := 0; i < 5; i++ { + m.state.Selected++ + _ = m.renderListView(styles, 96, 20) + } + if m.state.ActionOffset != 3 { + t.Fatalf("expected palette offset 3 after scrolling down, got %d", m.state.ActionOffset) + } + + m.state.Selected-- + _ = m.renderListView(styles, 96, 20) + if m.state.ActionOffset != 3 { + t.Fatalf("expected palette offset to stay 3 when reversing inside viewport, got %d", m.state.ActionOffset) + } +} diff --git a/agent-tracker/cmd/agent/todo_panel.go b/agent-tracker/cmd/agent/todo_panel.go new file mode 100644 index 0000000..813b6d4 --- /dev/null +++ b/agent-tracker/cmd/agent/todo_panel.go @@ -0,0 +1,926 @@ +package main + +import ( + "fmt" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var todoPanelClipboardWriter = writeTodoClipboard + +type todoPanelMode int + +const ( + todoPanelModeList todoPanelMode = iota + todoPanelModeAdd + todoPanelModeEdit + todoPanelModeConfirmDelete +) + +type todoPanelModel struct { + entries []tmuxTodoEntry + focusedScope todoScope + selectedWindow int + selectedGlobal int + windowOffset int + globalOffset int + mode todoPanelMode + sessionID string + windowID string + width int + height int + status string + statusUntil time.Time + addText []rune + addCursor int + addScope todoScope + deleteEntry *tmuxTodoEntry + editEntry *tmuxTodoEntry + closePalette bool + showAltHints bool + showCompleted bool + keepVisibleDone map[string]bool + styles todoPanelStyles +} + +type todoPanelStyles struct { + title lipgloss.Style + meta lipgloss.Style + subtle lipgloss.Style + input lipgloss.Style + inputCursor lipgloss.Style + item lipgloss.Style + itemSelected lipgloss.Style + itemMuted lipgloss.Style + itemTitle lipgloss.Style + itemTitleDim lipgloss.Style + itemMeta lipgloss.Style + checkBox lipgloss.Style + checkDone lipgloss.Style + scopeLabel lipgloss.Style + currentLabel lipgloss.Style + mutedLabel lipgloss.Style + divider lipgloss.Style + footer lipgloss.Style + status lipgloss.Style + statusBad lipgloss.Style + shortcutKey lipgloss.Style + shortcutText lipgloss.Style + modal lipgloss.Style + modalTitle lipgloss.Style + modalBody lipgloss.Style + modalHint lipgloss.Style +} + +func newTodoPanelStyles() todoPanelStyles { + accent := lipgloss.Color("223") + cyan := lipgloss.Color("117") + selected := lipgloss.Color("238") + text := lipgloss.Color("252") + muted := lipgloss.Color("245") + bright := lipgloss.Color("230") + warning := lipgloss.Color("203") + success := lipgloss.Color("150") + return todoPanelStyles{ + title: lipgloss.NewStyle().Bold(true).Foreground(bright), + meta: lipgloss.NewStyle().Foreground(muted), + subtle: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + input: lipgloss.NewStyle().Foreground(text), + inputCursor: lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(accent).Bold(true), + item: lipgloss.NewStyle().Padding(0, 1), + itemSelected: lipgloss.NewStyle().Background(selected).Padding(0, 1), + itemMuted: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1), + itemTitle: lipgloss.NewStyle().Foreground(text).Bold(true), + itemTitleDim: lipgloss.NewStyle().Foreground(lipgloss.Color("246")).Bold(true), + itemMeta: lipgloss.NewStyle().Foreground(muted), + checkBox: lipgloss.NewStyle().Foreground(muted), + checkDone: lipgloss.NewStyle().Foreground(success), + scopeLabel: lipgloss.NewStyle().Foreground(cyan).Background(lipgloss.Color("237")).Padding(0, 1).Bold(true), + currentLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(success).Padding(0, 1).Bold(true), + mutedLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("241")).Padding(0, 1).Bold(true), + divider: lipgloss.NewStyle().Foreground(lipgloss.Color("239")), + footer: lipgloss.NewStyle().Foreground(lipgloss.Color("216")), + status: lipgloss.NewStyle().Foreground(success), + statusBad: lipgloss.NewStyle().Foreground(warning), + shortcutKey: lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(accent).Padding(0, 1).Bold(true), + shortcutText: lipgloss.NewStyle().Foreground(muted), + modal: lipgloss.NewStyle().Border(paletteModalBorder).BorderForeground(accent).Padding(1, 2).Background(lipgloss.Color("235")), + modalTitle: lipgloss.NewStyle().Bold(true).Foreground(warning), + modalBody: lipgloss.NewStyle().Foreground(text), + modalHint: lipgloss.NewStyle().Foreground(muted), + } +} + +func runTodoPanel() error { + sessionID, windowID := getCurrentTmuxScopeInfo() + model, err := newTodoPanelModel(sessionID, windowID) + if err != nil { + return err + } + _, err = tea.NewProgram(model).Run() + if err != nil { + return err + } + if model.closePalette { + return errClosePalette + } + return nil +} + +func newTodoPanelModel(sessionID, windowID string) (*todoPanelModel, error) { + if _, err := loadTmuxTodoStore(); err != nil { + return nil, err + } + model := &todoPanelModel{ + sessionID: strings.TrimSpace(sessionID), + windowID: strings.TrimSpace(windowID), + focusedScope: todoScopeWindow, + keepVisibleDone: map[string]bool{}, + styles: newTodoPanelStyles(), + mode: todoPanelModeList, + } + model.reloadEntries() + return model, nil +} + +func collectTodoPanelEntries(currentWindowID string) []tmuxTodoEntry { + store, err := loadTmuxTodoStore() + if err != nil { + return nil + } + entries := make([]tmuxTodoEntry, 0, len(store.Global)+len(store.Windows[currentWindowID])) + for idx, item := range store.Windows[currentWindowID] { + entries = append(entries, tmuxTodoEntry{ + Title: item.Title, + Done: item.Done, + Priority: item.Priority, + Scope: todoScopeWindow, + ScopeID: currentWindowID, + ScopeName: "Window", + IsCurrent: true, + ItemIndex: idx, + }) + } + 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, + }) + } + return entries +} + +func (m *todoPanelModel) reloadEntries() { + m.entries = collectTodoPanelEntries(m.windowID) + m.pruneKeepVisibleDone() + m.clampSelections() +} + +func (m *todoPanelModel) Init() tea.Cmd { + return nil +} + +func (m *todoPanelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyMsg: + if isAltFooterToggleKey(msg) { + m.showAltHints = !m.showAltHints + return m, nil + } + m.showAltHints = false + key := msg.String() + if key == "alt+s" { + m.closePalette = true + return m, tea.Quit + } + if key == "esc" && m.mode == todoPanelModeList { + return m, tea.Quit + } + switch m.mode { + case todoPanelModeAdd: + return m.updateAdd(key) + case todoPanelModeEdit: + return m.updateEdit(key) + case todoPanelModeConfirmDelete: + return m.updateConfirmDelete(key) + default: + return m.updateList(key) + } + } + return m, nil +} + +func (m *todoPanelModel) updateList(key string) (tea.Model, tea.Cmd) { + switch key { + case "u", "up": + m.moveSelection(-1) + case "e", "down": + m.moveSelection(1) + case "ctrl+u": + return m.moveSelectedTodo(-1) + case "ctrl+e": + return m.moveSelectedTodo(1) + case "n", "left": + m.focusedScope = todoScopeWindow + m.clampSelections() + case "i", "right": + m.focusedScope = todoScopeGlobal + m.clampSelections() + case "enter", " ": + entry, ok := m.selectedEntry(m.focusedScope) + if !ok { + return m, nil + } + entryKey := todoEntryKey(entry) + toggledDone := !entry.Done + if err := toggleTmuxTodoByIndex(entry.Scope, entry.ScopeID, entry.ItemIndex); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + } else { + if toggledDone { + m.keepVisibleDone[entryKey] = true + } else { + delete(m.keepVisibleDone, entryKey) + } + m.reloadEntries() + } + case "a": + m.mode = todoPanelModeAdd + m.addText = nil + m.addCursor = 0 + m.addScope = m.focusedScope + case "E": + if entry, ok := m.selectedEntry(m.focusedScope); ok { + entryCopy := entry + m.editEntry = &entryCopy + m.mode = todoPanelModeEdit + m.addText = []rune(entry.Title) + m.addCursor = len(m.addText) + m.addScope = entry.Scope + } + case "y": + entry, ok := m.selectedEntry(m.focusedScope) + if !ok { + return m, nil + } + if err := todoPanelClipboardWriter(entry.Title); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + } else { + m.setStatus("Copied todo", 1500*time.Millisecond) + } + case "d", "x": + if entry, ok := m.selectedEntry(m.focusedScope); ok { + m.deleteEntry = &entry + m.mode = todoPanelModeConfirmDelete + } + case "1": + return m.setSelectedPriority(1) + case "2": + return m.setSelectedPriority(2) + case "3": + return m.setSelectedPriority(3) + case "c": + m.showCompleted = !m.showCompleted + m.clampSelections() + } + return m, nil +} + +func (m *todoPanelModel) updateAdd(key string) (tea.Model, tea.Cmd) { + if key == "esc" { + m.mode = todoPanelModeList + return m, nil + } + if key == "enter" { + title := strings.TrimSpace(string(m.addText)) + if title == "" { + m.mode = todoPanelModeList + return m, nil + } + scopeID := m.scopeID(m.addScope) + if err := addTmuxTodo(m.addScope, scopeID, title); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + } else { + m.focusedScope = m.addScope + m.reloadEntries() + m.setSelectedIndex(m.addScope, maxInt(0, len(m.visibleEntries(m.addScope))-1)) + } + m.mode = todoPanelModeList + return m, nil + } + if key == "left" { + m.addScope = todoScopeWindow + return m, nil + } + if key == "right" { + m.addScope = todoScopeGlobal + return m, nil + } + if key == "tab" { + if m.addScope == todoScopeWindow { + m.addScope = todoScopeGlobal + } else { + m.addScope = todoScopeWindow + } + return m, nil + } + applyPaletteInputKey(key, &m.addText, &m.addCursor, true) + return m, nil +} + +func (m *todoPanelModel) updateEdit(key string) (tea.Model, tea.Cmd) { + if key == "esc" { + m.editEntry = nil + m.mode = todoPanelModeList + return m, nil + } + if key == "enter" { + if m.editEntry == nil { + m.mode = todoPanelModeList + return m, nil + } + title := strings.TrimSpace(string(m.addText)) + if title == "" { + m.setStatus("todo title is required", 1500*time.Millisecond) + return m, nil + } + if err := updateTmuxTodoTitleByIndex(m.editEntry.Scope, m.editEntry.ScopeID, m.editEntry.ItemIndex, title); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + } else { + selected := m.selectedIndex(m.focusedScope) + m.reloadEntries() + m.setSelectedIndex(m.focusedScope, selected) + } + m.editEntry = nil + m.mode = todoPanelModeList + return m, nil + } + applyPaletteInputKey(key, &m.addText, &m.addCursor, true) + return m, nil +} + +func (m *todoPanelModel) setSelectedPriority(priority int) (tea.Model, tea.Cmd) { + entry, ok := m.selectedEntry(m.focusedScope) + if !ok { + return m, nil + } + if err := setTmuxTodoPriorityByIndex(entry.Scope, entry.ScopeID, entry.ItemIndex, priority); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + return m, nil + } + selected := m.selectedIndex(m.focusedScope) + m.reloadEntries() + m.setSelectedIndex(m.focusedScope, selected) + return m, nil +} + +func (m *todoPanelModel) moveSelectedTodo(delta int) (tea.Model, tea.Cmd) { + entries := m.visibleEntries(m.focusedScope) + selected := m.selectedIndex(m.focusedScope) + if len(entries) == 0 || selected < 0 || selected >= len(entries) { + return m, nil + } + target := selected + delta + if target < 0 || target >= len(entries) { + return m, nil + } + entry := entries[selected] + targetEntry := entries[target] + if err := moveTmuxTodoByIndex(entry.Scope, entry.ScopeID, entry.ItemIndex, targetEntry.ItemIndex); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + return m, nil + } + m.reloadEntries() + m.setSelectedIndex(m.focusedScope, target) + return m, nil +} + +func (m *todoPanelModel) updateConfirmDelete(key string) (tea.Model, tea.Cmd) { + if key == "esc" || key == "n" { + m.deleteEntry = nil + m.mode = todoPanelModeList + return m, nil + } + if key == "y" || key == "enter" { + if m.deleteEntry != nil { + if err := deleteTmuxTodoByIndex(m.deleteEntry.Scope, m.deleteEntry.ScopeID, m.deleteEntry.ItemIndex); err != nil { + m.setStatus(err.Error(), 1500*time.Millisecond) + } else { + m.reloadEntries() + } + } + m.deleteEntry = nil + m.mode = todoPanelModeList + return m, nil + } + return m, nil +} + +func (m *todoPanelModel) View() string { + w := m.width + h := m.height + if w < 52 || h < 12 { + return m.styles.title.Render("Window too small") + } + + switch m.mode { + case todoPanelModeAdd: + return m.renderAddMode(w, h) + case todoPanelModeEdit: + return m.renderEditMode(w, h) + case todoPanelModeConfirmDelete: + return m.renderConfirmDelete(w, h) + default: + return m.renderList(w, h) + } +} + +func (m *todoPanelModel) renderList(w, h int) string { + openCount := 0 + doneCount := 0 + for _, entry := range m.entries { + if entry.Done { + doneCount++ + } else { + openCount++ + } + } + completedLabel := "hidden" + if m.showCompleted { + completedLabel = "shown" + } + headerLine := m.styles.title.Render(fmt.Sprintf("Todo Panel %d open %d done", openCount, doneCount)) + metaLine := m.styles.meta.Render(fmt.Sprintf("Window %d Global %d Completed %s", len(m.visibleEntries(todoScopeWindow)), len(m.visibleEntries(todoScopeGlobal)), completedLabel)) + + contentH := h - 4 + leftW := maxInt(24, (w-1)/2) + rightW := maxInt(24, w-leftW-1) + body := lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(leftW).Height(contentH).Render(m.renderColumn(todoScopeWindow, leftW, contentH)), + m.renderDivider(contentH), + lipgloss.NewStyle().Width(rightW).Height(contentH).Render(m.renderColumn(todoScopeGlobal, rightW, contentH)), + ) + footer := m.renderFooter(w) + + view := lipgloss.JoinVertical(lipgloss.Left, + headerLine, + metaLine, + "", + body, + footer, + ) + return lipgloss.NewStyle().Width(w).Height(h).Padding(0, 1).Render(view) +} + +func (m *todoPanelModel) renderColumn(scope todoScope, width, height int) string { + entries := m.visibleEntries(scope) + selected := m.selectedIndex(scope) + labelStyle := m.styles.mutedLabel + if m.focusedScope == scope { + labelStyle = m.styles.currentLabel + } + label := labelStyle.Render(todoScopeLabel(scope)) + header := lipgloss.JoinHorizontal(lipgloss.Left, label, " ", m.styles.meta.Render(fmt.Sprintf("%d items", len(entries)))) + lines := []string{header, ""} + if len(entries) == 0 { + message := "No open todos" + if m.showCompleted { + message = "No todos" + } + lines = append(lines, m.styles.itemMuted.Width(width).Render(message)) + if !m.showCompleted && len(m.scopeEntries(scope)) > 0 { + lines = append(lines, m.styles.subtle.Width(width).Render("Press c to show completed todos.")) + } else { + lines = append(lines, m.styles.subtle.Width(width).Render("Press a to add a todo here.")) + } + return lipgloss.NewStyle().Width(width).Height(height).Render(strings.Join(lines, "\n")) + } + + visibleRows := maxInt(1, height-2) + offset := stableListOffset(m.selectedOffset(scope), selected, visibleRows, len(entries)) + m.setSelectedOffset(scope, offset) + usedRows := 0 + for idx := offset; usedRows < visibleRows; idx++ { + if idx >= len(entries) { + break + } + entry := entries[idx] + isSelected := m.focusedScope == scope && idx == selected + entryLines := m.renderTodoEntryLines(entry, width, isSelected) + remaining := visibleRows - usedRows + if len(entryLines) > remaining { + entryLines = entryLines[:remaining] + } + lines = append(lines, entryLines...) + usedRows += len(entryLines) + } + return lipgloss.NewStyle().Width(width).Height(height).Render(strings.Join(lines, "\n")) +} + +func (m *todoPanelModel) renderTodoEntryLines(entry tmuxTodoEntry, width int, isSelected bool) []string { + check := "○" + checkStyle := m.styles.checkBox + boxStyle := m.styles.item.Width(width) + titleStyle := m.styles.itemTitle + fillStyle := lipgloss.NewStyle() + if entry.Done { + check = "●" + checkStyle = m.styles.checkDone + titleStyle = m.styles.itemTitleDim + boxStyle = m.styles.itemMuted.Width(width) + } + if isSelected { + selectedBG := lipgloss.Color("238") + boxStyle = m.styles.itemSelected.Width(width) + checkStyle = checkStyle.Background(selectedBG) + titleStyle = titleStyle.Background(selectedBG).Foreground(lipgloss.Color("230")) + fillStyle = fillStyle.Background(selectedBG) + } + priorityChip := renderTodoPriorityChip(entry.Priority) + innerWidth := maxInt(16, width-2) + prefixWidth := lipgloss.Width(check) + 1 + chipWidth := lipgloss.Width(priorityChip) + titleWidth := maxInt(8, innerWidth-prefixWidth-chipWidth-1) + titleLines := wrapTodoTitle(entry.Title, titleWidth) + if len(titleLines) == 0 { + titleLines = []string{""} + } + gapWidth := maxInt(1, innerWidth-prefixWidth-lipgloss.Width(titleLines[0])-chipWidth) + rowLines := []string{boxStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, + checkStyle.Render(check), + fillStyle.Render(" "), + titleStyle.Render(titleLines[0]), + fillStyle.Render(strings.Repeat(" ", gapWidth)), + priorityChip, + ))} + continuationPrefix := fillStyle.Render(strings.Repeat(" ", prefixWidth)) + for _, line := range titleLines[1:] { + padding := maxInt(0, innerWidth-prefixWidth-lipgloss.Width(line)) + rowLines = append(rowLines, boxStyle.Render(continuationPrefix+titleStyle.Render(line)+fillStyle.Render(strings.Repeat(" ", padding)))) + } + return rowLines +} + +func (m *todoPanelModel) renderAddMode(w, h int) string { + target := todoScopeLabel(m.addScope) + body := lipgloss.JoinVertical(lipgloss.Left, + m.styles.modalTitle.Render("Add Todo"), + m.styles.modalBody.Render(fmt.Sprintf("Target: %s", target)), + "", + m.styles.input.Render(renderTodoInputValue(m.addText, m.addCursor, m.styles)), + "", + m.styles.modalHint.Render("Enter save Tab/left/right target Esc cancel"), + ) + box := m.styles.modal.Width(minInt(72, maxInt(34, w-10))).Render(body) + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box) +} + +func (m *todoPanelModel) renderEditMode(w, h int) string { + target := todoScopeLabel(m.addScope) + body := lipgloss.JoinVertical(lipgloss.Left, + m.styles.modalTitle.Render("Edit Todo"), + m.styles.modalBody.Render(fmt.Sprintf("Target: %s", target)), + "", + m.styles.input.Render(renderTodoInputValue(m.addText, m.addCursor, m.styles)), + "", + m.styles.modalHint.Render("Enter save Esc cancel"), + ) + box := m.styles.modal.Width(minInt(72, maxInt(34, w-10))).Render(body) + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box) +} + +func (m *todoPanelModel) renderConfirmDelete(w, h int) string { + title := "Delete todo?" + if m.deleteEntry != nil { + title = fmt.Sprintf("Delete \"%s\"?", truncate(m.deleteEntry.Title, 40)) + } + body := lipgloss.JoinVertical(lipgloss.Left, + m.styles.modalTitle.Render(title), + m.styles.modalBody.Render("This removes it from local tmux todos."), + "", + m.styles.modalHint.Render("y confirm n cancel"), + ) + box := m.styles.modal.Width(minInt(48, w-10)).Render(body) + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box) +} + +func (m *todoPanelModel) renderFooter(w int) string { + contentWidth := maxInt(1, w-2) + renderSegments := func(pairs [][2]string) string { + return renderShortcutPairs(func(v string) string { return m.styles.shortcutKey.Render(v) }, func(v string) string { return m.styles.shortcutText.Render(v) }, " ", pairs) + } + footer := "" + if m.showAltHints { + footer = pickRenderedShortcutFooter(contentWidth, renderSegments, + [][2]string{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, + [][2]string{{"Alt-S", "close"}}, + ) + } else { + footer = pickRenderedShortcutFooter(contentWidth, renderSegments, + [][2]string{{"u/e", "move"}, {"Ctrl-U/E", "reorder"}, {"n/i", "column"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "delete"}, {"1/2/3", "priority"}, {"c", "completed"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"Ctrl-U/E", "reorder"}, {"n/i", "col"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"c", "done"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"Ctrl-U/E", "reorder"}, {"n/i", "col"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"n/i", "col"}, {"Space", "toggle"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"n/i", "col"}, {"a", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}}, + [][2]string{{"Esc", "close"}, {footerHintToggleKey, "more"}}, + ) + } + status := strings.TrimSpace(m.currentStatus()) + if status != "" { + statusText := m.styles.statusBad.Render(truncate(status, maxInt(12, minInt(20, contentWidth/4)))) + if lipgloss.Width(footer)+2+lipgloss.Width(statusText) <= contentWidth { + gap := contentWidth - lipgloss.Width(footer) - lipgloss.Width(statusText) + if gap < 2 { + gap = 2 + } + return footer + strings.Repeat(" ", gap) + statusText + } + if lipgloss.Width(statusText) <= contentWidth { + return lipgloss.NewStyle().Width(contentWidth).Render(statusText) + } + } + return lipgloss.NewStyle().Width(contentWidth).Render(footer) +} + +func (m *todoPanelModel) scopeID(scope todoScope) string { + if scope == todoScopeGlobal { + return "global" + } + return m.windowID +} + +func (m *todoPanelModel) scopeEntries(scope todoScope) []tmuxTodoEntry { + entries := make([]tmuxTodoEntry, 0, len(m.entries)) + for _, entry := range m.entries { + if entry.Scope == scope { + entries = append(entries, entry) + } + } + return entries +} + +func (m *todoPanelModel) visibleEntries(scope todoScope) []tmuxTodoEntry { + entries := make([]tmuxTodoEntry, 0, len(m.entries)) + for _, entry := range m.entries { + if entry.Scope != scope { + continue + } + if entry.Done && !m.showCompleted && !m.keepVisibleDone[todoEntryKey(entry)] { + continue + } + entries = append(entries, entry) + } + return entries +} + +func (m *todoPanelModel) selectedIndex(scope todoScope) int { + if scope == todoScopeGlobal { + return m.selectedGlobal + } + return m.selectedWindow +} + +func (m *todoPanelModel) setSelectedIndex(scope todoScope, index int) { + if scope == todoScopeGlobal { + m.selectedGlobal = index + return + } + m.selectedWindow = index +} + +func (m *todoPanelModel) selectedOffset(scope todoScope) int { + if scope == todoScopeGlobal { + return m.globalOffset + } + return m.windowOffset +} + +func (m *todoPanelModel) setSelectedOffset(scope todoScope, offset int) { + if scope == todoScopeGlobal { + m.globalOffset = offset + return + } + m.windowOffset = offset +} + +func (m *todoPanelModel) clampSelections() { + for _, scope := range []todoScope{todoScopeWindow, todoScopeGlobal} { + entries := m.visibleEntries(scope) + selected := m.selectedIndex(scope) + if len(entries) == 0 { + m.setSelectedIndex(scope, 0) + continue + } + if selected < 0 { + selected = 0 + } + if selected >= len(entries) { + selected = len(entries) - 1 + } + m.setSelectedIndex(scope, selected) + } +} + +func (m *todoPanelModel) selectedEntry(scope todoScope) (tmuxTodoEntry, bool) { + entries := m.visibleEntries(scope) + selected := m.selectedIndex(scope) + if len(entries) == 0 || selected < 0 || selected >= len(entries) { + return tmuxTodoEntry{}, false + } + return entries[selected], true +} + +func (m *todoPanelModel) moveSelection(delta int) { + entries := m.visibleEntries(m.focusedScope) + if len(entries) == 0 { + return + } + selected := clampInt(m.selectedIndex(m.focusedScope)+delta, 0, len(entries)-1) + m.setSelectedIndex(m.focusedScope, selected) +} + +func (m *todoPanelModel) renderDivider(height int) string { + lines := make([]string, maxInt(1, height)) + for i := range lines { + lines[i] = m.styles.divider.Render("│") + } + return strings.Join(lines, "\n") +} + +func todoEntryKey(entry tmuxTodoEntry) string { + return fmt.Sprintf("%d|%s|%d|%s", entry.Scope, entry.ScopeID, entry.ItemIndex, entry.Title) +} + +func (m *todoPanelModel) pruneKeepVisibleDone() { + if len(m.keepVisibleDone) == 0 { + return + } + present := make(map[string]bool, len(m.entries)) + for _, entry := range m.entries { + present[todoEntryKey(entry)] = true + } + for key := range m.keepVisibleDone { + if !present[key] { + delete(m.keepVisibleDone, key) + } + } +} + +func todoScopeLabel(scope todoScope) string { + if scope == todoScopeGlobal { + return "GLOBAL" + } + return "WINDOW" +} + +func todoPriorityLabel(priority int) string { + switch normalizeTodoPriority(priority) { + case 1: + return "high" + case 3: + return "low" + default: + return "medium" + } +} + +func renderTodoPriorityChip(priority int) string { + label := "MED" + bg := lipgloss.Color("240") + fg := lipgloss.Color("230") + switch normalizeTodoPriority(priority) { + case 1: + label = "HIGH" + bg = lipgloss.Color("203") + fg = lipgloss.Color("235") + case 3: + label = "LOW" + bg = lipgloss.Color("241") + fg = lipgloss.Color("252") + } + return lipgloss.NewStyle().Foreground(fg).Background(bg).Padding(0, 1).Bold(true).Render(label) +} + +func (m *todoPanelModel) setStatus(text string, duration time.Duration) { + m.status = text + m.statusUntil = time.Now().Add(duration) +} + +func (m *todoPanelModel) currentStatus() string { + if m.status == "" { + return "" + } + if !m.statusUntil.IsZero() && time.Now().After(m.statusUntil) { + m.status = "" + return "" + } + return m.status +} + +func renderTodoInputValue(text []rune, cursor int, styles todoPanelStyles) string { + if cursor < 0 { + cursor = 0 + } + if cursor > len(text) { + cursor = len(text) + } + left := string(text[:cursor]) + right := string(text[cursor:]) + cursorChar := " " + if cursor < len(text) { + cursorChar = string(text[cursor]) + right = string(text[cursor+1:]) + } + if len(text) == 0 && cursor == 0 { + cursorChar = " " + } + return left + styles.inputCursor.Render(cursorChar) + right +} + +func wrapTodoTitle(text string, width int) []string { + text = strings.TrimSpace(text) + if width <= 0 { + return []string{""} + } + if text == "" { + return []string{""} + } + words := strings.Fields(text) + if len(words) == 0 { + return []string{""} + } + var lines []string + current := "" + appendCurrent := func() { + if current != "" { + lines = append(lines, current) + current = "" + } + } + for _, word := range words { + for lipgloss.Width(word) > width { + spaceLeft := width + if current != "" { + spaceLeft = maxInt(1, width-lipgloss.Width(current)-1) + } + chunk := truncate(word, spaceLeft) + chunk = strings.TrimSuffix(chunk, "…") + if chunk == "" { + chunk = string([]rune(word)[:1]) + } + if current == "" { + lines = append(lines, chunk) + } else { + lines = append(lines, current+" "+chunk) + current = "" + } + word = strings.TrimPrefix(word, chunk) + } + if current == "" { + current = word + continue + } + candidate := current + " " + word + if lipgloss.Width(candidate) <= width { + current = candidate + continue + } + appendCurrent() + current = word + } + appendCurrent() + if len(lines) == 0 { + return []string{""} + } + return lines +} + +func writeTodoClipboard(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return fmt.Errorf("nothing to copy") + } + cmd := exec.Command("pbcopy") + cmd.Stdin = strings.NewReader(value) + if output, err := cmd.CombinedOutput(); err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return err + } + return fmt.Errorf("clipboard copy failed: %s", message) + } + return nil +} diff --git a/agent-tracker/cmd/agent/todo_panel_test.go b/agent-tracker/cmd/agent/todo_panel_test.go new file mode 100644 index 0000000..cdfc22b --- /dev/null +++ b/agent-tracker/cmd/agent/todo_panel_test.go @@ -0,0 +1,450 @@ +package main + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func writeTestTodoStore(t *testing.T, home string, store *tmuxTodoStore) { + t.Helper() + t.Setenv("HOME", home) + if err := saveTmuxTodoStore(store); err != nil { + t.Fatalf("save todo store: %v", err) + } +} + +func TestTodoPanelViewShowsTwoColumnsWithoutPreview(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}}, + Windows: map[string][]tmuxTodoItem{ + "@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}}, + }, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + model.width = 96 + model.height = 24 + view := model.View() + + if !strings.Contains(view, "WINDOW") { + t.Fatalf("expected window column header in view: %q", view) + } + if !strings.Contains(view, "GLOBAL") { + t.Fatalf("expected global column header in view: %q", view) + } + if strings.Contains(view, "Overview") || strings.Contains(view, "Selected") { + t.Fatalf("unexpected preview content in view: %q", view) + } + if strings.Contains(view, "SESSION") { + t.Fatalf("unexpected session scope in view: %q", view) + } +} + +func TestTodoPanelSwitchesFocusedColumnWithNI(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}}, + Windows: map[string][]tmuxTodoItem{ + "@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}}, + }, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + if model.focusedScope != todoScopeWindow { + t.Fatalf("expected window focus by default, got %v", model.focusedScope) + } + model.updateList("i") + if model.focusedScope != todoScopeGlobal { + t.Fatalf("expected global focus after i, got %v", model.focusedScope) + } + model.updateList("n") + if model.focusedScope != todoScopeWindow { + t.Fatalf("expected window focus after n, got %v", model.focusedScope) + } +} + +func TestTodoPanelDefaultsToWindowFocusEvenWithoutWindowTodos(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}}, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + if model.focusedScope != todoScopeWindow { + t.Fatalf("expected window focus by default, got %v", model.focusedScope) + } +} + +func TestTodoPanelAddModeAcceptsNICharacters(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{Version: tmuxTodoStoreVersion}) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + model.updateList("a") + + model.updateAdd("n") + model.updateAdd("i") + + if got := string(model.addText); got != "ni" { + t.Fatalf("expected add text to include typed letters, got %q", got) + } + if model.addScope != todoScopeWindow { + t.Fatalf("expected add scope to stay window while typing, got %v", model.addScope) + } +} + +func TestTodoPanelAddModeUsesArrowsAndTabForTarget(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{Version: tmuxTodoStoreVersion}) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + model.updateList("a") + + model.updateAdd("right") + if model.addScope != todoScopeGlobal { + t.Fatalf("expected right to switch add scope to global, got %v", model.addScope) + } + model.updateAdd("left") + if model.addScope != todoScopeWindow { + t.Fatalf("expected left to switch add scope to window, got %v", model.addScope) + } + model.updateAdd("tab") + if model.addScope != todoScopeGlobal { + t.Fatalf("expected tab to toggle add scope to global, got %v", model.addScope) + } +} + +func TestTodoPanelCanEditSelectedTodo(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Windows: map[string][]tmuxTodoItem{ + "@1": {{Title: "old title", Priority: 2, CreatedAt: time.Now()}}, + }, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + + updated, cmd := model.updateList("E") + if cmd != nil { + t.Fatalf("expected no tea command when opening edit mode, got %v", cmd) + } + panel := updated.(*todoPanelModel) + if panel.mode != todoPanelModeEdit { + t.Fatalf("expected edit mode, got %v", panel.mode) + } + if got := string(panel.addText); got != "old title" { + t.Fatalf("expected initial edit text, got %q", got) + } + + for range []rune("old title") { + panel.updateEdit("backspace") + } + for _, key := range []string{"n", "e", "w", " ", "t", "i", "t", "l", "e"} { + panel.updateEdit(key) + } + updated, cmd = panel.updateEdit("enter") + if cmd != nil { + t.Fatalf("expected no tea command when saving edit, got %v", cmd) + } + panel = updated.(*todoPanelModel) + if panel.mode != todoPanelModeList { + t.Fatalf("expected list mode after save, got %v", panel.mode) + } + + store, err := loadTmuxTodoStore() + if err != nil { + t.Fatalf("load todo store: %v", err) + } + if got := store.Windows["@1"][0].Title; got != "new title" { + t.Fatalf("expected edited title, got %q", got) + } +} + +func TestTodoPanelCtrlEReordersFocusedColumn(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Windows: map[string][]tmuxTodoItem{ + "@1": { + {Title: "first", Priority: 2, CreatedAt: time.Now()}, + {Title: "second", Priority: 2, CreatedAt: time.Now()}, + }, + }, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + if _, cmd := model.updateList("ctrl+e"); cmd != nil { + t.Fatalf("expected no tea command from reorder") + } + + store, err := loadTmuxTodoStore() + if err != nil { + t.Fatalf("load todo store: %v", err) + } + items := store.Windows["@1"] + if len(items) != 2 { + t.Fatalf("expected 2 items after reorder, got %d", len(items)) + } + if items[0].Title != "second" || items[1].Title != "first" { + t.Fatalf("unexpected reordered items: %#v", items) + } + if model.selectedWindow != 1 { + t.Fatalf("expected moved selection to stay on reordered item, got %d", model.selectedWindow) + } +} + +func TestTodoPanelWrapsLongTodoTitles(t *testing.T) { + home := t.TempDir() + longTitle := "this is a very long todo title that should wrap across multiple lines in the panel" + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Windows: map[string][]tmuxTodoItem{ + "@1": {{Title: longTitle, Priority: 2, CreatedAt: time.Now()}}, + }, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + column := model.renderColumn(todoScopeWindow, 28, 10) + if !strings.Contains(column, "this is a very") || !strings.Contains(column, "long todo title") { + t.Fatalf("expected wrapped long todo across multiple lines, got %q", column) + } + if !strings.Contains(column, "MED") { + t.Fatalf("expected priority chip to remain visible, got %q", column) + } +} + +func TestTodoPanelYCopiesSelectedTodo(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Windows: map[string][]tmuxTodoItem{ + "@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}}, + }, + }) + + prev := todoPanelClipboardWriter + t.Cleanup(func() { todoPanelClipboardWriter = prev }) + got := "" + todoPanelClipboardWriter = func(value string) error { + got = value + return nil + } + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + + updated, cmd := model.updateList("y") + if cmd != nil { + t.Fatalf("expected no tea command from copy, got %v", cmd) + } + panel := updated.(*todoPanelModel) + if got != "window todo" { + t.Fatalf("expected copied todo title, got %q", got) + } + if status := panel.currentStatus(); status != "Copied todo" { + t.Fatalf("expected copied status, got %q", status) + } +} + +func TestTodoPanelFooterStaysSingleLine(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}}, + Windows: map[string][]tmuxTodoItem{ + "@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}}, + }, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + + for _, width := range []int{72, 78, 84, 96, 120} { + footer := model.renderFooter(width) + if strings.Contains(footer, "\n") { + t.Fatalf("width %d: footer wrapped into multiple lines: %q", width, footer) + } + if got := lipgloss.Width(footer); got > maxInt(1, width-2) { + t.Fatalf("width %d: footer width %d exceeds content width %d", width, got, maxInt(1, width-2)) + } + } +} + +func TestTodoPanelFooterStatusStaysSingleLine(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{Version: tmuxTodoStoreVersion}) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + model.setStatus("something went wrong with a very long message", time.Minute) + + for _, width := range []int{72, 78, 84, 96, 120} { + footer := model.renderFooter(width) + if strings.Contains(footer, "\n") { + t.Fatalf("width %d: footer wrapped into multiple lines: %q", width, footer) + } + if got := lipgloss.Width(footer); got > maxInt(1, width-2) { + t.Fatalf("width %d: footer width %d exceeds content width %d", width, got, maxInt(1, width-2)) + } + } +} + +func TestTodoPanelFooterTogglesAltHints(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{Version: tmuxTodoStoreVersion}) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + + footer := model.renderFooter(96) + if !strings.Contains(footer, "more") { + t.Fatalf("expected default todo footer to advertise alt options, got %q", footer) + } + if !strings.Contains(footer, footerHintToggleKey) { + t.Fatalf("expected default todo footer to show %q toggle, got %q", footerHintToggleKey, footer) + } + if strings.Contains(footer, "Alt-S") { + t.Fatalf("expected default todo footer to hide alt shortcuts, got %q", footer) + } + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + panel := updated.(*todoPanelModel) + altFooter := panel.renderFooter(96) + if !strings.Contains(altFooter, "Alt-S") { + t.Fatalf("expected alt todo footer to show alt shortcuts, got %q", altFooter) + } + if strings.Contains(altFooter, "toggle") || strings.Contains(altFooter, "add") { + t.Fatalf("expected alt todo footer to hide default shortcuts, got %q", altFooter) + } +} + +func TestTodoPanelViewNeverOverflowsWidth(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}}, + Windows: map[string][]tmuxTodoItem{ + "@1": { + {Title: "window todo", Priority: 1, CreatedAt: time.Now()}, + {Title: "another todo", Priority: 3, CreatedAt: time.Now()}, + }, + }, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + + for _, width := range []int{72, 78, 84, 96, 120} { + model.width = width + model.height = 20 + view := model.View() + for idx, line := range strings.Split(view, "\n") { + if got := lipgloss.Width(line); got > width { + t.Fatalf("width %d: line %d width %d exceeds viewport %d", width, idx+1, got, width) + } + } + } +} + +func TestTodoPanelFooterHasNoSpacerLine(t *testing.T) { + home := t.TempDir() + windowItems := make([]tmuxTodoItem, 0, 12) + globalItems := make([]tmuxTodoItem, 0, 12) + for i := 0; i < 12; i++ { + windowItems = append(windowItems, tmuxTodoItem{Title: fmt.Sprintf("window %d", i), Priority: 1, CreatedAt: time.Now()}) + globalItems = append(globalItems, tmuxTodoItem{Title: fmt.Sprintf("global %d", i), Priority: 2, CreatedAt: time.Now()}) + } + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Global: globalItems, + Windows: map[string][]tmuxTodoItem{"@1": windowItems}, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + for _, width := range []int{72, 78, 84, 96, 120} { + model.width = width + model.height = 20 + + lines := strings.Split(model.View(), "\n") + if len(lines) < 2 { + t.Fatalf("width %d: expected multi-line view, got %q", width, model.View()) + } + last := strings.TrimSpace(lines[len(lines)-1]) + prev := strings.TrimSpace(lines[len(lines)-2]) + if !strings.Contains(last, "Esc") { + t.Fatalf("width %d: expected footer on last line, got %q", width, last) + } + if prev == "" { + t.Fatalf("width %d: expected no blank spacer line above footer", width) + } + } +} + +func TestTodoPanelFooterShowsCopyShortcutWhenSpaceAllows(t *testing.T) { + home := t.TempDir() + writeTestTodoStore(t, home, &tmuxTodoStore{ + Version: tmuxTodoStoreVersion, + Windows: map[string][]tmuxTodoItem{ + "@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}}, + }, + }) + + model, err := newTodoPanelModel("$1", "@1") + if err != nil { + t.Fatalf("new todo panel model: %v", err) + } + footer := model.renderFooter(96) + if !strings.Contains(footer, "copy") || !strings.Contains(footer, "y") { + t.Fatalf("expected footer to show copy shortcut, got %q", footer) + } + if !strings.Contains(footer, "edit") || !strings.Contains(footer, "E") { + t.Fatalf("expected footer to show edit shortcut, got %q", footer) + } +} diff --git a/agent-tracker/cmd/agent/todos.go b/agent-tracker/cmd/agent/todos.go new file mode 100644 index 0000000..c570eb1 --- /dev/null +++ b/agent-tracker/cmd/agent/todos.go @@ -0,0 +1,454 @@ +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 +} + +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 := importLegacyDashboardTodos(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 importLegacyDashboardTodos(store *tmuxTodoStore) error { + reg, err := loadRegistry() + if err != nil { + return err + } + for _, record := range reg.Agents { + if record == nil { + continue + } + scope := todoScopeGlobal + scopeID := "global" + switch { + case strings.TrimSpace(record.TmuxWindowID) != "": + scope = todoScopeWindow + scopeID = strings.TrimSpace(record.TmuxWindowID) + case strings.TrimSpace(record.TmuxSessionID) != "": + scope = todoScopeSession + scopeID = strings.TrimSpace(record.TmuxSessionID) + } + for _, todo := range record.Dashboard.Todos { + appendUniqueTodo(store, scope, scopeID, tmuxTodoItem{Title: todo.Title, Done: todo.Done, Priority: 2}) + } + } + 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 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) +} diff --git a/agent-tracker/cmd/agent/tracker_cli.go b/agent-tracker/cmd/agent/tracker_cli.go new file mode 100644 index 0000000..46e1852 --- /dev/null +++ b/agent-tracker/cmd/agent/tracker_cli.go @@ -0,0 +1,177 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strings" + + "github.com/david/agent-tracker/internal/ipc" +) + +type trackerTmuxContext struct { + SessionName string + SessionID string + WindowName string + WindowID string + PaneID string +} + +func runTracker(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: agent tracker ") + } + switch args[0] { + case "command": + return runTrackerCommand(args[1:]) + case "state": + return runTrackerState(args[1:]) + default: + return fmt.Errorf("unknown tracker subcommand: %s", args[0]) + } +} + +func runTrackerCommand(args []string) error { + fs := flag.NewFlagSet("agent tracker command", flag.ExitOnError) + var client, session, sessionID, window, windowID, pane, summary string + fs.StringVar(&client, "client", "", "tmux client tty") + fs.StringVar(&session, "session", "", "tmux session name") + fs.StringVar(&sessionID, "session-id", "", "tmux session id") + fs.StringVar(&window, "window", "", "tmux window name") + fs.StringVar(&windowID, "window-id", "", "tmux window id") + fs.StringVar(&pane, "pane", "", "tmux pane id") + fs.StringVar(&summary, "summary", "", "summary or completion note") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + rest := fs.Args() + if len(rest) == 0 { + return fmt.Errorf("command name required") + } + if len(rest) > 1 { + summary = strings.Join(rest[1:], " ") + } + env := ipc.Envelope{ + Client: strings.TrimSpace(client), + Session: strings.TrimSpace(session), + SessionID: strings.TrimSpace(sessionID), + Window: strings.TrimSpace(window), + WindowID: strings.TrimSpace(windowID), + Pane: strings.TrimSpace(pane), + Summary: strings.TrimSpace(summary), + } + if env.Summary != "" { + env.Message = env.Summary + } + command := strings.TrimSpace(rest[0]) + switch command { + case "start_task", "finish_task", "acknowledge", "delete_task": + ctx, err := resolveTrackerContext(env.Session, env.SessionID, env.Window, env.WindowID, env.Pane) + if err != nil { + return err + } + env.Session = ctx.SessionName + env.SessionID = ctx.SessionID + env.Window = ctx.WindowName + env.WindowID = ctx.WindowID + env.Pane = ctx.PaneID + } + return sendTrackerCommand(command, &env) +} + +func runTrackerState(args []string) error { + fs := flag.NewFlagSet("agent tracker state", flag.ExitOnError) + var client string + fs.StringVar(&client, "client", "", "tmux client tty") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return err + } + env, err := trackerLoadState(client) + if err != nil { + return err + } + out := json.NewEncoder(os.Stdout) + out.SetEscapeHTML(false) + return out.Encode(env) +} + +func (c trackerTmuxContext) complete() bool { + return strings.TrimSpace(c.SessionID) != "" && strings.TrimSpace(c.WindowID) != "" && strings.TrimSpace(c.PaneID) != "" +} + +func resolveTrackerContext(sessionName, sessionID, windowName, windowID, paneID string) (trackerTmuxContext, error) { + ctx := trackerTmuxContext{ + SessionName: strings.TrimSpace(sessionName), + SessionID: strings.TrimSpace(sessionID), + WindowName: strings.TrimSpace(windowName), + WindowID: strings.TrimSpace(windowID), + PaneID: strings.TrimSpace(paneID), + } + fetchOrder := []string{} + if ctx.PaneID != "" { + fetchOrder = append(fetchOrder, ctx.PaneID) + } + fetchOrder = append(fetchOrder, "") + for _, target := range fetchOrder { + if ctx.complete() { + break + } + info, err := detectTrackerTmuxContext(target) + if err != nil { + if target == "" { + return trackerTmuxContext{}, err + } + continue + } + ctx = ctx.merge(info) + } + if ctx.SessionID == "" || ctx.WindowID == "" { + return trackerTmuxContext{}, fmt.Errorf("session and window identifiers required") + } + return ctx, nil +} + +func (c trackerTmuxContext) merge(other trackerTmuxContext) trackerTmuxContext { + if c.SessionName == "" { + c.SessionName = other.SessionName + } + if c.SessionID == "" { + c.SessionID = other.SessionID + } + if c.WindowName == "" { + c.WindowName = other.WindowName + } + if c.WindowID == "" { + c.WindowID = other.WindowID + } + if c.PaneID == "" { + c.PaneID = other.PaneID + } + return c +} + +func detectTrackerTmuxContext(target string) (trackerTmuxContext, error) { + args := []string{"display-message", "-p"} + if strings.TrimSpace(target) != "" { + args = append(args, "-t", strings.TrimSpace(target)) + } + args = append(args, "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}") + out, err := runTmuxOutput(args...) + if err != nil { + return trackerTmuxContext{}, err + } + parts := strings.Split(strings.TrimSpace(out), ":::") + if len(parts) != 5 { + return trackerTmuxContext{}, fmt.Errorf("unexpected tmux response: %s", strings.TrimSpace(out)) + } + return trackerTmuxContext{ + SessionName: strings.TrimSpace(parts[0]), + SessionID: strings.TrimSpace(parts[1]), + WindowName: strings.TrimSpace(parts[2]), + WindowID: strings.TrimSpace(parts[3]), + PaneID: strings.TrimSpace(parts[4]), + }, nil +} diff --git a/agent-tracker/cmd/agent/tracker_panel.go b/agent-tracker/cmd/agent/tracker_panel.go new file mode 100644 index 0000000..18841be --- /dev/null +++ b/agent-tracker/cmd/agent/tracker_panel.go @@ -0,0 +1,762 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/david/agent-tracker/internal/ipc" +) + +const ( + trackerTaskStatusInProgress = "in_progress" + trackerTaskStatusCompleted = "completed" +) + +type trackerPanelTickMsg struct{} + +type trackerPanelStateMsg struct { + env *ipc.Envelope + err error +} + +type trackerPanelCommandMsg struct { + message string + err error + close bool +} + +type trackerPanelListState struct { + selected int + offset int +} + +type trackerPanelContext struct { + SessionName string + SessionID string + WindowName string + WindowID string + PaneID string +} + +type trackerPanelModel struct { + runtime *paletteRuntime + currentCtx trackerPanelContext + width int + height int + taskList trackerPanelListState + state ipc.Envelope + loaded bool + message string + refreshedAt time.Time + refreshInFlight bool + pendingRefresh bool + showAltHints bool + helpVisible bool + requestBack bool + requestClose bool +} + +func newTrackerPanelModel(runtime *paletteRuntime) *trackerPanelModel { + model := &trackerPanelModel{runtime: runtime, message: "Loading tracker..."} + model.syncCurrentContext() + return model +} + +func (m *trackerPanelModel) activate() tea.Cmd { + m.requestBack = false + m.requestClose = false + m.syncCurrentContext() + if !m.loaded { + m.message = "Loading tracker..." + } + return tea.Batch(trackerPanelTickCmd(), m.requestRefreshCmd()) +} + +func (m *trackerPanelModel) Init() tea.Cmd { + return m.activate() +} + +func (m *trackerPanelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case trackerPanelTickMsg: + cmds := []tea.Cmd{trackerPanelTickCmd()} + if !m.loaded || time.Since(m.refreshedAt) >= time.Second { + cmds = append(cmds, m.requestRefreshCmd()) + } + return m, tea.Batch(cmds...) + case trackerPanelStateMsg: + m.refreshInFlight = false + if msg.err != nil { + m.message = msg.err.Error() + } else if msg.env != nil { + m.state = *msg.env + m.loaded = true + m.refreshedAt = time.Now() + if text := strings.TrimSpace(msg.env.Message); text != "" { + m.message = text + } + m.syncCurrentContext() + m.clampSelections() + } + if m.pendingRefresh { + m.pendingRefresh = false + return m, m.requestRefreshCmd() + } + return m, nil + case trackerPanelCommandMsg: + if msg.err != nil { + m.message = msg.err.Error() + return m, nil + } + if text := strings.TrimSpace(msg.message); text != "" { + m.message = text + } + if msg.close { + m.requestClose = true + return m, nil + } + return m, m.requestRefreshCmd() + case tea.KeyMsg: + if isAltFooterToggleKey(msg) { + m.showAltHints = !m.showAltHints + return m, nil + } + m.showAltHints = false + return m.updateNormal(msg.String()) + } + return m, nil +} + +func (m *trackerPanelModel) updateNormal(key string) (tea.Model, tea.Cmd) { + switch key { + case "esc", "ctrl+c": + m.requestBack = true + return m, nil + case "?": + m.helpVisible = !m.helpVisible + return m, nil + } + if m.helpVisible { + return m, nil + } + switch key { + case "u", "up", "ctrl+u": + m.moveSelection(-1) + return m, nil + case "e", "down", "ctrl+e": + m.moveSelection(1) + return m, nil + case "enter", "p": + return m.runPrimaryAction() + case "c": + return m.toggleSelected() + case "D": + return m.deleteSelected() + } + return m, nil +} + +func (m *trackerPanelModel) View() string { + return m.render(newPaletteStyles(), m.width, m.height) +} + +func (m *trackerPanelModel) render(styles paletteStyles, width, height int) string { + if width <= 0 { + width = 96 + } + if height <= 0 { + height = 28 + } + contentWidth := maxInt(16, width-2) + contentHeight := maxInt(8, height-7) + contextLine := "Tracker" + if location := m.renderContextLine(); strings.TrimSpace(location) != "" { + contextLine += " · " + location + } + header := lipgloss.JoinVertical(lipgloss.Left, + styles.title.Render("Tracker"), + styles.meta.Render(truncate(contextLine, maxInt(10, contentWidth))), + styles.muted.Render(truncate(m.renderMetricsLine(), maxInt(10, contentWidth))), + ) + var body string + if m.helpVisible { + body = m.renderHelp(styles, contentWidth, contentHeight) + } else { + body = m.renderTasks(styles, contentWidth, contentHeight) + } + footer := m.renderFooter(styles, contentWidth) + view := lipgloss.JoinVertical(lipgloss.Left, header, "", body, "", footer) + return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view) +} + +func (m *trackerPanelModel) renderTasks(styles paletteStyles, width, height int) string { + list := m.visibleTasks() + if width < 84 { + return m.renderTaskListSection(styles, width, height, list, "Queue", "Across your active tracker feed") + } + leftWidth := maxInt(34, width*56/100) + rightWidth := maxInt(24, width-leftWidth-5) + listSection := m.renderTaskListSection(styles, leftWidth, height, list, "Queue", "What still needs attention") + detailSection := m.renderTaskDetailSection(styles, rightWidth, height) + divider := styles.muted.Render(renderVerticalDivider(height)) + return lipgloss.JoinHorizontal(lipgloss.Top, listSection, " ", divider, " ", detailSection) +} + +func (m *trackerPanelModel) renderTaskListSection(styles paletteStyles, width, height int, tasks []ipc.Task, title, meta string) string { + contentHeight := maxInt(1, height-3) + entryHeight := 3 + entriesPerPage := maxInt(1, contentHeight/entryHeight) + selected := clampInt(m.taskList.selected, 0, maxInt(0, len(tasks)-1)) + offset := stableListOffset(m.taskList.offset, selected, entriesPerPage, len(tasks)) + m.taskList.selected = selected + m.taskList.offset = offset + rows := []string{} + if len(tasks) == 0 { + rows = append(rows, trackerEmptyState(styles, "No tasks in motion.")) + } else { + now := time.Now() + for idx := offset; idx < len(tasks) && idx < offset+entriesPerPage; idx++ { + rows = append(rows, m.renderTaskRow(styles, tasks[idx], idx == selected, width, now)) + } + } + sectionMeta := fmt.Sprintf("%d tasks", len(tasks)) + if strings.TrimSpace(meta) != "" { + sectionMeta += " · " + meta + } + return trackerRenderSection(styles, title, sectionMeta, strings.Join(rows, "\n"), width, height) +} + +func (m *trackerPanelModel) renderTaskRow(styles paletteStyles, task ipc.Task, selected bool, width int, now time.Time) string { + selectedBG := lipgloss.Color("238") + rowWidth := maxInt(16, width) + titleStyle := styles.itemTitle + metaStyle := styles.itemSubtitle + indicator := trackerTaskIndicator(task, now) + indicatorStyle := styles.selectedLabel + if task.Status == trackerTaskStatusCompleted { + indicatorStyle = styles.statusBad + titleStyle = styles.itemTitle.Copy().Foreground(lipgloss.Color("246")) + metaStyle = styles.itemSubtitle.Copy().Foreground(lipgloss.Color("246")) + if task.Acknowledged { + indicatorStyle = styles.todoCheckDone + } + } + padStyle := lipgloss.NewStyle() + if selected { + titleStyle = titleStyle.Copy().Foreground(lipgloss.Color("230")).Background(selectedBG) + metaStyle = metaStyle.Copy().Foreground(lipgloss.Color("251")).Background(selectedBG) + indicatorStyle = indicatorStyle.Copy().Background(selectedBG) + padStyle = padStyle.Copy().Foreground(lipgloss.Color("230")).Background(selectedBG) + } + duration := trackerLiveDuration(task, now) + meta := trackerFirstNonEmpty(strings.TrimSpace(task.Session), "Session") + if strings.TrimSpace(task.Window) != "" { + meta += " / " + strings.TrimSpace(task.Window) + } + if task.Status == trackerTaskStatusCompleted && !task.Acknowledged { + meta += " · awaiting review" + } + if duration != "" { + meta = strings.TrimSpace(meta + " · " + duration) + } + titleText := truncate(firstPaletteLine(task.Summary), maxInt(1, rowWidth-4-lipgloss.Width(indicator))) + line1RawWidth := 1 + lipgloss.Width(indicator) + 1 + lipgloss.Width(titleText) + line1Pad := maxInt(0, rowWidth-line1RawWidth) + line1 := padStyle.Render(" ") + indicatorStyle.Render(indicator) + padStyle.Render(" ") + titleStyle.Render(titleText) + padStyle.Render(strings.Repeat(" ", line1Pad)) + metaText := truncate(meta, maxInt(1, rowWidth-3)) + line2Pad := maxInt(0, rowWidth-2-lipgloss.Width(metaText)) + line2 := padStyle.Render(" ") + metaStyle.Render(metaText) + padStyle.Render(strings.Repeat(" ", line2Pad)) + line3 := padStyle.Render(strings.Repeat(" ", rowWidth)) + return lipgloss.JoinVertical(lipgloss.Left, line1, line2, line3) +} + +func (m *trackerPanelModel) renderTaskDetailSection(styles paletteStyles, width, height int) string { + task := m.selectedTask() + if task == nil { + return trackerRenderSection(styles, "Selected", "Open a task to jump into its tmux pane", trackerEmptyState(styles, "Nothing is selected."), width, height) + } + status := "Live" + if task.Status == trackerTaskStatusCompleted { + if task.Acknowledged { + status = "Done" + } else { + status = "Needs review" + } + } + meta := trackerFirstNonEmpty(strings.TrimSpace(task.Session), task.SessionID) + if strings.TrimSpace(task.Window) != "" { + meta += " / " + strings.TrimSpace(task.Window) + } + lines := []string{ + trackerRenderWrappedText(styles.panelText.Copy().Bold(true), firstPaletteLine(task.Summary), maxInt(10, width)), + "", + trackerDetailLine(styles, "status", status, width), + trackerDetailLine(styles, "window", meta, width), + trackerDetailLine(styles, "elapsed", trackerLiveDuration(*task, time.Now()), width), + } + if note := strings.TrimSpace(task.CompletionNote); note != "" { + lines = append(lines, "", styles.muted.Render("note"), trackerRenderWrappedText(styles.panelTextDone, note, maxInt(10, width))) + } + return trackerRenderSection(styles, "Selected", "Enter opens the highlighted pane", strings.Join(lines, "\n"), width, height) +} + +func (m *trackerPanelModel) renderHelp(styles paletteStyles, width, height int) string { + lines := []string{ + "u and e move through tasks. enter opens the highlighted tmux pane.", + "c settles a task. shift-d deletes it. esc returns to the command palette.", + } + styled := make([]string, 0, len(lines)) + for _, line := range lines { + styled = append(styled, styles.panelText.Render(truncate(line, maxInt(10, width-4)))) + } + return trackerRenderSection(styles, "Guide", "Task-only tracker", strings.Join(styled, "\n\n"), width, height) +} + +func (m *trackerPanelModel) renderFooter(styles paletteStyles, width int) string { + renderSegments := func(pairs [][2]string) string { + return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs) + } + footer := "" + if m.showAltHints { + footer = pickRenderedShortcutFooter(width, renderSegments, + [][2]string{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, + [][2]string{{"Alt-S", "close"}}, + ) + } else { + footer = pickRenderedShortcutFooter(width, renderSegments, + [][2]string{{"u/e", "move"}, {"Enter", "open"}, {"c", "settle"}, {"Shift-D", "delete"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + [][2]string{{"u/e", "move"}, {"Enter", "open"}, {"c", "settle"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, + [][2]string{{"Esc", "back"}, {footerHintToggleKey, "more"}}, + ) + } + if lipgloss.Width(footer) > width { + return styles.muted.Copy().Width(width).Render(truncate(strings.TrimSpace(m.currentStatus()), width)) + } + status := strings.TrimSpace(m.currentStatus()) + if status != "" && status != "Tracker" && status != "Loading tracker..." { + footer = strings.TrimSpace(status) + " " + footer + } + return lipgloss.NewStyle().Width(width).Render(footer) +} + +func trackerRenderSection(styles paletteStyles, title, meta, content string, width, height int) string { + header := []string{styles.panelTitle.Render(title)} + if strings.TrimSpace(meta) != "" { + header = append(header, styles.meta.Render(truncate(meta, maxInt(10, width)))) + } + body := lipgloss.JoinVertical(lipgloss.Left, strings.Join(header, "\n"), "", content) + return lipgloss.NewStyle().Width(width).Height(height).Render(body) +} + +func trackerEmptyState(styles paletteStyles, text string) string { + return styles.muted.Render(text) +} + +func trackerDetailLine(styles paletteStyles, label, value string, width int) string { + label = strings.TrimSpace(label) + value = strings.TrimSpace(value) + if value == "" { + value = "-" + } + labelWidth := 10 + contentWidth := maxInt(10, width-labelWidth-1) + parts := wrapText(value, contentWidth) + if len(parts) == 0 { + parts = []string{value} + } + lines := []string{styles.muted.Copy().Width(labelWidth).Render(label+":") + styles.panelText.Render(truncate(parts[0], contentWidth))} + indent := strings.Repeat(" ", labelWidth) + for _, part := range parts[1:] { + lines = append(lines, indent+styles.panelText.Render(truncate(part, contentWidth))) + } + return strings.Join(lines, "\n") +} + +func trackerRenderWrappedText(style lipgloss.Style, text string, width int) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + parts := wrapText(text, maxInt(10, width)) + if len(parts) == 0 { + parts = []string{text} + } + lines := make([]string, 0, len(parts)) + for _, part := range parts { + lines = append(lines, style.Render(truncate(part, maxInt(10, width)))) + } + return strings.Join(lines, "\n") +} + +func (m *trackerPanelModel) renderContextLine() string { + parts := []string{} + if strings.TrimSpace(m.currentCtx.SessionName) != "" { + parts = append(parts, strings.TrimSpace(m.currentCtx.SessionName)) + } + if strings.TrimSpace(m.currentCtx.WindowName) != "" { + parts = append(parts, strings.TrimSpace(m.currentCtx.WindowName)) + } + if len(parts) == 0 { + return "No tmux context detected" + } + return strings.Join(parts, " · ") +} + +func (m *trackerPanelModel) renderMetricsLine() string { + active := 0 + review := 0 + for _, task := range m.state.Tasks { + switch task.Status { + case trackerTaskStatusInProgress: + active++ + case trackerTaskStatusCompleted: + if !task.Acknowledged { + review++ + } + } + } + return fmt.Sprintf("%d live · %d review", active, review) +} + +func (m *trackerPanelModel) currentStatus() string { + if text := strings.TrimSpace(m.message); text != "" { + return text + } + if text := strings.TrimSpace(m.state.Message); text != "" { + return text + } + return "Tracker" +} + +func (m *trackerPanelModel) syncCurrentContext() { + if m.runtime == nil { + return + } + m.currentCtx = m.runtime.currentTrackerContext() +} + +func (m *trackerPanelModel) requestRefreshCmd() tea.Cmd { + if m.refreshInFlight { + m.pendingRefresh = true + return nil + } + m.refreshInFlight = true + return func() tea.Msg { + env, err := trackerLoadState("") + return trackerPanelStateMsg{env: env, err: err} + } +} + +func (m *trackerPanelModel) moveSelection(delta int) { + list := m.visibleTasks() + m.taskList.selected = clampInt(m.taskList.selected+delta, 0, maxInt(0, len(list)-1)) +} + +func (m *trackerPanelModel) runPrimaryAction() (tea.Model, tea.Cmd) { + task := m.selectedTask() + if task == nil { + return m, nil + } + return m, trackerPanelCommandFunc(func() error { + return focusTrackerTask(*task) + }, true) +} + +func (m *trackerPanelModel) toggleSelected() (tea.Model, tea.Cmd) { + task := m.selectedTask() + if task == nil { + return m, nil + } + return m, trackerPanelCommandCmd(m.toggleTask(*task), "Task updated") +} + +func (m *trackerPanelModel) deleteSelected() (tea.Model, tea.Cmd) { + task := m.selectedTask() + if task == nil { + return m, nil + } + return m, trackerPanelCommandCmd(m.deleteTask(*task), "Task deleted") +} + +func (m *trackerPanelModel) toggleTask(task ipc.Task) error { + env := ipc.Envelope{Session: task.Session, SessionID: task.SessionID, Window: task.Window, WindowID: task.WindowID, Pane: task.Pane} + command := "acknowledge" + if task.Status == trackerTaskStatusInProgress { + command = "finish_task" + } + return sendTrackerCommand(command, &env) +} + +func (m *trackerPanelModel) deleteTask(task ipc.Task) error { + env := ipc.Envelope{Session: task.Session, SessionID: task.SessionID, Window: task.Window, WindowID: task.WindowID, Pane: task.Pane} + return sendTrackerCommand("delete_task", &env) +} + +func (m *trackerPanelModel) clampSelections() { + m.taskList.selected = clampInt(m.taskList.selected, 0, maxInt(0, len(m.visibleTasks())-1)) +} + +func (m *trackerPanelModel) visibleTasks() []ipc.Task { + result := append([]ipc.Task(nil), m.state.Tasks...) + trackerSortTasks(result) + return result +} + +func (m *trackerPanelModel) selectedTask() *ipc.Task { + tasks := m.visibleTasks() + if len(tasks) == 0 || m.taskList.selected < 0 || m.taskList.selected >= len(tasks) { + return nil + } + task := tasks[m.taskList.selected] + return &task +} + +func (r *paletteRuntime) currentTrackerContext() trackerPanelContext { + ctx := trackerPanelContext{ + SessionName: strings.TrimSpace(r.currentSessionName), + WindowName: strings.TrimSpace(r.currentWindowName), + WindowID: strings.TrimSpace(r.windowID), + } + args := []string{"display-message", "-p"} + if strings.TrimSpace(r.windowID) != "" { + args = append(args, "-t", strings.TrimSpace(r.windowID)) + } + args = append(args, "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}") + out, err := runTmuxOutput(args...) + if err != nil { + return ctx + } + parts := strings.Split(strings.TrimSpace(out), ":::") + if len(parts) != 5 { + return ctx + } + ctx.SessionName = trackerFirstNonEmpty(strings.TrimSpace(parts[0]), ctx.SessionName) + ctx.SessionID = strings.TrimSpace(parts[1]) + ctx.WindowName = trackerFirstNonEmpty(strings.TrimSpace(parts[2]), ctx.WindowName) + ctx.WindowID = trackerFirstNonEmpty(strings.TrimSpace(parts[3]), ctx.WindowID) + ctx.PaneID = strings.TrimSpace(parts[4]) + return ctx +} + +func trackerPanelTickCmd() tea.Cmd { + return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return trackerPanelTickMsg{} }) +} + +func trackerPanelCommandCmd(err error, message string) tea.Cmd { + return func() tea.Msg { return trackerPanelCommandMsg{message: message, err: err} } +} + +func trackerPanelCommandFunc(fn func() error, close bool) tea.Cmd { + return func() tea.Msg { return trackerPanelCommandMsg{err: fn(), close: close} } +} + +func trackerLoadState(client string) (*ipc.Envelope, error) { + conn, err := net.Dial("unix", trackerSocketPath()) + if err != nil { + return nil, err + } + defer conn.Close() + enc := json.NewEncoder(conn) + dec := json.NewDecoder(bufio.NewReader(conn)) + if err := enc.Encode(&ipc.Envelope{Kind: "ui-register", Client: strings.TrimSpace(client)}); err != nil { + return nil, err + } + for { + var env ipc.Envelope + if err := dec.Decode(&env); err != nil { + return nil, err + } + if env.Kind == "state" { + return &env, nil + } + } +} + +func sendTrackerCommand(command string, env *ipc.Envelope) error { + conn, err := net.Dial("unix", trackerSocketPath()) + if err != nil { + return err + } + defer conn.Close() + request := ipc.Envelope{Kind: "command", Command: strings.TrimSpace(command)} + if env != nil { + request.Client = strings.TrimSpace(env.Client) + request.Session = strings.TrimSpace(env.Session) + request.SessionID = strings.TrimSpace(env.SessionID) + request.Window = strings.TrimSpace(env.Window) + request.WindowID = strings.TrimSpace(env.WindowID) + request.Pane = strings.TrimSpace(env.Pane) + request.Summary = strings.TrimSpace(env.Summary) + request.Message = strings.TrimSpace(env.Message) + } + enc := json.NewEncoder(conn) + if err := enc.Encode(&request); err != nil { + return err + } + dec := json.NewDecoder(bufio.NewReader(conn)) + for { + var reply ipc.Envelope + if err := dec.Decode(&reply); err != nil { + return err + } + if reply.Kind == "ack" { + return nil + } + } +} + +func trackerSocketPath() string { + if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { + return filepath.Join(dir, "agent-tracker.sock") + } + return filepath.Join(os.TempDir(), "agent-tracker.sock") +} + +func trackerSortTasks(tasks []ipc.Task) { + sort.SliceStable(tasks, func(i, j int) bool { + left, right := tasks[i], tasks[j] + leftRank, rightRank := trackerTaskStatusRank(left.Status), trackerTaskStatusRank(right.Status) + if leftRank != rightRank { + return leftRank < rightRank + } + switch left.Status { + case trackerTaskStatusInProgress: + return left.StartedAt < right.StartedAt + case trackerTaskStatusCompleted: + if left.Acknowledged != right.Acknowledged { + return !left.Acknowledged && right.Acknowledged + } + li, hasLi := trackerParseTimestamp(left.CompletedAt) + rj, hasRj := trackerParseTimestamp(right.CompletedAt) + if hasLi && hasRj && !li.Equal(rj) { + return li.After(rj) + } + if hasLi != hasRj { + return hasLi + } + } + li, hasLi := trackerParseTimestamp(left.StartedAt) + rj, hasRj := trackerParseTimestamp(right.StartedAt) + if hasLi && hasRj && !li.Equal(rj) { + return li.After(rj) + } + if hasLi != hasRj { + return hasLi + } + return left.Summary < right.Summary + }) +} + +func trackerTaskStatusRank(status string) int { + switch status { + case trackerTaskStatusInProgress: + return 0 + case trackerTaskStatusCompleted: + return 1 + default: + return 2 + } +} + +func trackerParseTimestamp(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + ts, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, false + } + return ts, true +} + +func trackerTaskIndicator(task ipc.Task, now time.Time) string { + switch task.Status { + case trackerTaskStatusInProgress: + frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} + return string(frames[int(now.UnixNano()/int64(100*time.Millisecond))%len(frames)]) + case trackerTaskStatusCompleted: + if task.Acknowledged { + return "✓" + } + return "⚑" + default: + return "•" + } +} + +func trackerLiveDuration(task ipc.Task, now time.Time) string { + start, ok := trackerParseTimestamp(task.StartedAt) + if !ok { + return trackerFormatDuration(task.DurationSeconds) + } + if task.Status == trackerTaskStatusCompleted { + if end, ok := trackerParseTimestamp(task.CompletedAt); ok { + return trackerFormatDuration(end.Sub(start).Seconds()) + } + return trackerFormatDuration(task.DurationSeconds) + } + return trackerFormatDuration(now.Sub(start).Seconds()) +} + +func trackerFormatDuration(seconds float64) string { + if seconds < 0 { + seconds = 0 + } + d := time.Duration(seconds * float64(time.Second)) + if d >= 99*time.Hour { + return ">=99h" + } + hours := d / time.Hour + minutes := (d % time.Hour) / time.Minute + secondsPart := (d % time.Minute) / time.Second + if hours > 0 { + return fmt.Sprintf("%02dh%02dm", hours, minutes) + } + if minutes > 0 { + return fmt.Sprintf("%02dm%02ds", minutes, secondsPart) + } + return fmt.Sprintf("%02ds", secondsPart) +} + +func focusTrackerTask(task ipc.Task) error { + if strings.TrimSpace(task.SessionID) == "" { + return fmt.Errorf("session required to focus task") + } + if err := runTmux("switch-client", "-t", strings.TrimSpace(task.SessionID)); err != nil { + return err + } + if strings.TrimSpace(task.WindowID) != "" { + if err := runTmux("select-window", "-t", strings.TrimSpace(task.WindowID)); err != nil { + return err + } + } + if strings.TrimSpace(task.Pane) != "" { + if err := runTmux("select-pane", "-t", strings.TrimSpace(task.Pane)); err != nil { + return err + } + } + return nil +} + +func trackerFirstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/agent-tracker/cmd/agent/tracker_panel_test.go b/agent-tracker/cmd/agent/tracker_panel_test.go new file mode 100644 index 0000000..4471756 --- /dev/null +++ b/agent-tracker/cmd/agent/tracker_panel_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/david/agent-tracker/internal/ipc" +) + +func TestPaletteAltROpensTrackerPanel(t *testing.T) { + model := newPaletteModel(&paletteRuntime{}, paletteUIState{Mode: paletteModeList}) + + updated, cmd := model.updateList("alt+r") + if cmd == nil { + t.Fatalf("expected tracker shortcut to return a command") + } + + palette := updated.(*paletteModel) + if palette.state.Mode != paletteModeTracker { + t.Fatalf("expected tracker mode, got %v", palette.state.Mode) + } + if palette.tracker == nil { + t.Fatalf("expected tracker panel to be created") + } +} + +func TestTrackerSortTasksPrioritizesActiveAndUnacknowledged(t *testing.T) { + tasks := []ipc.Task{ + {SessionID: "$1", WindowID: "@1", Pane: "%1", Summary: "ack", Status: trackerTaskStatusCompleted, Acknowledged: true, CompletedAt: "2026-03-23T18:00:00Z"}, + {SessionID: "$1", WindowID: "@2", Pane: "%2", Summary: "active", Status: trackerTaskStatusInProgress, StartedAt: "2026-03-23T17:00:00Z"}, + {SessionID: "$1", WindowID: "@3", Pane: "%3", Summary: "review", Status: trackerTaskStatusCompleted, Acknowledged: false, CompletedAt: "2026-03-23T19:00:00Z"}, + } + + trackerSortTasks(tasks) + if tasks[0].Summary != "active" { + t.Fatalf("expected active task first, got %#v", tasks) + } + if tasks[1].Summary != "review" { + t.Fatalf("expected unacknowledged completed task second, got %#v", tasks) + } +} + +func TestTrackerDetailLineWrapsWithinPanel(t *testing.T) { + line := trackerDetailLine(newPaletteStyles(), "where", "session-name / a-very-long-window-name-that-needs-wrapping", 26) + if !strings.Contains(line, "\n") { + t.Fatalf("expected wrapped detail line, got %q", line) + } +} + +func TestTrackerTaskRowUsesIndicatorInsteadOfLiveDoneLabel(t *testing.T) { + width := 36 + row := (&trackerPanelModel{}).renderTaskRow(newPaletteStyles(), ipc.Task{Summary: "Build release", Status: trackerTaskStatusInProgress}, true, width, mustTrackerTime(t, "2026-03-23T20:00:00Z")) + if strings.Contains(row, "LIVE") || strings.Contains(row, "DONE") || strings.Contains(row, "REVIEW") { + t.Fatalf("expected indicator-based tracker row, got %q", row) + } + for _, line := range strings.Split(row, "\n") { + if lipgloss.Width(line) > width { + t.Fatalf("expected rendered task row width <= %d, got %d for %q", width, lipgloss.Width(line), line) + } + } + + doneRow := (&trackerPanelModel{}).renderTaskRow(newPaletteStyles(), ipc.Task{Summary: "Build release", Status: trackerTaskStatusCompleted, Acknowledged: true}, false, width, mustTrackerTime(t, "2026-03-23T20:00:00Z")) + if !strings.Contains(doneRow, "✓") { + t.Fatalf("expected completed task row to use checkmark indicator, got %q", doneRow) + } +} + +func TestTrackerVisibleTasksReturnsSortedTasks(t *testing.T) { + model := &trackerPanelModel{state: ipc.Envelope{Tasks: []ipc.Task{ + {Summary: "done", Status: trackerTaskStatusCompleted, Acknowledged: true, CompletedAt: "2026-03-23T18:00:00Z"}, + {Summary: "active", Status: trackerTaskStatusInProgress, StartedAt: "2026-03-23T17:00:00Z"}, + {Summary: "review", Status: trackerTaskStatusCompleted, Acknowledged: false, CompletedAt: "2026-03-23T19:00:00Z"}, + }}} + visible := model.visibleTasks() + if len(visible) != 3 { + t.Fatalf("expected 3 visible tasks, got %d", len(visible)) + } + if visible[0].Summary != "active" || visible[1].Summary != "review" { + t.Fatalf("expected visible tasks to stay sorted by tracker priority, got %#v", visible) + } +} + +func TestTrackerTaskRowKeepsSameHeightWhenSelected(t *testing.T) { + styles := newPaletteStyles() + task := ipc.Task{Summary: "Build release", Status: trackerTaskStatusCompleted, Acknowledged: false, CompletionNote: "this should stay in the detail pane only"} + selected := (&trackerPanelModel{}).renderTaskRow(styles, task, true, 36, mustTrackerTime(t, "2026-03-23T20:00:00Z")) + unselected := (&trackerPanelModel{}).renderTaskRow(styles, task, false, 36, mustTrackerTime(t, "2026-03-23T20:00:00Z")) + if strings.Count(selected, "\n") != strings.Count(unselected, "\n") { + t.Fatalf("expected selected and unselected task rows to keep same height, got %q vs %q", selected, unselected) + } +} + +func mustTrackerTime(t *testing.T, value string) time.Time { + t.Helper() + ts, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse time: %v", err) + } + return ts +} diff --git a/agent-tracker/cmd/tracker-client/main.go b/agent-tracker/cmd/tracker-client/main.go deleted file mode 100644 index 8e0db68..0000000 --- a/agent-tracker/cmd/tracker-client/main.go +++ /dev/null @@ -1,2247 +0,0 @@ -package main - -import ( - "bufio" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "net" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "sync" - "time" - "unicode" - "unicode/utf8" - - "github.com/david/agent-tracker/internal/ipc" - "github.com/gdamore/tcell/v2" -) - -const ( - statusInProgress = "in_progress" - statusCompleted = "completed" -) - -var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} - -const spinnerInterval = 120 * time.Millisecond - -type viewMode int - -const ( - viewTracker viewMode = iota - viewNotes - viewArchive - viewEdit -) - -type noteScope string - -const ( - scopeWindow noteScope = "window" - scopeSession noteScope = "session" - scopeAll noteScope = "all" -) - -type listState struct { - selected int - offset int -} - -type promptMode string - -const ( - promptAddNote promptMode = "add_note" - promptEditNote promptMode = "edit_note" - promptAddGoal promptMode = "add_goal" -) - -type promptState struct { - active bool - mode promptMode - text []rune - cursor int - noteID string -} - -func main() { - log.SetFlags(0) - if len(os.Args) < 2 { - if err := runUI(os.Args[1:]); err != nil { - log.Fatal(err) - } - return - } - cmd := os.Args[1] - args := os.Args[2:] - switch cmd { - case "ui": - if err := runUI(args); err != nil { - log.Fatal(err) - } - case "command": - if err := runCommand(args); err != nil { - log.Fatal(err) - } - case "state": - if err := runState(args); err != nil { - log.Fatal(err) - } - default: - if err := runUI(os.Args[1:]); err != nil { - log.Fatal(err) - } - } -} - -func runCommand(args []string) error { - fs := flag.NewFlagSet("tracker-client command", flag.ExitOnError) - var client, session, sessionID, window, windowID, pane, summary, scope, noteID string - fs.StringVar(&client, "client", "", "tmux client tty") - fs.StringVar(&session, "session", "", "tmux session name") - fs.StringVar(&sessionID, "session-id", "", "tmux session id") - fs.StringVar(&window, "window", "", "tmux window name") - fs.StringVar(&windowID, "window-id", "", "tmux window id") - fs.StringVar(&pane, "pane", "", "tmux pane id") - fs.StringVar(&summary, "summary", "", "summary or note payload") - fs.StringVar(&scope, "scope", "", "note scope") - fs.StringVar(¬eID, "note-id", "", "note identifier") - if err := fs.Parse(args); err != nil { - return err - } - rest := fs.Args() - if len(rest) == 0 { - return fmt.Errorf("command name required") - } - if len(rest) > 1 { - summary = strings.Join(rest[1:], " ") - } - - env := ipc.Envelope{ - Kind: "command", - Command: rest[0], - Client: client, - Session: strings.TrimSpace(session), - SessionID: strings.TrimSpace(sessionID), - Window: strings.TrimSpace(window), - WindowID: strings.TrimSpace(windowID), - Pane: strings.TrimSpace(pane), - Scope: strings.TrimSpace(scope), - NoteID: strings.TrimSpace(noteID), - Summary: strings.TrimSpace(summary), - } - if env.Summary != "" { - env.Message = env.Summary - } - - switch env.Command { - case "start_task", "finish_task", "acknowledge", "note_add", "note_archive_pane", "note_attach": - ctx, err := resolveContext(env.Session, env.SessionID, env.Window, env.WindowID, env.Pane) - if err != nil { - return err - } - env.Session = ctx.SessionName - env.SessionID = ctx.SessionID - env.Window = ctx.WindowName - env.WindowID = ctx.WindowID - env.Pane = ctx.PaneID - } - - conn, err := net.Dial("unix", socketPath()) - if err != nil { - return err - } - defer conn.Close() - - enc := json.NewEncoder(conn) - if err := enc.Encode(&env); err != nil { - return err - } - - dec := json.NewDecoder(conn) - for { - var reply ipc.Envelope - if err := dec.Decode(&reply); err != nil { - return err - } - if reply.Kind == "ack" { - return nil - } - } -} - -func runState(args []string) error { - fs := flag.NewFlagSet("tracker-client state", flag.ExitOnError) - var client string - fs.StringVar(&client, "client", "", "tmux client tty") - if err := fs.Parse(args); err != nil { - return err - } - - conn, err := net.Dial("unix", socketPath()) - if err != nil { - return err - } - defer conn.Close() - - enc := json.NewEncoder(conn) - dec := json.NewDecoder(bufio.NewReader(conn)) - - if err := enc.Encode(&ipc.Envelope{Kind: "ui-register", Client: client}); err != nil { - return err - } - - for { - var env ipc.Envelope - if err := dec.Decode(&env); err != nil { - return err - } - if env.Kind == "state" { - out := json.NewEncoder(os.Stdout) - out.SetEscapeHTML(false) - if err := out.Encode(&env); err != nil { - return err - } - return nil - } - } -} - -type tmuxContext struct { - SessionName string - SessionID string - WindowName string - WindowID string - PaneID string -} - -func (c tmuxContext) complete() bool { - return strings.TrimSpace(c.SessionID) != "" && - strings.TrimSpace(c.WindowID) != "" && - strings.TrimSpace(c.PaneID) != "" -} - -func resolveContext(sessionName, sessionID, windowName, windowID, paneID string) (tmuxContext, error) { - ctx := tmuxContext{ - SessionName: strings.TrimSpace(sessionName), - SessionID: strings.TrimSpace(sessionID), - WindowName: strings.TrimSpace(windowName), - WindowID: strings.TrimSpace(windowID), - PaneID: strings.TrimSpace(paneID), - } - - fetchOrder := []string{} - if ctx.PaneID != "" { - fetchOrder = append(fetchOrder, ctx.PaneID) - } - fetchOrder = append(fetchOrder, "") - - for _, target := range fetchOrder { - if ctx.complete() { - break - } - info, err := detectTmuxContext(target) - if err != nil { - if target == "" { - return tmuxContext{}, err - } - continue - } - ctx = ctx.merge(info) - } - - if ctx.SessionID == "" || ctx.WindowID == "" { - return tmuxContext{}, fmt.Errorf("session and window identifiers required") - } - - if ctx.SessionName == "" || ctx.WindowName == "" { - if info, err := detectTmuxContext(ctx.WindowID); err == nil { - ctx = ctx.merge(info) - } - } - - if ctx.SessionName == "" { - ctx.SessionName = ctx.SessionID - } - if ctx.WindowName == "" { - ctx.WindowName = ctx.WindowID - } - - return ctx, nil -} - -func (c tmuxContext) merge(other tmuxContext) tmuxContext { - if c.SessionName == "" { - c.SessionName = other.SessionName - } - if c.SessionID == "" { - c.SessionID = other.SessionID - } - if c.WindowName == "" { - c.WindowName = other.WindowName - } - if c.WindowID == "" { - c.WindowID = other.WindowID - } - if c.PaneID == "" { - c.PaneID = other.PaneID - } - return c -} - -func detectTmuxContext(target string) (tmuxContext, error) { - format := "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}" - args := []string{"display-message", "-p"} - if strings.TrimSpace(target) != "" { - args = append(args, "-t", strings.TrimSpace(target)) - } - args = append(args, format) - cmd := exec.Command("tmux", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return tmuxContext{}, fmt.Errorf("tmux display-message: %w (%s)", err, strings.TrimSpace(string(output))) - } - parts := strings.Split(strings.TrimSpace(string(output)), ":::") - if len(parts) != 5 { - return tmuxContext{}, fmt.Errorf("unexpected tmux response: %s", strings.TrimSpace(string(output))) - } - return tmuxContext{ - SessionName: strings.TrimSpace(parts[0]), - SessionID: strings.TrimSpace(parts[1]), - WindowName: strings.TrimSpace(parts[2]), - WindowID: strings.TrimSpace(parts[3]), - PaneID: strings.TrimSpace(parts[4]), - }, nil -} - -func detectTmuxContextForClient(client string) (tmuxContext, error) { - if pane := strings.TrimSpace(os.Getenv("TMUX_PANE")); pane != "" { - if ctx, err := detectTmuxContext(pane); err == nil { - return ctx, nil - } - } - format := "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}" - args := []string{"display-message", "-p"} - if strings.TrimSpace(client) != "" { - args = append(args, "-c", strings.TrimSpace(client)) - } - args = append(args, format) - cmd := exec.Command("tmux", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return tmuxContext{}, fmt.Errorf("tmux display-message: %w (%s)", err, strings.TrimSpace(string(output))) - } - parts := strings.Split(strings.TrimSpace(string(output)), ":::") - if len(parts) != 5 { - return tmuxContext{}, fmt.Errorf("unexpected tmux response: %s", strings.TrimSpace(string(output))) - } - return tmuxContext{ - SessionName: strings.TrimSpace(parts[0]), - SessionID: strings.TrimSpace(parts[1]), - WindowName: strings.TrimSpace(parts[2]), - WindowID: strings.TrimSpace(parts[3]), - PaneID: strings.TrimSpace(parts[4]), - }, nil -} - -func runUI(args []string) error { - fs := flag.NewFlagSet("tracker-client ui", flag.ExitOnError) - var client string - var originSession, originSessionID, originWindow, originWindowID, originPane string - fs.StringVar(&client, "client", "", "tmux client tty") - fs.StringVar(&originSession, "origin-session", "", "origin session name") - fs.StringVar(&originSessionID, "origin-session-id", "", "origin session id") - fs.StringVar(&originWindow, "origin-window", "", "origin window name") - fs.StringVar(&originWindowID, "origin-window-id", "", "origin window id") - fs.StringVar(&originPane, "origin-pane", "", "origin pane id") - if err := fs.Parse(args); err != nil { - return err - } - - conn, err := net.Dial("unix", socketPath()) - if err != nil { - return err - } - defer conn.Close() - - screen, err := tcell.NewScreen() - if err != nil { - return err - } - if err := screen.Init(); err != nil { - return err - } - defer screen.Fini() - screen.Clear() - - enc := json.NewEncoder(conn) - dec := json.NewDecoder(bufio.NewReader(conn)) - - if err := enc.Encode(&ipc.Envelope{Kind: "ui-register", Client: client}); err != nil { - return err - } - - type state struct { - message string - tasks []ipc.Task - notes []ipc.Note - archived []ipc.Note - goals []ipc.Goal - } - st := state{message: "Connecting to tracker…"} - - originCtx := tmuxContext{ - SessionName: strings.TrimSpace(originSession), - SessionID: strings.TrimSpace(originSessionID), - WindowName: strings.TrimSpace(originWindow), - WindowID: strings.TrimSpace(originWindowID), - PaneID: strings.TrimSpace(originPane), - } - - currentCtx := originCtx - refreshCtx := func() { - if originCtx.complete() { - currentCtx = originCtx - return - } - if ctx, err := detectTmuxContextForClient(client); err == nil { - currentCtx = ctx - } - } - refreshCtx() - - incoming := make(chan ipc.Envelope) - errCh := make(chan error, 1) - go func() { - for { - var env ipc.Envelope - if err := dec.Decode(&env); err != nil { - errCh <- err - close(incoming) - return - } - incoming <- env - } - }() - - events := make(chan tcell.Event) - go func() { - for { - ev := screen.PollEvent() - if ev == nil { - close(events) - return - } - events <- ev - } - }() - - ticker := time.NewTicker(spinnerInterval) - defer ticker.Stop() - - var encMu sync.Mutex - sendCommand := func(name string, opts ...func(*ipc.Envelope)) error { - encMu.Lock() - defer encMu.Unlock() - env := ipc.Envelope{Kind: "command", Command: name, Client: client} - for _, opt := range opts { - opt(&env) - } - return enc.Encode(&env) - } - - mode := viewNotes - scope := scopeWindow - showCompletedTasks := true - showCompletedNotes := false - showCompletedArchive := false - taskList := listState{} - noteList := listState{} - archiveList := listState{} - goalList := listState{} - keepTasksVisible := make(map[string]bool) - keepNotesVisible := make(map[string]bool) - prompt := promptState{} - helpVisible := false - focusGoals := false - var editNote *ipc.Note - - cycleScope := func(forward bool, wrap bool) { - order := []noteScope{scopeWindow, scopeSession, scopeAll} - pos := 0 - for i, s := range order { - if scope == s { - pos = i - break - } - } - if forward { - if pos < len(order)-1 { - scope = order[pos+1] - } else if wrap { - scope = order[0] - } - return - } - if pos > 0 { - scope = order[pos-1] - } else if wrap { - scope = order[len(order)-1] - } - } - - scopeStyle := func(s noteScope) tcell.Style { - switch s { - case scopeWindow: - return tcell.StyleDefault.Foreground(tcell.ColorLightYellow).Bold(true) - case scopeSession: - return tcell.StyleDefault.Foreground(tcell.ColorFuchsia).Bold(true) - case scopeAll: - return tcell.StyleDefault.Foreground(tcell.ColorLightGreen).Bold(true) - default: - return tcell.StyleDefault.Foreground(tcell.ColorLightYellow).Bold(true) - } - } - - noteScopeOf := func(n ipc.Note) noteScope { - switch strings.ToLower(strings.TrimSpace(n.Scope)) { - case string(scopeWindow): - return scopeWindow - case string(scopeSession): - return scopeSession - case string(scopeAll): - return scopeAll - } - switch { - case strings.TrimSpace(n.WindowID) != "": - return scopeWindow - case strings.TrimSpace(n.SessionID) != "": - return scopeSession - default: - return scopeAll - } - } - - scopeTag := func(s noteScope) string { - switch s { - case scopeWindow: - return "W" - case scopeSession: - return "S" - case scopeAll: - return "G" - default: - return "?" - } - } - - clampList := func(state *listState, length int, rowHeight int, visibleRows int) { - if length == 0 { - state.selected = 0 - state.offset = 0 - return - } - if state.selected >= length { - state.selected = length - 1 - } - if state.selected < 0 { - state.selected = 0 - } - capacity := visibleRows / rowHeight - if capacity < 1 { - capacity = 1 - } - maxOffset := length - capacity - if maxOffset < 0 { - maxOffset = 0 - } - if state.offset > maxOffset { - state.offset = maxOffset - } - if state.selected < state.offset { - state.offset = state.selected - } - if state.selected >= state.offset+capacity { - state.offset = state.selected - capacity + 1 - } - if state.offset < 0 { - state.offset = 0 - } - } - - matchesScope := func(n ipc.Note, s noteScope, ctx tmuxContext) bool { - ns := noteScopeOf(n) - switch s { - case scopeWindow: - if ns == scopeAll { - return true - } - if ns == scopeSession && strings.TrimSpace(n.SessionID) == strings.TrimSpace(ctx.SessionID) { - return true - } - return ns == scopeWindow && strings.TrimSpace(n.WindowID) == strings.TrimSpace(ctx.WindowID) - case scopeSession: - if ns == scopeAll { - return true - } - return strings.TrimSpace(n.SessionID) == strings.TrimSpace(ctx.SessionID) - case scopeAll: - return true - default: - return true - } - } - - sortNotes := func(notes []ipc.Note) { - sort.SliceStable(notes, func(i, j int) bool { - ic, hasIC := parseTimestamp(notes[i].CreatedAt) - jc, hasJC := parseTimestamp(notes[j].CreatedAt) - if hasIC && hasJC && !ic.Equal(jc) { - return ic.Before(jc) - } - if hasIC != hasJC { - return hasIC - } - return notes[i].Summary < notes[j].Summary - }) - } - - sortGoals := func(goals []ipc.Goal) { - sort.SliceStable(goals, func(i, j int) bool { - ci, hasCi := parseTimestamp(goals[i].CreatedAt) - cj, hasCj := parseTimestamp(goals[j].CreatedAt) - if hasCi && hasCj && !ci.Equal(cj) { - return ci.After(cj) - } - if hasCi != hasCj { - return hasCi - } - return goals[i].Summary < goals[j].Summary - }) - } - - textWidth := func(s string) int { - return utf8.RuneCountInString(s) - } - - getVisibleTasks := func() []ipc.Task { - - result := make([]ipc.Task, 0, len(st.tasks)) - for _, t := range st.tasks { - key := fmt.Sprintf("%s|%s|%s", strings.TrimSpace(t.SessionID), strings.TrimSpace(t.WindowID), strings.TrimSpace(t.Pane)) - if !showCompletedTasks && t.Status == statusCompleted && !keepTasksVisible[key] { - continue - } - result = append(result, t) - } - sortTasks(result) - return result - } - - getVisibleNotes := func() []ipc.Note { - result := make([]ipc.Note, 0, len(st.notes)) - for _, n := range st.notes { - if n.Archived { - continue - } - if !showCompletedNotes && n.Completed && !keepNotesVisible[n.ID] { - continue - } - if matchesScope(n, scope, currentCtx) { - result = append(result, n) - } - } - sortNotes(result) - return result - } - - getVisibleGoals := func() []ipc.Goal { - result := make([]ipc.Goal, 0, len(st.goals)) - for _, g := range st.goals { - result = append(result, g) - } - sortGoals(result) - return result - } - - getArchivedNotes := func() []ipc.Note { - result := make([]ipc.Note, 0, len(st.archived)) - for _, n := range st.archived { - if !showCompletedArchive && n.Completed { - continue - } - result = append(result, n) - } - sortNotes(result) - return result - } - - setScopeFields := func(env *ipc.Envelope, s noteScope, ctx tmuxContext) { - env.Scope = string(s) - env.Session = ctx.SessionName - env.SessionID = ctx.SessionID - if ctx.WindowName != "" || ctx.WindowID != "" { - env.Window = ctx.WindowName - env.WindowID = ctx.WindowID - } - if ctx.PaneID != "" { - env.Pane = ctx.PaneID - } - } - - addGoal := func(text string) error { - text = strings.TrimSpace(text) - if text == "" { - return fmt.Errorf("goal text required") - } - refreshCtx() - ctx := currentCtx - if strings.TrimSpace(ctx.SessionID) == "" { - return fmt.Errorf("session required to add goal") - } - return sendCommand("goal_add", func(env *ipc.Envelope) { - env.Summary = text - env.Session = ctx.SessionName - env.SessionID = ctx.SessionID - }) - } - - toggleGoal := func(id string) error { - if strings.TrimSpace(id) == "" { - return fmt.Errorf("goal id required") - } - return sendCommand("goal_toggle_complete", func(env *ipc.Envelope) { - env.GoalID = id - }) - } - - deleteGoal := func(id string) error { - if strings.TrimSpace(id) == "" { - return fmt.Errorf("goal id required") - } - return sendCommand("goal_delete", func(env *ipc.Envelope) { - env.GoalID = id - }) - } - - focusGoal := func(g ipc.Goal) error { - if strings.TrimSpace(g.SessionID) == "" { - return fmt.Errorf("session required to focus goal") - } - return sendCommand("goal_focus", func(env *ipc.Envelope) { - env.SessionID = g.SessionID - env.Session = g.Session - }) - } - - addNote := func(text string) error { - text = strings.TrimSpace(text) - if text == "" { - return fmt.Errorf("note text required") - } - refreshCtx() - ctx := currentCtx - return sendCommand("note_add", func(env *ipc.Envelope) { - env.Summary = text - setScopeFields(env, scope, ctx) - }) - } - - updateNote := func(id, text, scope string) error { - text = strings.TrimSpace(text) - scope = strings.TrimSpace(scope) - if text == "" && scope == "" { - return fmt.Errorf("note text or scope required") - } - if strings.TrimSpace(id) == "" { - return fmt.Errorf("note id required") - } - return sendCommand("note_edit", func(env *ipc.Envelope) { - env.NoteID = id - env.Summary = text - env.Scope = scope - }) - } - - cycleNoteScope := func(n ipc.Note) error { - current := noteScopeOf(n) - var next noteScope - switch current { - case scopeWindow: - next = scopeSession - case scopeSession: - next = scopeAll - case scopeAll: - next = scopeWindow - default: - next = scopeWindow - } - return updateNote(n.ID, "", string(next)) - } - - toggleNote := func(id string) error { - if strings.TrimSpace(id) == "" { - return fmt.Errorf("note id required") - } - keepNotesVisible[id] = true - return sendCommand("note_toggle_complete", func(env *ipc.Envelope) { - env.NoteID = id - }) - } - - deleteNote := func(id string) error { - if strings.TrimSpace(id) == "" { - return fmt.Errorf("note id required") - } - return sendCommand("note_delete", func(env *ipc.Envelope) { - env.NoteID = id - }) - } - - archiveNote := func(id string) error { - if strings.TrimSpace(id) == "" { - return fmt.Errorf("note id required") - } - return sendCommand("note_archive", func(env *ipc.Envelope) { - env.NoteID = id - }) - } - - attachNote := func(id string) error { - if strings.TrimSpace(id) == "" { - return fmt.Errorf("note id required") - } - refreshCtx() - ctx := currentCtx - return sendCommand("note_attach", func(env *ipc.Envelope) { - env.NoteID = id - setScopeFields(env, scopeWindow, ctx) - }) - } - - toggleTask := func(t ipc.Task) error { - key := fmt.Sprintf("%s|%s|%s", strings.TrimSpace(t.SessionID), strings.TrimSpace(t.WindowID), strings.TrimSpace(t.Pane)) - keepTasksVisible[key] = true - if t.Status == statusInProgress { - return sendCommand("finish_task", func(env *ipc.Envelope) { - env.Session = t.Session - env.SessionID = t.SessionID - env.Window = t.Window - env.WindowID = t.WindowID - env.Pane = t.Pane - }) - } - return sendCommand("acknowledge", func(env *ipc.Envelope) { - env.Session = t.Session - env.SessionID = t.SessionID - env.Window = t.Window - env.WindowID = t.WindowID - env.Pane = t.Pane - }) - } - - deleteTask := func(t ipc.Task) error { - return sendCommand("delete_task", func(env *ipc.Envelope) { - env.Session = t.Session - env.SessionID = t.SessionID - env.Window = t.Window - env.WindowID = t.WindowID - env.Pane = t.Pane - }) - } - - focusTask := func(t ipc.Task) error { - return sendCommand("focus_task", func(env *ipc.Envelope) { - env.Session = t.Session - env.SessionID = t.SessionID - env.Window = t.Window - env.WindowID = t.WindowID - env.Pane = t.Pane - }) - } - - startAddPrompt := func() { - prompt = promptState{active: true, mode: promptAddNote, text: []rune{}, cursor: 0} - } - - startGoalPrompt := func() { - prompt = promptState{active: true, mode: promptAddGoal, text: []rune{}, cursor: 0} - } - - startEditPrompt := func(n ipc.Note) { - copy := n - editNote = © - mode = viewEdit - runes := []rune(n.Summary) - prompt = promptState{active: true, mode: promptEditNote, text: runes, cursor: len(runes), noteID: n.ID} - } - - handlePromptKey := func(tev *tcell.EventKey) (bool, error) { - if !prompt.active { - return false, nil - } - switch tev.Key() { - case tcell.KeyEnter: - text := strings.TrimSpace(string(prompt.text)) - var err error - switch prompt.mode { - case promptAddNote: - err = addNote(text) - case promptEditNote: - err = updateNote(prompt.noteID, text, "") - case promptAddGoal: - err = addGoal(text) - } - prompt.active = false - if prompt.mode == promptEditNote { - mode = viewNotes - editNote = nil - } - if err != nil { - return true, err - } - return true, nil - case tcell.KeyEscape: - prompt.active = false - if prompt.mode == promptEditNote { - mode = viewNotes - editNote = nil - } - return true, nil - case tcell.KeyLeft: - if prompt.cursor > 0 { - prompt.cursor-- - } - return true, nil - case tcell.KeyRight: - if prompt.cursor < len(prompt.text) { - prompt.cursor++ - } - return true, nil - case tcell.KeyBackspace, tcell.KeyBackspace2: - if prompt.cursor > 0 { - prompt.text = append(prompt.text[:prompt.cursor-1], prompt.text[prompt.cursor:]...) - prompt.cursor-- - } - return true, nil - case tcell.KeyCtrlW: - if prompt.cursor > 0 { - // skip trailing spaces - i := prompt.cursor - for i > 0 && unicode.IsSpace(prompt.text[i-1]) { - i-- - } - // skip non-spaces - for i > 0 && !unicode.IsSpace(prompt.text[i-1]) { - i-- - } - prompt.text = append(prompt.text[:i], prompt.text[prompt.cursor:]...) - prompt.cursor = i - } - return true, nil - case tcell.KeyCtrlU: - prompt.text = prompt.text[:0] - prompt.cursor = 0 - return true, nil - case tcell.KeyTab: - if prompt.mode == promptAddNote { - cycleScope(true, true) - } - return true, nil - case tcell.KeyRune: - r := tev.Rune() - prompt.text = append(prompt.text[:prompt.cursor], append([]rune{r}, prompt.text[prompt.cursor:]...)...) - prompt.cursor++ - return true, nil - default: - return true, nil - } - } - - draw := func(now time.Time) { - screen.Clear() - width, height := screen.Size() - - headerStyle := tcell.StyleDefault.Foreground(tcell.ColorLightCyan).Bold(true) - subtleStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray) - infoStyle := tcell.StyleDefault.Foreground(tcell.ColorSilver) - - title := "Tracker" - if mode == viewNotes { - title = "Notes" - } else if mode == viewArchive { - title = "Archive" - } else if mode == viewEdit { - title = "Edit Note" - } - subtitle := st.message - if mode == viewNotes { - completedState := "hidden" - if showCompletedNotes { - completedState = "shown" - } - subtitle = fmt.Sprintf("%s · Completed: %s", st.message, completedState) - } else if mode == viewEdit && editNote != nil { - subtitle = fmt.Sprintf("%s · %s · %s", st.message, editNote.Session, editNote.Window) - } - - writeStyledLine(screen, 0, 0, truncate(fmt.Sprintf("▌ %s", title), width), headerStyle) - writeStyledLine(screen, 0, 1, truncate(subtitle, width), subtleStyle) - if width > 0 { - writeStyledLine(screen, 0, 2, strings.Repeat("─", width), infoStyle) - } - - visibleRows := height - 3 - if visibleRows < 0 { - visibleRows = 0 - } - - renderTasks := func(list []ipc.Task, state *listState) { - clampList(state, len(list), 3, visibleRows) - row := 3 - for idx := state.offset; idx < len(list); idx++ { - if row >= height { - break - } - t := list[idx] - indicator := taskIndicator(t, now) - summary := t.Summary - if summary == "" { - summary = "(no summary)" - } - - // Style definitions - baseStyle := tcell.StyleDefault - accentStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray) - timeStyle := tcell.StyleDefault.Foreground(tcell.ColorDarkCyan) - - switch t.Status { - case statusInProgress: - baseStyle = baseStyle.Foreground(tcell.ColorLightGoldenrodYellow).Bold(true) - indicator = "▶ " + indicator - case statusCompleted: - if t.Acknowledged { - baseStyle = baseStyle.Foreground(tcell.ColorLightGreen) - } else { - baseStyle = baseStyle.Foreground(tcell.ColorFuchsia) - } - } - - if idx == state.selected { - baseStyle = baseStyle.Background(tcell.ColorDarkSlateGray) - accentStyle = accentStyle.Background(tcell.ColorDarkSlateGray) - timeStyle = timeStyle.Background(tcell.ColorDarkSlateGray) - } - - // Line 1: Indicator + Summary + Right-aligned Duration - dur := liveDuration(t, now) - availWidth := width - len(indicator) - 1 - len(dur) - 3 - if availWidth < 0 { - availWidth = 0 - } - - line1Segs := []struct { - text string - style tcell.Style - }{ - {text: indicator + " ", style: baseStyle}, - {text: truncate(summary, availWidth), style: baseStyle}, - } - - // Fill spacing for right alignment - usedWidth := len(indicator) + 1 + len([]rune(truncate(summary, availWidth))) - padding := width - usedWidth - len(dur) - if padding > 0 { - line1Segs = append(line1Segs, struct { - text string - style tcell.Style - }{ - text: strings.Repeat(" ", padding), - style: baseStyle, - }) - } - line1Segs = append(line1Segs, struct { - text string - style tcell.Style - }{ - text: dur, - style: timeStyle, - }) - - writeStyledSegments(screen, row, line1Segs...) - row++ - - if row >= height { - break - } - - // Line 2: Meta info (Session / Window) - meta := fmt.Sprintf(" └ %s / %s", t.Session, t.Window) - if t.Status == statusCompleted && !t.Acknowledged { - meta += " (awaiting review)" - } - - writeStyledLine(screen, 0, row, truncate(meta, width), accentStyle) - row++ - - if t.CompletionNote != "" && row < height { - note := fmt.Sprintf(" Note: %s", t.CompletionNote) - noteStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSteelBlue) - if idx == state.selected { - noteStyle = noteStyle.Background(tcell.ColorDarkSlateGray) - } - writeStyledLine(screen, 0, row, truncate(note, width), noteStyle) - row++ - } - - // Spacer - if row < height { - if idx == state.selected { - // Optional: subtle separator for selected item - } - row++ - } - } - } - - wrapText := func(text string, maxWidth int) []string { - if maxWidth <= 0 { - return []string{""} - } - runes := []rune(text) - if len(runes) <= maxWidth { - return []string{text} - } - var lines []string - for len(runes) > maxWidth { - split := maxWidth - found := false - for i := maxWidth; i > 0; i-- { - if unicode.IsSpace(runes[i]) { - split = i - found = true - break - } - } - if !found { - split = maxWidth - } - lines = append(lines, string(runes[:split])) - runes = runes[split:] - for len(runes) > 0 && unicode.IsSpace(runes[0]) { - runes = runes[1:] - } - } - if len(runes) > 0 { - lines = append(lines, string(runes)) - } - return lines - } - - renderGoals := func(list []ipc.Goal, state *listState, focused bool, startRow int) int { - gutterText := " " - gutterStyle := infoStyle - if focused { - gutterText = "│ " - gutterStyle = tcell.StyleDefault.Foreground(tcell.ColorLightCyan) - } - row := startRow - header := "Goals (all sessions)" - if strings.TrimSpace(currentCtx.SessionName) != "" { - header = fmt.Sprintf("Goals (all) · current: %s", currentCtx.SessionName) - } - headerStyle := infoStyle - if focused { - headerStyle = headerStyle.Bold(true) - } - contentWidth := width - textWidth(gutterText) - if contentWidth < 0 { - contentWidth = 0 - } - writeStyledSegments(screen, row, - struct { - text string - style tcell.Style - }{text: gutterText, style: gutterStyle}, - struct { - text string - style tcell.Style - }{text: truncate(header, contentWidth), style: headerStyle}, - ) - row++ - availableRows := height - row - if availableRows < 0 { - availableRows = 0 - } - clampList(state, len(list), 3, availableRows) - if len(list) == 0 { - if row < height { - writeStyledSegments(screen, row, - struct { - text string - style tcell.Style - }{text: gutterText, style: gutterStyle}, - struct { - text string - style tcell.Style - }{text: truncate("No goals for this session.", contentWidth), style: subtleStyle}, - ) - row++ - } - return row - } - for idx := state.offset; idx < len(list); idx++ { - if row >= height { - break - } - g := list[idx] - indicator := "•" - baseStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite) - metaStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray) - timeStyle := tcell.StyleDefault.Foreground(tcell.ColorDarkCyan) - if g.Completed { - indicator = "✓" - baseStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) - metaStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) - timeStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) - } - if strings.TrimSpace(g.SessionID) == strings.TrimSpace(currentCtx.SessionID) && strings.TrimSpace(currentCtx.SessionID) != "" { - metaStyle = tcell.StyleDefault.Foreground(tcell.ColorLightGreen) - } - itemGutterStyle := gutterStyle - if focused && idx == state.selected { - baseStyle = baseStyle.Background(tcell.ColorDarkSlateGray) - metaStyle = metaStyle.Background(tcell.ColorDarkSlateGray) - timeStyle = timeStyle.Background(tcell.ColorDarkSlateGray) - itemGutterStyle = itemGutterStyle.Background(tcell.ColorDarkSlateGray) - } - - created := "" - if ts, ok := parseTimestamp(g.CreatedAt); ok { - created = ts.Format("15:04") - } - - summary := g.Summary - if summary == "" { - summary = "(no summary)" - } - avail := contentWidth - textWidth(indicator) - 1 - textWidth(created) - if avail < 5 { - avail = 5 - } - lineText := truncate(summary, avail) - segs := []struct { - text string - style tcell.Style - }{ - {text: gutterText, style: itemGutterStyle}, - {text: indicator + " ", style: baseStyle}, - {text: lineText, style: baseStyle}, - } - used := textWidth(indicator) + 1 + textWidth(lineText) - padding := contentWidth - used - textWidth(created) - if padding > 0 { - segs = append(segs, struct { - text string - style tcell.Style - }{ - text: strings.Repeat(" ", padding), - style: baseStyle, - }) - } - segs = append(segs, struct { - text string - style tcell.Style - }{ - text: created, - style: timeStyle, - }) - writeStyledSegments(screen, row, segs...) - row++ - if row >= height { - break - } - - meta := fmt.Sprintf(" └ %s", g.Session) - metaDisplay := truncate(meta, contentWidth) - metaPadding := contentWidth - textWidth(metaDisplay) - writeStyledSegments(screen, row, - struct { - text string - style tcell.Style - }{text: gutterText, style: itemGutterStyle}, - struct { - text string - style tcell.Style - }{text: metaDisplay, style: metaStyle}, - struct { - text string - style tcell.Style - }{text: strings.Repeat(" ", metaPadding), style: metaStyle}, - ) - row++ - if row >= height { - break - } - - spacerStyle := tcell.StyleDefault - if focused && idx == state.selected { - spacerStyle = spacerStyle.Background(tcell.ColorDarkSlateGray) - } - writeStyledSegments(screen, row, - struct { - text string - style tcell.Style - }{text: gutterText, style: itemGutterStyle}, - struct { - text string - style tcell.Style - }{text: strings.Repeat(" ", contentWidth), style: spacerStyle}, - ) - row++ - } - return row - } - - renderNotes := func(list []ipc.Note, state *listState, archived bool, startRow int, focused bool) int { - availableRows := height - startRow - if availableRows < 0 { - availableRows = 0 - } - clampList(state, len(list), 3, availableRows) - row := startRow - gutterText := " " - gutterStyle := infoStyle - if focused { - gutterText = "│ " - gutterStyle = tcell.StyleDefault.Foreground(tcell.ColorLightCyan) - } - contentWidth := width - textWidth(gutterText) - if contentWidth < 0 { - contentWidth = 0 - } - for idx := state.offset; idx < len(list); idx++ { - if row >= height { - break - } - n := list[idx] - ns := noteScopeOf(n) - - // Styles - scopeSt := scopeStyle(ns) - textStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite) - metaStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray) - timeStyle := tcell.StyleDefault.Foreground(tcell.ColorDarkCyan) - - if n.Completed { - textStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) - scopeSt = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) - metaStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) - timeStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) - } - - if focused && idx == state.selected { - textStyle = textStyle.Background(tcell.ColorDarkSlateGray) - scopeSt = scopeSt.Background(tcell.ColorDarkSlateGray) - metaStyle = metaStyle.Background(tcell.ColorDarkSlateGray) - timeStyle = timeStyle.Background(tcell.ColorDarkSlateGray) - } - - // Tag Logic - tagText := " " + scopeTag(ns) + " " - - // Timestamp Logic - tsStr := "" - if archived { - if n.ArchivedAt != "" { - if ts, ok := parseTimestamp(n.ArchivedAt); ok { - tsStr = ts.Format("15:04") - } - } - } else { - if n.CreatedAt != "" { - if ts, ok := parseTimestamp(n.CreatedAt); ok { - tsStr = ts.Format("15:04") - } - } - } - - summary := n.Summary - if n.Completed { - summary = "✓ " + summary - } - - // --- Line 1 Rendering --- - availWidth1 := contentWidth - len(tagText) - len(tsStr) - 2 - if availWidth1 < 5 { - availWidth1 = 5 - } - - // Split summary for first line - line1Text := summary - remText := "" - runes := []rune(summary) - if len(runes) > availWidth1 { - split := availWidth1 - found := false - for i := availWidth1; i > 0; i-- { - if unicode.IsSpace(runes[i]) { - split = i - found = true - break - } - } - if !found { - split = availWidth1 - } - - line1Text = string(runes[:split]) - remText = string(runes[split:]) - // trim leading space from remainder - trimRunes := []rune(remText) - for len(trimRunes) > 0 && unicode.IsSpace(trimRunes[0]) { - trimRunes = trimRunes[1:] - } - remText = string(trimRunes) - } - - segs := []struct { - text string - style tcell.Style - }{ - {text: gutterText, style: gutterStyle}, - {text: tagText, style: scopeSt}, - {text: line1Text, style: textStyle}, - } - - usedLen := len(tagText) + len([]rune(line1Text)) - padding := contentWidth - usedLen - len(tsStr) - if padding > 0 { - segs = append(segs, struct { - text string - style tcell.Style - }{ - text: strings.Repeat(" ", padding), - style: textStyle, - }) - } - segs = append(segs, struct { - text string - style tcell.Style - }{ - text: tsStr, - style: timeStyle, - }) - - writeStyledSegments(screen, row, segs...) - row++ - if row >= height { - break - } - - // --- Wrapped Lines Rendering --- - if remText != "" { - indent := len(tagText) - availWidthN := contentWidth - indent - if availWidthN < 5 { - availWidthN = 5 - } - - wrappedRem := wrapText(remText, availWidthN) - for _, wLine := range wrappedRem { - segs := []struct { - text string - style tcell.Style - }{ - {text: gutterText, style: gutterStyle}, - {text: strings.Repeat(" ", indent), style: scopeSt}, - {text: wLine, style: textStyle}, - } - used := indent + len([]rune(wLine)) - if contentWidth > used { - segs = append(segs, struct { - text string - style tcell.Style - }{ - text: strings.Repeat(" ", contentWidth-used), - style: textStyle, - }) - } - writeStyledSegments(screen, row, segs...) - row++ - if row >= height { - break - } - } - } - if row >= height { - break - } - - // --- Meta Line Rendering --- - prefix := " " - metaText := fmt.Sprintf("%s / %s", n.Session, n.Window) - - metaSegs := []struct { - text string - style tcell.Style - }{ - {text: gutterText, style: gutterStyle}, - {text: prefix, style: metaStyle}, - {text: truncate(metaText, contentWidth-len(prefix)), style: metaStyle}, - } - metaUsed := len(prefix) + len([]rune(truncate(metaText, contentWidth-len(prefix)))) - if contentWidth > metaUsed { - metaSegs = append(metaSegs, struct { - text string - style tcell.Style - }{ - text: strings.Repeat(" ", contentWidth-metaUsed), - style: metaStyle, - }) - } - - writeStyledSegments(screen, row, metaSegs...) - row++ - if row >= height { - break - } - - // --- Spacer Rendering --- - spacerStyle := tcell.StyleDefault - if focused && idx == state.selected { - spacerStyle = spacerStyle.Background(tcell.ColorDarkSlateGray) - } - writeStyledSegments(screen, row, - struct { - text string - style tcell.Style - }{text: gutterText, style: gutterStyle}, - struct { - text string - style tcell.Style - }{text: strings.Repeat(" ", contentWidth), style: spacerStyle}, - ) - row++ - } - return row - } - - if helpVisible { - helpLines := []string{ - "t: toggle Tracker/Notes | Tab: focus goals/notes | n/i: view scope | Alt-A: archive view", - "Goals: a add | Enter/c: complete | Shift-D: delete (focus goals first)", - "Notes: a add | k edit | Enter/c: complete | Shift-A: archive | Shift-D: delete | Shift-C: show/hide completed | Esc: close | ?: toggle help", - } - row := 3 - for _, line := range helpLines { - if row >= height { - break - } - writeStyledLine(screen, 0, row, truncate(line, width), infoStyle) - row++ - } - screen.Show() - return - } - - switch mode { - case viewTracker: - list := getVisibleTasks() - if len(list) == 0 && height > 3 { - writeStyledLine(screen, 0, 3, truncate("No tasks.", width), infoStyle) - } else { - renderTasks(list, &taskList) - } - case viewNotes: - goals := getVisibleGoals() - notes := getVisibleNotes() - rowStart := 3 - rowStart = renderGoals(goals, &goalList, focusGoals, rowStart) - if rowStart < height { - notesHeader := "Notes" - notesStyle := infoStyle - gutterText := " " - gutterStyle := infoStyle - scopeLabel := "Window" - switch scope { - case scopeSession: - scopeLabel = "Session" - case scopeAll: - scopeLabel = "Global" - } - scopeText := fmt.Sprintf("[%s]", scopeLabel) - scopeLabelStyle := scopeStyle(scope) - if !focusGoals { - notesStyle = notesStyle.Bold(true) - gutterText = "│ " - gutterStyle = tcell.StyleDefault.Foreground(tcell.ColorLightCyan) - } - contentWidth := width - textWidth(gutterText) - if contentWidth < 0 { - contentWidth = 0 - } - spaceWidth := 1 - combinedWidth := textWidth(notesHeader) + spaceWidth + textWidth(scopeText) - if combinedWidth > contentWidth { - if contentWidth > textWidth(notesHeader)+spaceWidth { - scopeText = truncate(scopeText, contentWidth-(textWidth(notesHeader)+spaceWidth)) - } else { - scopeText = "" - spaceWidth = 0 - } - } - writeStyledSegments(screen, rowStart, - struct { - text string - style tcell.Style - }{text: gutterText, style: gutterStyle}, - struct { - text string - style tcell.Style - }{text: truncate(notesHeader, contentWidth), style: notesStyle}, - struct { - text string - style tcell.Style - }{text: strings.Repeat(" ", spaceWidth), style: notesStyle}, - struct { - text string - style tcell.Style - }{text: scopeText, style: scopeLabelStyle}, - ) - rowStart++ - } - if len(notes) == 0 && height > rowStart { - gutterText := " " - gutterStyle := infoStyle - if !focusGoals { - gutterText = "│ " - gutterStyle = tcell.StyleDefault.Foreground(tcell.ColorLightCyan) - } - contentWidth := width - textWidth(gutterText) - if contentWidth < 0 { - contentWidth = 0 - } - writeStyledSegments(screen, rowStart, - struct { - text string - style tcell.Style - }{text: gutterText, style: gutterStyle}, - struct { - text string - style tcell.Style - }{text: truncate("No notes in this scope.", contentWidth), style: infoStyle}, - ) - } else { - renderNotes(notes, ¬eList, false, rowStart, !focusGoals) - } - case viewArchive: - list := getArchivedNotes() - if len(list) == 0 && height > 3 { - writeStyledLine(screen, 0, 3, truncate("Archive is empty.", width), infoStyle) - } else { - renderNotes(list, &archiveList, true, 3, true) - } - case viewEdit: - bodyStyle := tcell.StyleDefault.Foreground(tcell.ColorLightGreen) - if editNote == nil { - writeStyledLine(screen, 0, 3, truncate("No note selected.", width), infoStyle) - } else { - writeStyledLine(screen, 0, 3, truncate("Editing note (Enter to save, Esc to cancel):", width), infoStyle) - writeStyledLine(screen, 0, 5, truncate(string(prompt.text), width), bodyStyle) - if prompt.active { - cx := prompt.cursor - if cx > width-1 { - cx = width - 1 - } - screen.ShowCursor(cx, 5) - } - } - } - - if prompt.active && mode != viewEdit { - label := "Add note: " - if prompt.mode == promptEditNote { - label = "Edit note: " - } else if prompt.mode == promptAddGoal { - label = "Add goal: " - } else if prompt.mode == promptAddNote { - label = fmt.Sprintf("Add note (%s): ", scopeTag(scope)) - } - line := label + string(prompt.text) - writeStyledLine(screen, 0, height-1, truncate(line, width), tcell.StyleDefault.Foreground(tcell.ColorLightGreen)) - - cx := len(label) + prompt.cursor - if cx > width-1 { - cx = width - 1 - } - screen.ShowCursor(cx, height-1) - } - - screen.Show() - } - - draw(time.Now()) - - for { - select { - case env, ok := <-incoming: - if !ok { - return <-errCh - } - switch env.Kind { - case "state": - st.message = env.Message - st.tasks = make([]ipc.Task, len(env.Tasks)) - copy(st.tasks, env.Tasks) - st.notes = make([]ipc.Note, len(env.Notes)) - copy(st.notes, env.Notes) - st.archived = make([]ipc.Note, len(env.Archived)) - copy(st.archived, env.Archived) - st.goals = make([]ipc.Goal, len(env.Goals)) - copy(st.goals, env.Goals) - refreshCtx() - draw(time.Now()) - case "ack": - default: - } - case ev, ok := <-events: - if !ok { - return nil - } - switch tev := ev.(type) { - case *tcell.EventKey: - if handled, err := handlePromptKey(tev); handled { - if err != nil { - st.message = err.Error() - } - draw(time.Now()) - continue - } - - if tev.Key() == tcell.KeyRune && tev.Rune() == '?' { - helpVisible = !helpVisible - draw(time.Now()) - continue - } - - if tev.Key() == tcell.KeyEscape { - if prompt.active { - prompt.active = false - draw(time.Now()) - continue - } - if mode == viewEdit { - mode = viewNotes - editNote = nil - draw(time.Now()) - continue - } - if err := sendCommand("hide"); err != nil { - return err - } - return nil - } - - if tev.Key() == tcell.KeyCtrlC { - if err := sendCommand("hide"); err != nil { - return err - } - return nil - } - - if tev.Modifiers()&tcell.ModAlt != 0 { - r := unicode.ToLower(tev.Rune()) - if r == 'a' { - if mode == viewNotes { - mode = viewArchive - } else if mode == viewArchive { - mode = viewNotes - } else { - mode = viewNotes - } - draw(time.Now()) - continue - } - } - - if tev.Key() == tcell.KeyEnter { - switch mode { - case viewTracker: - tasks := getVisibleTasks() - if len(tasks) > 0 && taskList.selected < len(tasks) { - if err := focusTask(tasks[taskList.selected]); err != nil { - st.message = err.Error() - } - } - case viewNotes: - if focusGoals { - goals := getVisibleGoals() - if len(goals) > 0 && goalList.selected < len(goals) { - if err := focusGoal(goals[goalList.selected]); err != nil { - st.message = err.Error() - } - } - } else { - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - if err := focusTask(ipc.Task{ - Session: notes[noteList.selected].Session, - SessionID: notes[noteList.selected].SessionID, - Window: notes[noteList.selected].Window, - WindowID: notes[noteList.selected].WindowID, - Pane: notes[noteList.selected].Pane, - }); err != nil { - st.message = err.Error() - } - } - } - case viewArchive: - notes := getArchivedNotes() - if len(notes) > 0 && archiveList.selected < len(notes) { - if err := attachNote(notes[archiveList.selected].ID); err != nil { - st.message = err.Error() - } - } - } - draw(time.Now()) - continue - } - - if tev.Key() == tcell.KeyTab && mode == viewNotes { - focusGoals = !focusGoals - draw(time.Now()) - continue - } - - if tev.Key() != tcell.KeyRune { - continue - } - - r := tev.Rune() - lower := unicode.ToLower(r) - shift := tev.Modifiers()&tcell.ModShift != 0 || unicode.IsUpper(r) - - switch lower { - case 't': - if mode == viewTracker { - mode = viewNotes - } else { - mode = viewTracker - } - draw(time.Now()) - case 'n': - if mode == viewNotes { - cycleScope(false, false) - draw(time.Now()) - } - case 'i': - if mode == viewNotes { - cycleScope(true, false) - draw(time.Now()) - } - case 's': - if mode == viewNotes { - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - if err := cycleNoteScope(notes[noteList.selected]); err != nil { - st.message = err.Error() - } - } - draw(time.Now()) - } - case 'u': - switch mode { - case viewTracker: - if taskList.selected > 0 { - taskList.selected-- - } - case viewNotes: - if focusGoals { - if goalList.selected > 0 { - goalList.selected-- - } - } else { - if noteList.selected > 0 { - noteList.selected-- - } - } - case viewArchive: - if archiveList.selected > 0 { - archiveList.selected-- - } - } - draw(time.Now()) - case 'e': - switch mode { - case viewTracker: - tasks := getVisibleTasks() - if taskList.selected < len(tasks)-1 { - taskList.selected++ - } - case viewNotes: - if focusGoals { - goals := getVisibleGoals() - if goalList.selected < len(goals)-1 { - goalList.selected++ - } - } else { - notes := getVisibleNotes() - if noteList.selected < len(notes)-1 { - noteList.selected++ - } - } - case viewArchive: - notes := getArchivedNotes() - if archiveList.selected < len(notes)-1 { - archiveList.selected++ - } - } - draw(time.Now()) - case 'c': - if shift { - switch mode { - case viewNotes: - showCompletedNotes = !showCompletedNotes - case viewArchive: - showCompletedArchive = !showCompletedArchive - } - draw(time.Now()) - break - } - switch mode { - case viewTracker: - tasks := getVisibleTasks() - if len(tasks) > 0 && taskList.selected < len(tasks) { - if err := toggleTask(tasks[taskList.selected]); err != nil { - st.message = err.Error() - } - } - case viewNotes: - if focusGoals { - goals := getVisibleGoals() - if len(goals) > 0 && goalList.selected < len(goals) { - if err := toggleGoal(goals[goalList.selected].ID); err != nil { - st.message = err.Error() - } - } - } else { - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - if err := toggleNote(notes[noteList.selected].ID); err != nil { - st.message = err.Error() - } - } - } - case viewArchive: - notes := getArchivedNotes() - if len(notes) > 0 && archiveList.selected < len(notes) { - if err := attachNote(notes[archiveList.selected].ID); err != nil { - st.message = err.Error() - } - } - } - draw(time.Now()) - case 'p': - if mode == viewTracker { - tasks := getVisibleTasks() - if len(tasks) > 0 && taskList.selected < len(tasks) { - if err := focusTask(tasks[taskList.selected]); err != nil { - st.message = err.Error() - } - } - draw(time.Now()) - } else if mode == viewNotes { - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - if err := focusTask(ipc.Task{ - Session: notes[noteList.selected].Session, - SessionID: notes[noteList.selected].SessionID, - Window: notes[noteList.selected].Window, - WindowID: notes[noteList.selected].WindowID, - Pane: notes[noteList.selected].Pane, - }); err != nil { - st.message = err.Error() - } - } - draw(time.Now()) - } - case 'a': - if shift && mode == viewNotes && !focusGoals { - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - if err := archiveNote(notes[noteList.selected].ID); err != nil { - st.message = err.Error() - } - } - draw(time.Now()) - break - } - if mode == viewNotes { - if focusGoals { - startGoalPrompt() - } else { - startAddPrompt() - } - draw(time.Now()) - } - case 'k': - if mode == viewNotes { - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - startEditPrompt(notes[noteList.selected]) - } - draw(time.Now()) - } - - case 'd': - if shift { - switch mode { - case viewTracker: - tasks := getVisibleTasks() - if len(tasks) > 0 && taskList.selected < len(tasks) { - if err := deleteTask(tasks[taskList.selected]); err != nil { - st.message = err.Error() - } - } - case viewNotes: - if focusGoals { - goals := getVisibleGoals() - if len(goals) > 0 && goalList.selected < len(goals) { - if err := deleteGoal(goals[goalList.selected].ID); err != nil { - st.message = err.Error() - } - } - } else { - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - if err := deleteNote(notes[noteList.selected].ID); err != nil { - st.message = err.Error() - } - } - } - case viewArchive: - notes := getArchivedNotes() - if len(notes) > 0 && archiveList.selected < len(notes) { - if err := deleteNote(notes[archiveList.selected].ID); err != nil { - st.message = err.Error() - } - } - } - draw(time.Now()) - } - } - case *tcell.EventResize: - draw(time.Now()) - } - case now := <-ticker.C: - draw(now) - case err := <-errCh: - if err == nil || errors.Is(err, io.EOF) || strings.Contains(err.Error(), "use of closed network connection") { - return nil - } - return err - } - } -} - -func sortTasks(tasks []ipc.Task) { - sort.SliceStable(tasks, func(i, j int) bool { - ti, tj := tasks[i], tasks[j] - ranki := taskStatusRank(ti.Status) - rankj := taskStatusRank(tj.Status) - if ranki != rankj { - return ranki < rankj - } - switch ti.Status { - case statusInProgress: - return ti.StartedAt < tj.StartedAt - case statusCompleted: - if ti.Acknowledged != tj.Acknowledged { - return !ti.Acknowledged && tj.Acknowledged - } - if ci, hasCi := parseTimestamp(ti.CompletedAt); hasCi { - if cj, hasCj := parseTimestamp(tj.CompletedAt); hasCj { - if !ci.Equal(cj) { - return ci.After(cj) - } - } else { - return true - } - } else if _, hasCj := parseTimestamp(tj.CompletedAt); hasCj { - return false - } - si, hasSi := parseTimestamp(ti.StartedAt) - sj, hasSj := parseTimestamp(tj.StartedAt) - if hasSi && hasSj && !si.Equal(sj) { - return si.After(sj) - } - if hasSi != hasSj { - return hasSi - } - return ti.StartedAt > tj.StartedAt - default: - return ti.StartedAt < tj.StartedAt - } - }) -} - -func taskStatusRank(status string) int { - switch status { - case statusInProgress: - return 0 - case statusCompleted: - return 1 - default: - return 2 - } -} - -func parseTimestamp(value string) (time.Time, bool) { - value = strings.TrimSpace(value) - if value == "" { - return time.Time{}, false - } - ts, err := time.Parse(time.RFC3339, value) - if err != nil { - return time.Time{}, false - } - return ts, true -} - -func taskIndicator(t ipc.Task, now time.Time) string { - switch t.Status { - case statusInProgress: - idx := int(now.UnixNano()/int64(spinnerInterval)) % len(spinnerFrames) - return string(spinnerFrames[idx]) - case statusCompleted: - if t.Acknowledged { - return "✓" - } - return "⚑" - default: - return "?" - } -} - -func liveDuration(t ipc.Task, now time.Time) string { - if t.StartedAt == "" { - return "0s" - } - start, err := time.Parse(time.RFC3339, t.StartedAt) - if err != nil { - return formatDuration(t.DurationSeconds) - } - if t.Status == statusCompleted { - if t.CompletedAt != "" { - if end, err := time.Parse(time.RFC3339, t.CompletedAt); err == nil { - return formatDuration(end.Sub(start).Seconds()) - } - } - return formatDuration(t.DurationSeconds) - } - return formatDuration(time.Since(start).Seconds()) -} - -func formatDuration(seconds float64) string { - if seconds < 0 { - seconds = 0 - } - d := time.Duration(seconds * float64(time.Second)) - if d >= 99*time.Hour { - return ">=99h" - } - hours := d / time.Hour - minutes := (d % time.Hour) / time.Minute - secondsPart := (d % time.Minute) / time.Second - if hours > 0 { - return fmt.Sprintf("%02dh%02dm", hours, minutes) - } - if minutes > 0 { - return fmt.Sprintf("%02dm%02ds", minutes, secondsPart) - } - return fmt.Sprintf("%02ds", secondsPart) -} - -func truncate(text string, width int) string { - if width <= 0 { - return "" - } - runes := []rune(text) - if len(runes) <= width { - return text - } - if width <= 1 { - return string(runes[:width]) - } - return string(runes[:width-1]) + "…" -} - -func writeStyledLine(s tcell.Screen, x, y int, text string, style tcell.Style) { - width, _ := s.Size() - if x >= width { - return - } - runes := []rune(text) - limit := width - x - for i := 0; i < limit; i++ { - r := rune(' ') - if i < len(runes) { - r = runes[i] - } - s.SetContent(x+i, y, r, nil, style) - } -} - -func writeStyledSegments(s tcell.Screen, y int, segments ...struct { - text string - style tcell.Style -}) { - x := 0 - width, _ := s.Size() - for _, seg := range segments { - runes := []rune(seg.text) - for _, r := range runes { - if x >= width { - return - } - s.SetContent(x, y, r, nil, seg.style) - x++ - } - } -} - -func writeStyledSegmentsPad(s tcell.Screen, y int, segments []struct { - text string - style tcell.Style -}, fill tcell.Style) { - x := 0 - width, _ := s.Size() - for _, seg := range segments { - runes := []rune(seg.text) - for _, r := range runes { - if x >= width { - return - } - s.SetContent(x, y, r, nil, seg.style) - x++ - } - } - for x < width { - s.SetContent(x, y, ' ', nil, fill) - x++ - } -} - -func socketPath() string { - if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { - return filepath.Join(dir, "agent-tracker.sock") - } - return filepath.Join(os.TempDir(), "agent-tracker.sock") -} diff --git a/agent-tracker/cmd/tracker-mcp/main.go b/agent-tracker/cmd/tracker-mcp/main.go index be2b630..ba297bb 100644 --- a/agent-tracker/cmd/tracker-mcp/main.go +++ b/agent-tracker/cmd/tracker-mcp/main.go @@ -105,9 +105,7 @@ func main() { } env := ipc.Envelope{ Command: "start_task", - Session: target.SessionID, SessionID: target.SessionID, - Window: target.WindowID, WindowID: target.WindowID, Pane: target.PaneID, Summary: summary, diff --git a/agent-tracker/cmd/tracker-server/main.go b/agent-tracker/cmd/tracker-server/main.go index cb3aa90..6faa999 100644 --- a/agent-tracker/cmd/tracker-server/main.go +++ b/agent-tracker/cmd/tracker-server/main.go @@ -15,37 +15,22 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "syscall" "time" "github.com/david/agent-tracker/internal/ipc" ) -type position string - -const ( - posTopRight position = "top-right" - posTopLeft position = "top-left" - posBottomLeft position = "bottom-left" - posBottomRight position = "bottom-right" - posCenter position = "center" -) - const ( statusInProgress = "in_progress" statusCompleted = "completed" ) -const ( - scopeWindow = "window" - scopeSession = "session" - scopeAll = "all" -) - type taskRecord struct { SessionID string + SessionName string WindowID string + WindowName string Pane string Summary string CompletionNote string @@ -55,54 +40,8 @@ type taskRecord struct { Acknowledged bool } -type noteRecord struct { - ID string - Scope string - SessionID string - Session string - WindowID string - Window string - PaneID string - Summary string - Completed bool - Archived bool - CreatedAt time.Time - ArchivedAt *time.Time -} - -type goalRecord struct { - ID string - SessionID string - Session string - Summary string - Completed bool - CreatedAt time.Time - UpdatedAt time.Time -} - -type storedNote struct { - ID string `json:"id"` - Scope string `json:"scope"` - SessionID string `json:"session_id"` - Session string `json:"session"` - WindowID string `json:"window_id"` - Window string `json:"window"` - PaneID string `json:"pane_id"` - Summary string `json:"summary"` - Completed bool `json:"completed"` - Archived bool `json:"archived"` - CreatedAt time.Time `json:"created_at"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` -} - -type storedGoal struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - Session string `json:"session"` - Summary string `json:"summary"` - Completed bool `json:"completed"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +type storedSettings struct { + NotificationsEnabled *bool `json:"notifications_enabled,omitempty"` } type tmuxTarget struct { @@ -111,6 +50,8 @@ type tmuxTarget struct { WindowName string WindowID string PaneID string + WindowIndex string + PaneIndex string } type uiSubscriber struct { @@ -118,34 +59,21 @@ type uiSubscriber struct { } type server struct { - mu sync.Mutex - socketPath string - visible bool - pos position - width int - height int - tasks map[string]*taskRecord - notes map[string]*noteRecord - goals map[string]*goalRecord - subscribers map[*uiSubscriber]struct{} - notesPath string - goalsPath string - noteCounter uint64 - goalCounter uint64 + mu sync.Mutex + socketPath string + notificationsEnabled bool + tasks map[string]*taskRecord + subscribers map[*uiSubscriber]struct{} + settingsPath string } func newServer() *server { return &server{ - socketPath: socketPath(), - pos: posTopRight, - width: 84, - height: 28, - tasks: make(map[string]*taskRecord), - notes: make(map[string]*noteRecord), - goals: make(map[string]*goalRecord), - subscribers: make(map[*uiSubscriber]struct{}), - notesPath: notesStorePath(), - goalsPath: goalsStorePath(), + socketPath: socketPath(), + notificationsEnabled: true, + tasks: make(map[string]*taskRecord), + subscribers: make(map[*uiSubscriber]struct{}), + settingsPath: settingsStorePath(), } } @@ -157,10 +85,7 @@ func main() { } func (s *server) run() error { - if err := s.loadNotes(); err != nil { - return err - } - if err := s.loadGoals(); err != nil { + if err := s.loadSettings(); err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.socketPath), 0o755); err != nil { @@ -248,24 +173,6 @@ func (s *server) handleConn(conn net.Conn) { func (s *server) handleCommand(env ipc.Envelope) error { switch env.Command { - case "toggle": - return s.toggle() - case "show": - return s.show() - case "hide": - return s.hide() - case "refresh": - return s.refresh() - case "move_left": - return s.moveLeft() - case "move_right": - return s.moveRight() - case "move_up": - return s.moveUp() - case "move_down": - return s.moveDown() - case "center": - return s.center() case "start_task": target, err := requireSessionWindow(env) if err != nil { @@ -287,13 +194,32 @@ func (s *server) handleCommand(env ipc.Envelope) error { return err } note := firstNonEmpty(env.Summary, env.Message) - if err := s.finishTask(target, note); err != nil { + notify, err := s.finishTask(target, note) + if err != nil { return err } - // s.notifyResponded(target) + if notify && s.notificationsAreEnabled() { + go s.notifyResponded(target) + } s.broadcastStateAsync() s.statusRefreshAsync() return nil + case "notifications_toggle": + enabled, err := s.toggleNotifications() + if err != nil { + return err + } + if client := strings.TrimSpace(env.Client); client != "" { + status := "OFF" + if enabled { + status = "ON" + } + if err := runTmux("display-message", "-c", client, "push notifications: "+status); err != nil { + log.Printf("notification toggle message error: %v", err) + } + } + s.broadcastStateAsync() + return nil case "acknowledge": target, err := requireSessionWindow(env) if err != nil { @@ -316,109 +242,6 @@ func (s *server) handleCommand(env ipc.Envelope) error { s.broadcastStateAsync() s.statusRefreshAsync() return nil - case "focus_task": - target, err := requireSessionWindow(env) - if err != nil { - return err - } - if err := s.focusTask(env.Client, target); err != nil { - return err - } - return nil - case "note_add": - target := tmuxTarget{ - SessionName: strings.TrimSpace(env.Session), - SessionID: strings.TrimSpace(env.SessionID), - WindowName: strings.TrimSpace(env.Window), - WindowID: strings.TrimSpace(env.WindowID), - PaneID: strings.TrimSpace(env.Pane), - } - if err := s.addNote(target, env.Scope, env.Summary); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "note_edit": - if err := s.editNote(strings.TrimSpace(env.NoteID), env.Scope, env.Summary); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "note_toggle_complete": - if err := s.toggleNoteCompletion(strings.TrimSpace(env.NoteID)); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "note_delete": - if err := s.deleteNote(strings.TrimSpace(env.NoteID)); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "note_archive": - if err := s.archiveNote(strings.TrimSpace(env.NoteID)); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "note_archive_pane": - if err := s.archiveNotesForPane(strings.TrimSpace(env.SessionID), strings.TrimSpace(env.WindowID), strings.TrimSpace(env.Pane)); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "note_attach": - target := tmuxTarget{ - SessionName: strings.TrimSpace(env.Session), - SessionID: strings.TrimSpace(env.SessionID), - WindowName: strings.TrimSpace(env.Window), - WindowID: strings.TrimSpace(env.WindowID), - PaneID: strings.TrimSpace(env.Pane), - } - if err := s.attachArchivedNote(strings.TrimSpace(env.NoteID), target); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "goal_add": - target := tmuxTarget{ - SessionName: strings.TrimSpace(env.Session), - SessionID: strings.TrimSpace(env.SessionID), - } - summary := firstNonEmpty(env.Summary, env.Message) - if err := s.addGoal(target, summary); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "goal_toggle_complete": - if err := s.toggleGoalCompletion(strings.TrimSpace(env.GoalID)); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "goal_delete": - if err := s.deleteGoal(strings.TrimSpace(env.GoalID)); err != nil { - return err - } - s.broadcastStateAsync() - s.statusRefreshAsync() - return nil - case "goal_focus": - if err := s.focusGoal(env.Client, strings.TrimSpace(env.SessionID)); err != nil { - return err - } - return nil default: return fmt.Errorf("unknown command %q", env.Command) } @@ -428,12 +251,15 @@ func (s *server) startTask(target tmuxTarget, summary string) error { if target.SessionID == "" || target.WindowID == "" { return fmt.Errorf("cannot create task: missing session or window ID") } + target = normalizeTargetNames(target) now := time.Now() s.mu.Lock() defer s.mu.Unlock() s.tasks[taskKey(target.SessionID, target.WindowID, target.PaneID)] = &taskRecord{ SessionID: target.SessionID, + SessionName: strings.TrimSpace(target.SessionName), WindowID: target.WindowID, + WindowName: strings.TrimSpace(target.WindowName), Pane: target.PaneID, Summary: summary, StartedAt: now, @@ -443,22 +269,34 @@ func (s *server) startTask(target tmuxTarget, summary string) error { return nil } -func (s *server) finishTask(target tmuxTarget, note string) error { +func (s *server) finishTask(target tmuxTarget, note string) (bool, error) { if target.SessionID == "" || target.WindowID == "" { - return nil // silently ignore - pane likely died + return false, nil // silently ignore - pane likely died } + target = normalizeTargetNames(target) now := time.Now() s.mu.Lock() defer s.mu.Unlock() key := taskKey(target.SessionID, target.WindowID, target.PaneID) t, ok := s.tasks[key] + wasCompleted := false if !ok { - t = &taskRecord{SessionID: target.SessionID, WindowID: target.WindowID, Pane: target.PaneID, StartedAt: now} + t = &taskRecord{ + SessionID: target.SessionID, + SessionName: strings.TrimSpace(target.SessionName), + WindowID: target.WindowID, + WindowName: strings.TrimSpace(target.WindowName), + Pane: target.PaneID, + StartedAt: now, + } s.tasks[key] = t + } else { + wasCompleted = t.Status == statusCompleted } if t.Summary == "" { t.Summary = note } + mergeTaskNamesFromTarget(t, target) t.Status = statusCompleted t.CompletedAt = &now if note != "" { @@ -466,7 +304,7 @@ func (s *server) finishTask(target tmuxTarget, note string) error { } // Auto-acknowledge if user is currently in this pane t.Acknowledged = isActivePane(target.PaneID) - return nil + return !wasCompleted, nil } func (s *server) acknowledgeTask(sessionID, windowID, paneID string) error { @@ -485,560 +323,199 @@ func (s *server) deleteTask(sessionID, windowID, paneID string) error { return nil } -func normalizeScope(scope string) string { - scope = strings.TrimSpace(strings.ToLower(scope)) - switch scope { - case scopeWindow, scopeSession, scopeAll: - return scope - default: - return scopeWindow +func normalizeTargetNames(target tmuxTarget) tmuxTarget { + if strings.TrimSpace(target.SessionName) == strings.TrimSpace(target.SessionID) { + target.SessionName = "" } -} - -func (s *server) addNote(target tmuxTarget, scope, summary string) error { - summary = strings.TrimSpace(summary) - if summary == "" { - return fmt.Errorf("note summary required") - } - scope = normalizeScope(scope) - target = normalizeNoteTargetNames(target) - switch scope { - case scopeWindow: - if target.SessionID == "" || target.WindowID == "" { - return fmt.Errorf("window notes require session and window identifiers") - } - case scopeSession, scopeAll: - // allow global (scopeAll) notes to omit session/window/pane - } - - now := time.Now() - s.mu.Lock() - defer s.mu.Unlock() - n := ¬eRecord{ - ID: s.newNoteIDLocked(now), - Scope: scope, - SessionID: target.SessionID, - Session: target.SessionName, - WindowID: target.WindowID, - Window: target.WindowName, - PaneID: target.PaneID, - Summary: summary, - CreatedAt: now, - } - s.notes[n.ID] = n - return s.saveNotesLocked() -} - -func (s *server) editNote(id, scope, summary string) error { - summary = strings.TrimSpace(summary) - scope = normalizeScope(scope) - - s.mu.Lock() - defer s.mu.Unlock() - n, ok := s.notes[id] - if !ok { - return fmt.Errorf("note not found") - } - if summary != "" { - n.Summary = summary - } - if scope != "" { - n.Scope = scope - } - return s.saveNotesLocked() -} - -func (s *server) toggleNoteCompletion(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - n, ok := s.notes[id] - if !ok { - return fmt.Errorf("note not found") - } - n.Completed = !n.Completed - return s.saveNotesLocked() -} - -func (s *server) deleteNote(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - if _, ok := s.notes[id]; !ok { - return fmt.Errorf("note not found") - } - delete(s.notes, id) - return s.saveNotesLocked() -} - -func (s *server) archiveNote(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - n, ok := s.notes[id] - if !ok { - return fmt.Errorf("note not found") - } - if n.Archived { - return nil - } - now := time.Now() - n.Archived = true - n.ArchivedAt = &now - return s.saveNotesLocked() -} - -func (s *server) archiveNotesForPane(sessionID, windowID, paneID string) error { - sessionID = strings.TrimSpace(sessionID) - windowID = strings.TrimSpace(windowID) - paneID = strings.TrimSpace(paneID) - if sessionID == "" || windowID == "" || paneID == "" { - return fmt.Errorf("pane archive requires session, window, and pane identifiers") - } - s.mu.Lock() - defer s.mu.Unlock() - now := time.Now() - changed := false - for _, n := range s.notes { - if n.Archived { - continue - } - if n.SessionID == sessionID && n.WindowID == windowID && n.PaneID == paneID { - n.Archived = true - n.ArchivedAt = &now - changed = true - } - } - if !changed { - return nil - } - return s.saveNotesLocked() -} - -func (s *server) attachArchivedNote(id string, target tmuxTarget) error { - target = normalizeNoteTargetNames(target) - if target.SessionID == "" || target.WindowID == "" || target.PaneID == "" { - return fmt.Errorf("attach requires session, window, and pane identifiers") - } - s.mu.Lock() - defer s.mu.Unlock() - n, ok := s.notes[id] - if !ok { - return fmt.Errorf("note not found") - } - if !n.Archived { - return fmt.Errorf("note is not archived") - } - n.SessionID = target.SessionID - n.Session = target.SessionName - n.WindowID = target.WindowID - n.Window = target.WindowName - n.PaneID = target.PaneID - n.Scope = scopeWindow - n.Archived = false - n.ArchivedAt = nil - return s.saveNotesLocked() -} - -func (s *server) saveNotesLocked() error { - if err := os.MkdirAll(filepath.Dir(s.notesPath), 0o755); err != nil { - return err - } - records := make([]storedNote, 0, len(s.notes)) - for _, n := range s.notes { - records = append(records, storedNote(*n)) - } - data, err := json.MarshalIndent(records, "", " ") - if err != nil { - return err - } - tmp := s.notesPath + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, s.notesPath) -} - -func (s *server) loadNotes() error { - if s.notes == nil { - s.notes = make(map[string]*noteRecord) - } - data, err := os.ReadFile(s.notesPath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - var records []storedNote - if err := json.Unmarshal(data, &records); err != nil { - return err - } - for _, rec := range records { - n := rec - if strings.TrimSpace(n.Scope) == "" { - switch { - case n.WindowID != "": - n.Scope = scopeWindow - case n.SessionID != "": - n.Scope = scopeSession - default: - n.Scope = scopeAll - } - } - s.notes[n.ID] = ¬eRecord{ - ID: n.ID, - Scope: n.Scope, - SessionID: n.SessionID, - Session: n.Session, - WindowID: n.WindowID, - Window: n.Window, - PaneID: n.PaneID, - Summary: n.Summary, - Completed: n.Completed, - Archived: n.Archived, - CreatedAt: n.CreatedAt, - ArchivedAt: n.ArchivedAt, - } - } - return nil -} - -func (s *server) saveGoalsLocked() error { - if err := os.MkdirAll(filepath.Dir(s.goalsPath), 0o755); err != nil { - return err - } - records := make([]storedGoal, 0, len(s.goals)) - for _, g := range s.goals { - records = append(records, storedGoal(*g)) - } - data, err := json.MarshalIndent(records, "", " ") - if err != nil { - return err - } - tmp := s.goalsPath + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, s.goalsPath) -} - -func (s *server) loadGoals() error { - if s.goals == nil { - s.goals = make(map[string]*goalRecord) - } - data, err := os.ReadFile(s.goalsPath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - var records []storedGoal - if err := json.Unmarshal(data, &records); err != nil { - return err - } - for _, rec := range records { - g := rec - s.goals[g.ID] = &goalRecord{ - ID: g.ID, - SessionID: g.SessionID, - Session: g.Session, - Summary: g.Summary, - Completed: g.Completed, - CreatedAt: g.CreatedAt, - UpdatedAt: g.UpdatedAt, - } - } - return nil -} - -func (s *server) addGoal(target tmuxTarget, summary string) error { - summary = strings.TrimSpace(summary) - if summary == "" { - return fmt.Errorf("goal summary required") - } - target = normalizeNoteTargetNames(target) - if strings.TrimSpace(target.SessionID) == "" { - return fmt.Errorf("session required for goal") - } - now := time.Now() - s.mu.Lock() - defer s.mu.Unlock() - g := &goalRecord{ - ID: s.newGoalIDLocked(now), - SessionID: target.SessionID, - Session: target.SessionName, - Summary: summary, - Completed: false, - CreatedAt: now, - UpdatedAt: now, - } - s.goals[g.ID] = g - return s.saveGoalsLocked() -} - -func (s *server) toggleGoalCompletion(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - g, ok := s.goals[id] - if !ok { - return fmt.Errorf("goal not found") - } - g.Completed = !g.Completed - g.UpdatedAt = time.Now() - return s.saveGoalsLocked() -} - -func (s *server) deleteGoal(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - if _, ok := s.goals[id]; !ok { - return fmt.Errorf("goal not found") - } - delete(s.goals, id) - return s.saveGoalsLocked() -} - -func (s *server) newNoteIDLocked(now time.Time) string { - counter := atomic.AddUint64(&s.noteCounter, 1) - return fmt.Sprintf("%x-%x", now.UnixNano(), counter) -} - -func (s *server) newGoalIDLocked(now time.Time) string { - counter := atomic.AddUint64(&s.goalCounter, 1) - return fmt.Sprintf("%x-%x", now.UnixNano(), counter) -} - -func normalizeNoteTargetNames(target tmuxTarget) tmuxTarget { - if strings.TrimSpace(target.SessionName) == "" { - target.SessionName = target.SessionID - } - if strings.TrimSpace(target.WindowName) == "" { - target.WindowName = target.WindowID + if strings.TrimSpace(target.WindowName) == strings.TrimSpace(target.WindowID) { + target.WindowName = "" } return target } -func (s *server) notesForState() ([]ipc.Note, []ipc.Note) { +func mergeTaskNamesFromTarget(task *taskRecord, target tmuxTarget) { + if task == nil { + return + } + if sessionName := strings.TrimSpace(target.SessionName); sessionName != "" { + task.SessionName = sessionName + } + if windowName := strings.TrimSpace(target.WindowName); windowName != "" { + task.WindowName = windowName + } +} + +func (s *server) loadSettings() error { + data, err := os.ReadFile(s.settingsPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var stored storedSettings + if err := json.Unmarshal(data, &stored); err != nil { + return err + } + if stored.NotificationsEnabled != nil { + s.mu.Lock() + s.notificationsEnabled = *stored.NotificationsEnabled + s.mu.Unlock() + } + return nil +} + +func (s *server) saveSettingsLocked() error { + if err := os.MkdirAll(filepath.Dir(s.settingsPath), 0o755); err != nil { + return err + } + enabled := s.notificationsEnabled + data, err := json.MarshalIndent(storedSettings{NotificationsEnabled: &enabled}, "", " ") + if err != nil { + return err + } + tmp := s.settingsPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.settingsPath) +} + +func (s *server) notificationsAreEnabled() bool { s.mu.Lock() - records := make([]*noteRecord, 0, len(s.notes)) - for _, n := range s.notes { - records = append(records, n) + defer s.mu.Unlock() + return s.notificationsEnabled +} + +func (s *server) toggleNotifications() (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.notificationsEnabled = !s.notificationsEnabled + if err := s.saveSettingsLocked(); err != nil { + return false, err } - s.mu.Unlock() - - active := make([]ipc.Note, 0, len(records)) - archived := make([]ipc.Note, 0, len(records)) - - for _, n := range records { - copy := ipc.Note{ - ID: n.ID, - Scope: n.Scope, - SessionID: n.SessionID, - Session: n.Session, - WindowID: n.WindowID, - Window: n.Window, - Pane: n.PaneID, - Summary: n.Summary, - Completed: n.Completed, - Archived: n.Archived, - CreatedAt: n.CreatedAt.Format(time.RFC3339), - } - if n.ArchivedAt != nil { - copy.ArchivedAt = n.ArchivedAt.Format(time.RFC3339) - } - if n.Archived { - archived = append(archived, copy) - } else { - active = append(active, copy) - } - } - - return active, archived + return s.notificationsEnabled, nil } func (s *server) notifyResponded(target tmuxTarget) { + target = s.fillTargetNamesFromTask(target) summary := strings.TrimSpace(s.summaryForTask(target.SessionID, target.WindowID, target.PaneID)) if summary == "" { summary = "Task marked complete" } + title := notificationTitleForTarget(target) action := notificationActionForTarget(target) - if err := sendSystemNotification("Tracker", summary, action); err != nil { + if err := sendSystemNotification(title, summary, action); err != nil { log.Printf("notification error: %v", err) } } +func (s *server) fillTargetNamesFromTask(target tmuxTarget) tmuxTarget { + target = normalizeTargetNames(target) + s.mu.Lock() + defer s.mu.Unlock() + if task, ok := s.tasks[taskKey(target.SessionID, target.WindowID, target.PaneID)]; ok { + if strings.TrimSpace(target.SessionName) == "" { + target.SessionName = strings.TrimSpace(task.SessionName) + } + if strings.TrimSpace(target.WindowName) == "" { + target.WindowName = strings.TrimSpace(task.WindowName) + } + } + return target +} + +func notificationTitleForTarget(target tmuxTarget) string { + target = normalizeTargetNames(target) + session := strings.TrimSpace(target.SessionName) + if session != "" { + session = stripSessionIndexPrefix(session) + } + if session == "" { + session = strings.TrimSpace(target.SessionID) + } + window := strings.TrimSpace(target.WindowName) + if window == "" { + window = strings.TrimSpace(target.WindowID) + } + + if session != "" && window != "" { + return session + " - " + window + } + if session != "" { + return session + } + if window != "" { + return window + } + return "Tracker" +} + +func stripSessionIndexPrefix(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + + i := 0 + for i < len(name) && name[i] >= '0' && name[i] <= '9' { + i++ + } + if i == 0 { + return name + } + + j := i + for j < len(name) && name[j] == ' ' { + j++ + } + if j >= len(name) || name[j] != '-' { + return name + } + + j++ + for j < len(name) && name[j] == ' ' { + j++ + } + + trimmed := strings.TrimSpace(name[j:]) + if trimmed == "" { + return name + } + return trimmed +} + func (s *server) summaryForTask(sessionID, windowID, paneID string) string { s.mu.Lock() defer s.mu.Unlock() if t, ok := s.tasks[taskKey(sessionID, windowID, paneID)]; ok { - if note := strings.TrimSpace(t.CompletionNote); note != "" { + note := strings.TrimSpace(t.CompletionNote) + summary := strings.TrimSpace(t.Summary) + if note != "" && !isGenericCompletionNote(note) { return note } - if summary := strings.TrimSpace(t.Summary); summary != "" { + if summary != "" { return summary } + if note != "" { + return note + } } return "" } -func (s *server) focusTask(client string, target tmuxTarget) error { - client = strings.TrimSpace(client) - if client == "" { - return fmt.Errorf("client required for focus_task") +func isGenericCompletionNote(note string) bool { + normalized := strings.ToLower(strings.TrimSpace(note)) + normalized = strings.Trim(normalized, ".!?,;:-_()[]{}\"'` ") + if normalized == "" { + return true } - if target.SessionID == "" || target.WindowID == "" { - return fmt.Errorf("session and window required for focus_task") - } - if err := runTmux("switch-client", "-c", client, "-t", target.SessionID); err != nil { - return err - } - if err := runTmux("select-window", "-t", target.WindowID); err != nil { - return err - } - if err := runTmux("select-pane", "-t", target.PaneID); err != nil { - return err - } - return s.hide() -} - -func (s *server) focusGoal(client string, sessionID string) error { - client = strings.TrimSpace(client) - if client == "" { - return fmt.Errorf("client required for goal_focus") - } - sessionID = strings.TrimSpace(sessionID) - if sessionID == "" { - return fmt.Errorf("session id required for goal_focus") - } - if err := runTmux("switch-client", "-c", client, "-t", sessionID); err != nil { - return err - } - return s.hide() -} - -func (s *server) toggle() error { - s.mu.Lock() - visible := s.visible - s.mu.Unlock() - if visible { - return s.hide() - } - return s.show() -} - -func (s *server) show() error { - s.mu.Lock() - s.visible = true - s.mu.Unlock() - return s.openOnClients(false) -} - -func (s *server) hide() error { - s.mu.Lock() - s.visible = false - s.mu.Unlock() - return s.closeOnClients() -} - -func (s *server) refresh() error { - s.mu.Lock() - visible := s.visible - s.mu.Unlock() - if !visible { - return nil - } - return s.openOnClients(true) -} - -func (s *server) moveLeft() error { - s.mu.Lock() - defer s.mu.Unlock() - switch s.pos { - case posTopRight: - s.pos = posTopLeft - case posBottomRight: - s.pos = posBottomLeft - case posCenter: - s.pos = posTopLeft + switch normalized { + case "done", "complete", "completed", "finished", "fixed", "resolved", "ok", "okay", "success", "successful", "all set", "all good", "implemented", "updated", "shipped": + return true default: - return nil + return false } - s.broadcastStateAsync() - s.asyncRefresh() - return nil -} - -func (s *server) moveRight() error { - s.mu.Lock() - defer s.mu.Unlock() - switch s.pos { - case posTopLeft: - s.pos = posTopRight - case posBottomLeft: - s.pos = posBottomRight - case posCenter: - s.pos = posTopRight - default: - return nil - } - s.broadcastStateAsync() - s.asyncRefresh() - return nil -} - -func (s *server) moveUp() error { - s.mu.Lock() - defer s.mu.Unlock() - switch s.pos { - case posBottomLeft: - s.pos = posTopLeft - case posBottomRight: - s.pos = posTopRight - case posCenter: - s.pos = posTopRight - default: - return nil - } - s.broadcastStateAsync() - s.asyncRefresh() - return nil -} - -func (s *server) moveDown() error { - s.mu.Lock() - defer s.mu.Unlock() - switch s.pos { - case posTopLeft: - s.pos = posBottomLeft - case posTopRight: - s.pos = posBottomRight - case posCenter: - s.pos = posBottomRight - default: - return nil - } - s.broadcastStateAsync() - s.asyncRefresh() - return nil -} - -func (s *server) center() error { - s.mu.Lock() - defer s.mu.Unlock() - if s.pos == posCenter { - return nil - } - s.pos = posCenter - s.broadcastStateAsync() - s.asyncRefresh() - return nil -} - -func (s *server) asyncRefresh() { - go func() { - if err := s.refresh(); err != nil { - log.Printf("refresh error: %v", err) - } - }() } func (s *server) broadcastStateAsync() { @@ -1072,131 +549,6 @@ func (s *server) statusRefreshAsync() { }() } -func (s *server) openOnClients(refresh bool) error { - clients, err := listClients() - if err != nil { - return err - } - for _, client := range clients { - client := client - go func() { - if refresh { - if err := runTmux("display-popup", "-C", "-c", client); err != nil { - log.Printf("tracker: close popup %s failed: %v", client, err) - } - } - if err := s.openPopup(client); err != nil { - log.Printf("tracker: open popup %s failed: %v", client, err) - } - }() - } - return nil -} - -func (s *server) closeOnClients() error { - clients, err := listClients() - if err != nil { - return err - } - for _, client := range clients { - if err := runTmux("display-popup", "-C", "-c", client); err != nil { - log.Printf("close popup %s: %v", client, err) - } - } - return nil -} - -func (s *server) openPopup(client string) error { - origin := "" - if ctx, err := tmuxDisplay(client, "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}"); err == nil { - origin = strings.TrimSpace(ctx) - } - width, height := s.popupSize() - x, y, err := s.popupPosition(client, width, height) - if err != nil { - return err - } - args := []string{ - "display-popup", - "-E", - "-c", client, - "-w", strconv.Itoa(width), - "-h", strconv.Itoa(height), - "-x", strconv.Itoa(x), - "-y", strconv.Itoa(y), - } - bin, err := trackerClientBinary() - if err != nil { - return err - } - args = append(args, bin, "ui", "--client", client) - if origin != "" { - parts := strings.Split(origin, ":::") - if len(parts) == 5 { - args = append(args, - "--origin-session", parts[0], - "--origin-session-id", parts[1], - "--origin-window", parts[2], - "--origin-window-id", parts[3], - "--origin-pane", parts[4], - ) - } - } - return runTmux(args...) -} - -func (s *server) popupSize() (int, int) { - s.mu.Lock() - defer s.mu.Unlock() - return s.width, s.height -} - -func (s *server) popupPosition(client string, width, height int) (int, int, error) { - cols, rows, err := clientSize(client) - if err != nil { - return 0, 0, err - } - s.mu.Lock() - pos := s.pos - s.mu.Unlock() - - if width >= cols { - width = cols - 2 - } - if height >= rows { - height = rows - 1 - } - - var x, y int - switch pos { - case posTopRight: - x = cols - width - y = 0 - case posTopLeft: - x = 0 - y = 0 - case posBottomLeft: - x = 0 - y = rows - height - case posBottomRight: - x = cols - width - y = rows - height - case posCenter: - x = (cols - width) / 2 - y = (rows - height) / 2 - default: - x = cols - width - y = 0 - } - if x < 0 { - x = 0 - } - if y < 0 { - y = 0 - } - return x, y, nil -} - func (s *server) sendState(enc *json.Encoder) { env := s.buildStateEnvelope() if env == nil { @@ -1221,14 +573,10 @@ func (s *server) sendStateTo(sub *uiSubscriber) error { func (s *server) buildStateEnvelope() *ipc.Envelope { s.mu.Lock() - visible := s.visible - pos := s.pos copies := make([]*taskRecord, 0, len(s.tasks)) - taskKeys := make([]string, 0, len(s.tasks)) - for key, task := range s.tasks { + for _, task := range s.tasks { copy := *task copies = append(copies, ©) - taskKeys = append(taskKeys, key) } s.mu.Unlock() @@ -1251,27 +599,47 @@ func (s *server) buildStateEnvelope() *ipc.Envelope { if duration < 0 { duration = 0 } - var names [2]string - if cached, ok := nameCache[t.WindowID]; ok { - if cached[0] == "" && cached[1] == "" { - continue + sessionName := strings.TrimSpace(t.SessionName) + windowName := strings.TrimSpace(t.WindowName) + if sessionName == strings.TrimSpace(t.SessionID) { + sessionName = "" + } + if windowName == strings.TrimSpace(t.WindowID) { + windowName = "" + } + if sessionName == "" || windowName == "" { + if cached, ok := nameCache[t.WindowID]; ok { + if sessionName == "" { + sessionName = cached[0] + } + if windowName == "" { + windowName = cached[1] + } + } else { + sessName, winName, err := tmuxNamesForWindow(t.WindowID) + if err == nil { + nameCache[t.WindowID] = [2]string{sessName, winName} + if sessionName == "" { + sessionName = sessName + } + if windowName == "" { + windowName = winName + } + } } - names = cached - } else { - sessName, winName, err := tmuxNamesForWindow(t.WindowID) - if err != nil || (sessName == "" && winName == "") { - nameCache[t.WindowID] = [2]string{"", ""} - continue - } - names = [2]string{sessName, winName} - nameCache[t.WindowID] = names + } + if sessionName == "" { + sessionName = t.SessionID + } + if windowName == "" { + windowName = t.WindowID } tasks = append(tasks, ipc.Task{ SessionID: t.SessionID, - Session: names[0], + Session: sessionName, WindowID: t.WindowID, - Window: names[1], + Window: windowName, Pane: t.Pane, Status: t.Status, Summary: t.Summary, @@ -1283,47 +651,11 @@ func (s *server) buildStateEnvelope() *ipc.Envelope { }) } - activeNotes, archived := s.notesForState() - - s.mu.Lock() - goalCopies := make([]*goalRecord, 0, len(s.goals)) - for _, g := range s.goals { - copy := *g - goalCopies = append(goalCopies, ©) - } - s.mu.Unlock() - - goals := make([]ipc.Goal, 0, len(goalCopies)) - for _, g := range goalCopies { - created := "" - updated := "" - if !g.CreatedAt.IsZero() { - created = g.CreatedAt.Format(time.RFC3339) - } - if !g.UpdatedAt.IsZero() { - updated = g.UpdatedAt.Format(time.RFC3339) - } - goals = append(goals, ipc.Goal{ - ID: g.ID, - SessionID: g.SessionID, - Session: g.Session, - Summary: g.Summary, - Completed: g.Completed, - CreatedAt: created, - UpdatedAt: updated, - }) - } - - msg := stateSummary(tasks, activeNotes, archived) + msg := stateSummary(tasks) return &ipc.Envelope{ - Kind: "state", - Visible: &visible, - Position: string(pos), - Message: msg, - Tasks: tasks, - Notes: activeNotes, - Archived: archived, - Goals: goals, + Kind: "state", + Message: msg, + Tasks: tasks, } } @@ -1352,13 +684,20 @@ func notificationActionForTarget(target tmuxTarget) *notificationAction { return nil } cmd := fmt.Sprintf("tmux switch-client -t %s && tmux select-window -t %s && tmux select-pane -t %s", - strconv.Quote(session), strconv.Quote(window), strconv.Quote(pane)) + shellQuote(session), shellQuote(window), shellQuote(pane)) return ¬ificationAction{ Command: "sh -lc " + strconv.Quote(cmd), - ActivateApp: "com.apple.Terminal", + ActivateApp: "com.googlecode.iterm2", } } +func shellQuote(value string) string { + if value == "" { + return "''" + } + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + func sendSystemNotification(title, message string, action *notificationAction) error { title = strings.TrimSpace(title) if title == "" { @@ -1444,26 +783,6 @@ func tmuxOutput(args ...string) (string, error) { return string(output), nil } -func clientSize(client string) (int, int, error) { - colsStr, err := tmuxDisplay(client, "#{client_width}") - if err != nil { - return 0, 0, err - } - rowsStr, err := tmuxDisplay(client, "#{client_height}") - if err != nil { - return 0, 0, err - } - cols, err := strconv.Atoi(strings.TrimSpace(colsStr)) - if err != nil { - return 0, 0, err - } - rows, err := strconv.Atoi(strings.TrimSpace(rowsStr)) - if err != nil { - return 0, 0, err - } - return cols, rows, nil -} - func tmuxDisplay(client, format string) (string, error) { cmd := exec.Command("tmux", "display-message", "-p", "-c", client, format) output, err := cmd.CombinedOutput() @@ -1490,18 +809,6 @@ func listClients() ([]string, error) { return clients, nil } -func trackerClientBinary() (string, error) { - base := filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "bin", "tracker-client") - if info, err := os.Stat(base); err == nil && !info.IsDir() { - return base, nil - } - path, err := exec.LookPath("tracker-client") - if err != nil { - return "", fmt.Errorf("tracker-client binary not found") - } - return path, nil -} - func socketPath() string { if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { return filepath.Join(dir, "agent-tracker.sock") @@ -1509,14 +816,9 @@ func socketPath() string { return filepath.Join(os.TempDir(), "agent-tracker.sock") } -func notesStorePath() string { +func settingsStorePath() string { base := filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "run") - return filepath.Join(base, "notes.json") -} - -func goalsStorePath() string { - base := filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "run") - return filepath.Join(base, "goals.json") + return filepath.Join(base, "settings.json") } func taskKey(sessionID, windowID, paneID string) string { @@ -1524,13 +826,13 @@ func taskKey(sessionID, windowID, paneID string) string { } func requireSessionWindow(env ipc.Envelope) (tmuxTarget, error) { - ctx := tmuxTarget{ + ctx := normalizeTargetNames(tmuxTarget{ SessionName: strings.TrimSpace(env.Session), SessionID: strings.TrimSpace(env.SessionID), WindowName: strings.TrimSpace(env.Window), WindowID: strings.TrimSpace(env.WindowID), PaneID: strings.TrimSpace(env.Pane), - } + }) fetchOrder := []string{} if ctx.PaneID != "" { @@ -1561,7 +863,7 @@ func requireSessionWindow(env ipc.Envelope) (tmuxTarget, error) { if ctx.SessionName == "" || ctx.WindowName == "" { if info, err := detectTmuxTarget(ctx.WindowID); err == nil { - ctx = ctx.merge(info) + ctx = ctx.merge(normalizeTargetNames(info)) } } @@ -1598,17 +900,23 @@ func (t tmuxTarget) merge(other tmuxTarget) tmuxTarget { if t.PaneID == "" { t.PaneID = other.PaneID } + if t.WindowIndex == "" { + t.WindowIndex = other.WindowIndex + } + if t.PaneIndex == "" { + t.PaneIndex = other.PaneIndex + } return t } func detectTmuxTarget(target string) (tmuxTarget, error) { - format := "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}" + format := "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}:::#{window_index}:::#{pane_index}" output, err := tmuxQuery(strings.TrimSpace(target), format) if err != nil { return tmuxTarget{}, err } parts := strings.Split(strings.TrimSpace(output), ":::") - if len(parts) != 5 { + if len(parts) != 7 { return tmuxTarget{}, fmt.Errorf("unexpected tmux response: %s", strings.TrimSpace(output)) } return tmuxTarget{ @@ -1617,6 +925,8 @@ func detectTmuxTarget(target string) (tmuxTarget, error) { WindowName: strings.TrimSpace(parts[2]), WindowID: strings.TrimSpace(parts[3]), PaneID: strings.TrimSpace(parts[4]), + WindowIndex: strings.TrimSpace(parts[5]), + PaneIndex: strings.TrimSpace(parts[6]), }, nil } @@ -1658,7 +968,7 @@ func firstNonEmpty(values ...string) string { return "" } -func stateSummary(tasks []ipc.Task, notes []ipc.Note, archived []ipc.Note) string { +func stateSummary(tasks []ipc.Task) string { inProgress := 0 waiting := 0 for _, t := range tasks { @@ -1671,11 +981,5 @@ func stateSummary(tasks []ipc.Task, notes []ipc.Note, archived []ipc.Note) strin } } } - noteCount := len(notes) - archivedCount := len(archived) - notePart := fmt.Sprintf("Notes %d", noteCount) - if archivedCount > 0 { - notePart = fmt.Sprintf("%s (+%d archived)", notePart, archivedCount) - } - return fmt.Sprintf("Active %d · Waiting %d · %s · %s", inProgress, waiting, notePart, time.Now().Format(time.Kitchen)) + return fmt.Sprintf("Active %d · Waiting %d · %s", inProgress, waiting, time.Now().Format(time.Kitchen)) } diff --git a/agent-tracker/cmd/tracker-server/main_test.go b/agent-tracker/cmd/tracker-server/main_test.go new file mode 100644 index 0000000..2f1144d --- /dev/null +++ b/agent-tracker/cmd/tracker-server/main_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "testing" + "time" +) + +func TestNormalizeTargetNamesClearsIDPlaceholders(t *testing.T) { + target := normalizeTargetNames(tmuxTarget{ + SessionName: "$3", + SessionID: "$3", + WindowName: "@12", + WindowID: "@12", + PaneID: "%7", + }) + if target.SessionName != "" { + t.Fatalf("expected placeholder session name to be cleared, got %q", target.SessionName) + } + if target.WindowName != "" { + t.Fatalf("expected placeholder window name to be cleared, got %q", target.WindowName) + } +} + +func TestBuildStateEnvelopeUsesStoredTaskNames(t *testing.T) { + srv := newServer() + started := time.Date(2026, 3, 24, 10, 0, 0, 0, time.UTC) + srv.tasks[taskKey("$3", "@12", "%7")] = &taskRecord{ + SessionID: "$3", + SessionName: "workbench", + WindowID: "@12", + WindowName: "agent-tracker", + Pane: "%7", + Summary: "Polish notifications", + StartedAt: started, + Status: statusInProgress, + Acknowledged: true, + } + + env := srv.buildStateEnvelope() + if env == nil { + t.Fatal("expected state envelope") + } + if len(env.Tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(env.Tasks)) + } + task := env.Tasks[0] + if task.Session != "workbench" { + t.Fatalf("expected stored session name, got %q", task.Session) + } + if task.Window != "agent-tracker" { + t.Fatalf("expected stored window name, got %q", task.Window) + } +} diff --git a/agent-tracker/go.mod b/agent-tracker/go.mod index 896c52b..e165d01 100644 --- a/agent-tracker/go.mod +++ b/agent-tracker/go.mod @@ -5,16 +5,31 @@ go 1.25.1 require ( github.com/gdamore/tcell/v2 v2.9.0 github.com/modelcontextprotocol/go-sdk v0.5.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/google/jsonschema-go v0.2.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/rivo/uniseg v0.4.3 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.36.0 // indirect golang.org/x/term v0.34.0 // indirect golang.org/x/text v0.28.0 // indirect ) diff --git a/agent-tracker/go.sum b/agent-tracker/go.sum index a521fcd..a97c113 100644 --- a/agent-tracker/go.sum +++ b/agent-tracker/go.sum @@ -1,3 +1,19 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.9.0 h1:N6t+eqK7/xwtRPwxzs1PXeRWnm0H9l02CrgJ7DLn1ys= @@ -8,13 +24,27 @@ github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+euk github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/modelcontextprotocol/go-sdk v0.5.0 h1:WXRHx/4l5LF5MZboeIJYn7PMFCrMNduGGVapYWFgrF8= github.com/modelcontextprotocol/go-sdk v0.5.0/go.mod h1:degUj7OVKR6JcYbDF+O99Fag2lTSTbamZacbGTRTSGU= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -32,11 +62,15 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -56,3 +90,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agent-tracker/install.sh b/agent-tracker/install.sh index 8fa4375..f291eb5 100755 --- a/agent-tracker/install.sh +++ b/agent-tracker/install.sh @@ -4,10 +4,14 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT" -go build -o bin/tracker-client ./cmd/tracker-client +if ! command -v go >/dev/null 2>&1 && [[ -x /opt/homebrew/bin/go ]]; then + export PATH="/opt/homebrew/bin:$PATH" +fi go build -o bin/tracker-server ./cmd/tracker-server go build -o bin/tracker-mcp ./cmd/tracker-mcp -echo "Built tracker client, server, and MCP binaries into bin/" +go build -o bin/agent ./cmd/agent + +echo "Built tracker server, MCP, and agent binaries into bin/" diff --git a/agent-tracker/internal/ipc/envelope.go b/agent-tracker/internal/ipc/envelope.go index c44401b..f09e844 100644 --- a/agent-tracker/internal/ipc/envelope.go +++ b/agent-tracker/internal/ipc/envelope.go @@ -9,17 +9,9 @@ type Envelope struct { Window string `json:"window,omitempty"` WindowID string `json:"window_id,omitempty"` Pane string `json:"pane,omitempty"` - Scope string `json:"scope,omitempty"` - NoteID string `json:"note_id,omitempty"` - GoalID string `json:"goal_id,omitempty"` - Position string `json:"position,omitempty"` - Visible *bool `json:"visible,omitempty"` Message string `json:"message,omitempty"` Summary string `json:"summary,omitempty"` Tasks []Task `json:"tasks,omitempty"` - Notes []Note `json:"notes,omitempty"` - Archived []Note `json:"archived,omitempty"` - Goals []Goal `json:"goals,omitempty"` } type Task struct { @@ -36,28 +28,3 @@ type Task struct { DurationSeconds float64 `json:"duration_seconds"` Acknowledged bool `json:"acknowledged"` } - -type Note struct { - ID string `json:"id"` - Scope string `json:"scope,omitempty"` - SessionID string `json:"session_id"` - Session string `json:"session"` - WindowID string `json:"window_id"` - Window string `json:"window"` - Pane string `json:"pane,omitempty"` - Summary string `json:"summary"` - Completed bool `json:"completed"` - Archived bool `json:"archived"` - CreatedAt string `json:"created_at"` - ArchivedAt string `json:"archived_at,omitempty"` -} - -type Goal struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - Session string `json:"session"` - Summary string `json:"summary"` - Completed bool `json:"completed"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} diff --git a/agent-tracker/scripts/dair-sync-test.sh b/agent-tracker/scripts/dair-sync-test.sh new file mode 100755 index 0000000..84265fc --- /dev/null +++ b/agent-tracker/scripts/dair-sync-test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REMOTE="${DAIR_REMOTE:-david@dair.local}" +REMOTE_ROOT="${DAIR_REMOTE_ROOT:-/Users/david/.config/agent-tracker}" + +if [[ "${1:-}" == "--help" ]]; then + echo "Usage: $(basename "$0") [remote command]" + echo "Syncs $ROOT to $REMOTE:$REMOTE_ROOT and runs a command there." + echo "Default remote command: ./install.sh && /opt/homebrew/bin/go test ./..." + exit 0 +fi + +REMOTE_CMD="${*:-./install.sh && /opt/homebrew/bin/go test ./...}" + +rsync -az --delete \ + --exclude '.git/' \ + --exclude '.build/' \ + --exclude 'bin/' \ + --exclude 'run/' \ + --exclude '.DS_Store' \ + "$ROOT/" "$REMOTE:$REMOTE_ROOT/" + +ssh "$REMOTE" "mkdir -p '$REMOTE_ROOT' && cd '$REMOTE_ROOT' && export PATH='/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:\$PATH' && $REMOTE_CMD" diff --git a/agent-tracker/scripts/focus_last_origin.sh b/agent-tracker/scripts/focus_last_origin.sh index bd959a8..6360455 100644 --- a/agent-tracker/scripts/focus_last_origin.sh +++ b/agent-tracker/scripts/focus_last_origin.sh @@ -6,13 +6,54 @@ if [[ ! -f "$F" ]]; then exit 0 fi +resolve_by_locator() { + local session_name="$1" + local window_index="$2" + local pane_index="$3" + tmux list-panes -a -F "#{session_name}:::#{window_index}:::#{pane_index}:::#{session_id}:::#{window_id}:::#{pane_id}" \ + | awk -F ':::' -v s="$session_name" -v w="$window_index" -v p="$pane_index" '$1==s && $2==w && $3==p {print $4":::"$5":::"$6; exit}' +} + +target_exists() { + local pane="$1" + tmux display-message -p -t "$pane" '#{pane_id}' >/dev/null 2>&1 +} + +resolve_by_pane() { + local pane="$1" + tmux display-message -p -t "$pane" '#{session_id}:::#{window_id}:::#{pane_id}' 2>/dev/null | tr -d '\r\n' +} + sid=$(awk -F ':::' 'NR==1{print $1}' "$F" | tr -d '\r\n') wid=$(awk -F ':::' 'NR==1{print $2}' "$F" | tr -d '\r\n') pid=$(awk -F ':::' 'NR==1{print $3}' "$F" | tr -d '\r\n') +session_name=$(awk -F ':::' 'NR==1{print $4}' "$F" | tr -d '\r\n') +window_index=$(awk -F ':::' 'NR==1{print $5}' "$F" | tr -d '\r\n') +pane_index=$(awk -F ':::' 'NR==1{print $6}' "$F" | tr -d '\r\n') if [[ -z "${sid:-}" || -z "${wid:-}" || -z "${pid:-}" ]]; then exit 0 fi -tmux switch-client -t "$sid" \; select-window -t "$wid" \; select-pane -t "$pid" +if target_exists "$pid"; then + resolved=$(resolve_by_pane "$pid") + if [[ -n "$resolved" ]]; then + sid=$(printf '%s' "$resolved" | awk -F ':::' '{print $1}') + wid=$(printf '%s' "$resolved" | awk -F ':::' '{print $2}') + pid=$(printf '%s' "$resolved" | awk -F ':::' '{print $3}') + fi +else + if [[ -n "${session_name:-}" && -n "${window_index:-}" && -n "${pane_index:-}" ]]; then + resolved=$(resolve_by_locator "$session_name" "$window_index" "$pane_index") + if [[ -z "$resolved" ]]; then + exit 0 + fi + sid=$(printf '%s' "$resolved" | awk -F ':::' '{print $1}') + wid=$(printf '%s' "$resolved" | awk -F ':::' '{print $2}') + pid=$(printf '%s' "$resolved" | awk -F ':::' '{print $3}') + else + exit 0 + fi +fi +tmux switch-client -t "$sid" \; select-window -t "$wid" \; select-pane -t "$pid" diff --git a/agent-tracker/scripts/focus_latest_notified.sh b/agent-tracker/scripts/focus_latest_notified.sh index 3c4d5b0..a581e9c 100644 --- a/agent-tracker/scripts/focus_latest_notified.sh +++ b/agent-tracker/scripts/focus_latest_notified.sh @@ -1,35 +1,73 @@ #!/usr/bin/env bash set -euo pipefail -F="$HOME/.config/agent-tracker/run/latest_notified.txt" -if [[ ! -f "$F" ]]; then - exit 0 +AGENT_BIN="$HOME/.config/agent-tracker/bin/agent" +[[ -x "$AGENT_BIN" ]] || exit 0 +command -v jq >/dev/null 2>&1 || exit 0 + +state=$("$AGENT_BIN" tracker state 2>/dev/null || true) +[[ -n "$state" ]] || exit 0 + +target=$(echo "$state" | jq -r ' + (.tasks // []) + | map( + select( + .status == "completed" and + (.acknowledged != true) and + (.session_id // "") != "" and + (.window_id // "") != "" + ) + | . + {__ts: ((.completed_at // .started_at // "") | (fromdateiso8601? // 0))} + ) + | if length == 0 then empty else max_by(.__ts) end + | [.session_id, .window_id, (.pane // "")] | @tsv +' 2>/dev/null || true) + +[[ -n "$target" ]] || exit 0 +IFS=$'\t' read -r sid wid pid <<< "$target" +[[ -n "${sid:-}" && -n "${wid:-}" ]] || exit 0 + +task_pid="$pid" + +pane_exists() { + local pane="$1" + [[ -n "$pane" ]] || return 1 + tmux list-panes -a -F "#{pane_id}" 2>/dev/null | awk -v p="$pane" '$1==p {found=1} END{exit(found?0:1)}' +} + +resolve_first_pane_for_window() { + local window="$1" + tmux list-panes -t "$window" -F "#{pane_id}" 2>/dev/null | awk 'NF {print $1; exit}' +} + +if pane_exists "$pid"; then + resolved=$(tmux display-message -p -t "$pid" '#{session_id}:::#{window_id}:::#{pane_id}' 2>/dev/null | tr -d '\r\n') + rsid=$(printf '%s' "$resolved" | awk -F ':::' '{print $1}') + rwid=$(printf '%s' "$resolved" | awk -F ':::' '{print $2}') + rpid=$(printf '%s' "$resolved" | awk -F ':::' '{print $3}') + if [[ -n "$rsid" && -n "$rwid" && -n "$rpid" ]]; then + sid="$rsid" + wid="$rwid" + pid="$rpid" + fi +else + pid=$(resolve_first_pane_for_window "$wid") fi -# Read line and split by literal ':::' into sid, wid, pid robustly -# Extract fields robustly using awk with a literal ':::' separator -sid=$(awk -F ':::' 'NR==1{print $1}' "$F" | tr -d '\r\n') -wid=$(awk -F ':::' 'NR==1{print $2}' "$F" | tr -d '\r\n') -pid=$(awk -F ':::' 'NR==1{print $3}' "$F" | tr -d '\r\n') - -if [[ -z "${sid:-}" || -z "${wid:-}" || -z "${pid:-}" ]]; then - exit 0 -fi +[[ -n "${pid:-}" ]] || exit 0 RUN_DIR="$HOME/.config/agent-tracker/run" mkdir -p "$RUN_DIR" -# Record current location for jump-back -current=$(tmux display-message -p "#{session_id}:::#{window_id}:::#{pane_id}" | tr -d '\r\n') +current=$(tmux display-message -p "#{session_id}:::#{window_id}:::#{pane_id}:::#{session_name}:::#{window_index}:::#{pane_index}" | tr -d '\r\n') if [[ -n "$current" ]]; then printf '%s\n' "$current" > "$RUN_DIR/jump_back.txt" fi -# Mark as viewed (acknowledged) in tracker (graceful if unavailable) -CLIENT_BIN="$HOME/.config/agent-tracker/bin/tracker-client" -if [[ -x "$CLIENT_BIN" ]]; then - "$CLIENT_BIN" command acknowledge -session-id "$sid" -window-id "$wid" -pane "$pid" >/dev/null 2>&1 || true +if [[ -x "$AGENT_BIN" ]]; then + ack_pid="$pid" + [[ -n "${task_pid:-}" ]] && ack_pid="$task_pid" + "$AGENT_BIN" tracker command acknowledge -session-id "$sid" -window-id "$wid" -pane "$ack_pid" >/dev/null 2>&1 || true fi -# Focus the tmux target tmux switch-client -t "$sid" \; select-window -t "$wid" \; select-pane -t "$pid" diff --git a/bin/agent b/bin/agent new file mode 100755 index 0000000..487ccfc --- /dev/null +++ b/bin/agent @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$HOME/.config/agent-tracker" +BIN="$ROOT/bin/agent" + +if [[ ! -x "$BIN" ]]; then + echo "agent binary not found at $BIN; run ~/.config/agent-tracker/install.sh first" >&2 + exit 1 +fi + +exec "$BIN" "$@" diff --git a/bin/ai-mem-usage b/bin/ai-mem-usage new file mode 100755 index 0000000..ec88d84 --- /dev/null +++ b/bin/ai-mem-usage @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +Analyze memory usage of opencode and dart processes, mapping them to tmux windows. +""" +from __future__ import annotations + +import subprocess +import re +from dataclasses import dataclass, field +from collections import defaultdict +from typing import Optional + + +@dataclass +class Process: + pid: int + mem_mb: float + compressed_mb: float + command: str + parent_pid: Optional[int] = None + parent_cmd: Optional[str] = None + + +@dataclass +class TmuxPane: + session: str + window_idx: int + window_name: str + pane_pid: int + + +def run_cmd(cmd: str) -> str: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + return result.stdout + + +def parse_mem(mem_str: str) -> float: + """Parse memory string like '1234M' or '2G' to MB.""" + mem_str = mem_str.strip() + if not mem_str or mem_str == '-': + return 0.0 + match = re.match(r'([\d.]+)([KMGB]?)', mem_str, re.IGNORECASE) + if not match: + return 0.0 + value = float(match.group(1)) + unit = match.group(2).upper() + if unit == 'K': + return value / 1024 + elif unit == 'G': + return value * 1024 + return value + + +def fmt_mem(mb: float) -> str: + """Format memory in human readable form (GB or MB).""" + if mb >= 1024: + return f"{mb / 1024:.1f}G" + return f"{mb:.0f}M" + + +def get_all_process_memory() -> dict[int, tuple[float, float]]: + """Get MEM and CMPRS for all processes using a single top call.""" + output = run_cmd('top -l 1 -o mem -n 500 -stats pid,mem,cmprs 2>/dev/null') + result = {} + for line in output.strip().split('\n'): + parts = line.split() + if len(parts) >= 3 and parts[0].isdigit(): + pid = int(parts[0]) + mem = parse_mem(parts[1]) + cmprs = parse_mem(parts[2]) + result[pid] = (mem, cmprs) + return result + + +def get_target_processes() -> list[Process]: + """Get all opencode and dart processes.""" + output = run_cmd("ps -eo pid,ppid,rss,comm | grep -E 'opencode|dart'") + processes = [] + + mem_data = get_all_process_memory() + + for line in output.strip().split('\n'): + if not line.strip(): + continue + parts = line.split() + if len(parts) >= 4: + try: + pid = int(parts[0]) + ppid = int(parts[1]) + comm = ' '.join(parts[3:]) + if 'opencode' in comm or 'dart' in comm: + mem, cmprs = mem_data.get(pid, (0.0, 0.0)) + processes.append(Process( + pid=pid, + mem_mb=mem, + compressed_mb=cmprs, + command=comm, + parent_pid=ppid + )) + except ValueError: + continue + + return processes + + +def get_tmux_panes() -> list[TmuxPane]: + """Get all tmux panes with their shell PIDs.""" + sessions = run_cmd("tmux list-sessions -F '#{session_name}' 2>/dev/null").strip().split('\n') + panes = [] + + for session in sessions: + if not session: + continue + output = run_cmd(f"tmux list-panes -s -t '{session}' -F '#{{window_index}}:#{{window_name}}:#{{pane_pid}}' 2>/dev/null") + for line in output.strip().split('\n'): + if not line or ':' not in line: + continue + parts = line.split(':') + if len(parts) >= 3: + try: + panes.append(TmuxPane( + session=session, + window_idx=int(parts[0]), + window_name=parts[1], + pane_pid=int(parts[2]) + )) + except ValueError: + continue + + return panes + + +def get_descendants(pid: int, max_depth: int = 5) -> set[int]: + """Get all descendant PIDs of a process.""" + descendants = set() + to_check = [pid] + depth = 0 + + while to_check and depth < max_depth: + next_check = [] + for p in to_check: + children = run_cmd(f"pgrep -P {p} 2>/dev/null").strip().split('\n') + for child in children: + if child.isdigit(): + child_pid = int(child) + if child_pid not in descendants: + descendants.add(child_pid) + next_check.append(child_pid) + to_check = next_check + depth += 1 + + return descendants + + +def get_parent_chain(pid: int, max_depth: int = 10) -> list[tuple[int, str]]: + """Get parent chain up to launchd.""" + chain = [] + current = pid + + for _ in range(max_depth): + output = run_cmd(f"ps -p {current} -o ppid=,comm= 2>/dev/null").strip() + if not output: + break + parts = output.split(None, 1) + if len(parts) < 2: + break + try: + ppid = int(parts[0]) + comm = parts[1] + chain.append((ppid, comm)) + if ppid <= 1: + break + current = ppid + except ValueError: + break + + return chain + + +def is_orphaned_opencode(proc: Process) -> bool: + """Check if an opencode process is orphaned (parent is launchd or another opencode).""" + if 'opencode' not in proc.command: + return False + chain = get_parent_chain(proc.pid) + if not chain: + return True + parent_pid, parent_cmd = chain[0] + if parent_pid == 1: + return True + if 'node' in parent_cmd: + if len(chain) > 1 and chain[1][0] == 1: + return True + return False + + +def main(): + processes = get_target_processes() + panes = get_tmux_panes() + + pane_descendants: dict[int, set[int]] = {} + for pane in panes: + pane_descendants[pane.pane_pid] = get_descendants(pane.pane_pid) + + tmux_mapping: dict[int, TmuxPane] = {} + for proc in processes: + for pane in panes: + if proc.pid in pane_descendants[pane.pane_pid]: + tmux_mapping[proc.pid] = pane + break + + by_window: dict[str, list[Process]] = defaultdict(list) + orphans: list[Process] = [] + orphaned_opencode_pids: set[int] = set() + + for proc in processes: + if 'opencode' in proc.command and is_orphaned_opencode(proc): + orphaned_opencode_pids.add(proc.pid) + + for proc in processes: + chain = get_parent_chain(proc.pid) + if chain: + proc.parent_pid = chain[0][0] + proc.parent_cmd = chain[0][1] + + is_child_of_orphan = any( + p[0] in orphaned_opencode_pids for p in chain + ) + + if proc.pid in orphaned_opencode_pids or is_child_of_orphan: + orphans.append(proc) + elif proc.pid in tmux_mapping: + pane = tmux_mapping[proc.pid] + key = f"{pane.session}:{pane.window_idx}:{pane.window_name}" + by_window[key].append(proc) + else: + orphans.append(proc) + + print("\n" + "=" * 70) + print("AI MEMORY USAGE (opencode + dart)") + print("=" * 70) + + window_totals = [] + for key in sorted(by_window.keys()): + procs = by_window[key] + parts = key.split(':') + session, window_idx, window_name = parts[0], parts[1], parts[2] + + total_mem = sum(p.mem_mb for p in procs) + total_cmprs = sum(p.compressed_mb for p in procs) + window_totals.append((key, total_mem, total_cmprs, procs)) + + window_totals.sort(key=lambda x: x[1] + x[2], reverse=True) + + for key, total_mem, total_cmprs, procs in window_totals: + parts = key.split(':') + session, window_idx, window_name = parts[0], parts[1], parts[2] + + total_virt = total_mem + total_cmprs + print(f"\n[{session}] Window {window_idx}: {window_name} ({fmt_mem(total_virt)} total)") + + procs.sort(key=lambda p: p.mem_mb + p.compressed_mb, reverse=True) + for proc in procs: + proc_type = "opencode" if "opencode" in proc.command else "dart" + virt = proc.mem_mb + proc.compressed_mb + print(f" {proc.pid:5} {fmt_mem(virt):>6} ({fmt_mem(proc.mem_mb):>5} + {fmt_mem(proc.compressed_mb):>5} cmprs) {proc_type}") + + total_orphan_mem = sum(p.mem_mb for p in orphans) + total_orphan_cmprs = sum(p.compressed_mb for p in orphans) + + if orphans: + total_orphan_virt = total_orphan_mem + total_orphan_cmprs + print("\n" + "=" * 70) + print(f"ORPHANS ({fmt_mem(total_orphan_virt)} total)") + print("=" * 70) + + orphans.sort(key=lambda p: p.mem_mb + p.compressed_mb, reverse=True) + + for proc in orphans: + proc_type = "opencode" if "opencode" in proc.command else "dart" + virt = proc.mem_mb + proc.compressed_mb + parent_info = proc.parent_cmd.split('/')[-1] if proc.parent_cmd else "?" + print(f" {proc.pid:5} {fmt_mem(virt):>6} ({fmt_mem(proc.mem_mb):>5} + {fmt_mem(proc.compressed_mb):>5} cmprs) {proc_type} <- {parent_info}") + + print(f"\nkill {' '.join(str(p.pid) for p in orphans)}") + + all_mem = sum(p.mem_mb for p in processes) + all_cmprs = sum(p.compressed_mb for p in processes) + all_virt = all_mem + all_cmprs + in_tmux_virt = (all_mem - total_orphan_mem) + (all_cmprs - total_orphan_cmprs) + + print("\n" + "-" * 70) + print(f"Total: {fmt_mem(all_virt)} (tmux: {fmt_mem(in_tmux_virt)}", end="") + if orphans: + print(f", orphans: {fmt_mem(total_orphan_mem + total_orphan_cmprs)}", end="") + print(")") + + +if __name__ == '__main__': + main() diff --git a/bin/tmux-resurrect b/bin/tmux-resurrect new file mode 100755 index 0000000..fa4e145 --- /dev/null +++ b/bin/tmux-resurrect @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +BOOTSTRAP_SESSION="__resurrect_bootstrap__" +RESTORE_SCRIPT="${TMUX_RESURRECT_PLUGIN_SCRIPT:-$HOME/.tmux/plugins/tmux-resurrect/scripts/restore.sh}" + +resurrect_dir() { + if [[ -d "$HOME/.tmux/resurrect" ]]; then + printf '%s\n' "$HOME/.tmux/resurrect" + else + printf '%s\n' "${XDG_DATA_HOME:-$HOME/.local/share}/tmux/resurrect" + fi +} + +LAST_FILE="${TMUX_RESURRECT_LAST_FILE:-$(resurrect_dir)/last}" + +die() { + printf 'tmux-resurrect: %s\n' "$*" >&2 + exit 1 +} + +tmux_has_sessions() { + tmux list-sessions >/dev/null 2>&1 +} + +bootstrap_is_only_session() { + local sessions + sessions="$(tmux list-sessions -F '#{session_name}' 2>/dev/null || true)" + [[ -n "$sessions" ]] && [[ "$sessions" == "$BOOTSTRAP_SESSION" ]] +} + +post_restore() { + [[ -x "$RESTORE_SCRIPT" ]] || die "restore script not found at $RESTORE_SCRIPT" + [[ -e "$LAST_FILE" ]] || die "no resurrect file found at $LAST_FILE" + + if ! "$RESTORE_SCRIPT"; then + tmux display-message "tmux restore failed" + exec "${SHELL:-/bin/zsh}" -l + fi + + if tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -qv "^${BOOTSTRAP_SESSION}$"; then + tmux kill-session -t "$BOOTSTRAP_SESSION" 2>/dev/null || true + fi +} + +usage() { + cat <<'EOF' +Usage: tmux-resurrect + +Restore the last tmux-resurrect snapshot, resume saved OpenCode panes, +and attach to tmux. +EOF +} + +if [[ "${1:-}" == "--post-restore" ]]; then + if [[ -n "${2:-}" ]]; then + BOOTSTRAP_SESSION="$2" + fi + post_restore + exit 0 +fi + +case "${1:-}" in + "" ) + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; +esac + +if tmux_has_sessions; then + if bootstrap_is_only_session; then + tmux kill-session -t "$BOOTSTRAP_SESSION" 2>/dev/null || true + else + exec tmux attach-session + fi +fi + +[[ -x "$RESTORE_SCRIPT" ]] || die "restore script not found at $RESTORE_SCRIPT" +[[ -e "$LAST_FILE" ]] || die "no resurrect file found at $LAST_FILE" + +SELF="$0" +if [[ "$SELF" != /* ]]; then + SELF="$(command -v "$SELF" || true)" +fi +[[ -n "$SELF" ]] || die "could not resolve script path" + +exec tmux new-session -A -s "$BOOTSTRAP_SESSION" "\"$SELF\" --post-restore \"$BOOTSTRAP_SESSION\"" diff --git a/opencode/tui-plugins/tracker-notify.ts b/opencode/tui-plugins/tracker-notify.ts index b49ccb3..64d06a4 100644 --- a/opencode/tui-plugins/tracker-notify.ts +++ b/opencode/tui-plugins/tracker-notify.ts @@ -1,10 +1,13 @@ -import { appendFileSync } from "fs"; +import { appendFileSync, mkdirSync, renameSync, writeFileSync } from "fs"; const NOTIFY_BIN = "/usr/bin/python3"; const NOTIFY_SCRIPT = "/Users/david/.config/codex/notify.py"; const MAX_SUMMARY_CHARS = 600; const TRACKER_BIN = "/Users/david/.config/agent-tracker/bin/tracker-client"; const LOG_FILE = "/tmp/tracker-notify-debug.log"; +const STATE_ROOT = process.env.XDG_STATE_HOME || `${process.env.HOME || ""}/.local/state`; +const OP_STATE_DIR = `${STATE_ROOT}/op`; +const TMUX_BINS = [process.env.TMUX_BIN, "/opt/homebrew/bin/tmux", "tmux"].filter(Boolean); const log = (msg: string, data?: any) => { const timestamp = new Date().toISOString(); @@ -17,6 +20,11 @@ const log = (msg: string, data?: any) => { }; export const TrackerNotifyPlugin = async ({ client, directory, $ }) => { + const trackerNotifyEnabled = process.env.OP_TRACKER_NOTIFY === "1"; + if (!trackerNotifyEnabled) { + return {}; + } + // Only run within tmux (TMUX_PANE must be set) const TMUX_PANE = process.env.TMUX_PANE; log("Plugin loading, TMUX_PANE:", TMUX_PANE); @@ -29,24 +37,61 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => { let tmuxContext = null; const resolveTmuxContext = async () => { if (tmuxContext) return tmuxContext; - try { - const result = await $`tmux display-message -p -t ${TMUX_PANE} "#{session_id}:::#{window_id}:::#{pane_id}"`.quiet(); - const parts = result.stdout.trim().split(":::"); - if (parts.length === 3) { - tmuxContext = { - sessionId: parts[0], - windowId: parts[1], - paneId: parts[2], - }; + for (const tmuxBin of TMUX_BINS) { + try { + const output = await $`${tmuxBin} display-message -p -t ${TMUX_PANE} "#{session_id}:::#{window_id}:::#{pane_id}:::#{session_name}:::#{window_index}:::#{pane_index}"`.text(); + const parts = output.trim().split(":::"); + if (parts.length === 6) { + tmuxContext = { + sessionId: parts[0], + windowId: parts[1], + paneId: parts[2], + sessionName: parts[3], + windowIndex: parts[4], + paneIndex: parts[5], + }; + break; + } + } catch { + // continue } - } catch { - // Fallback: use TMUX_PANE directly + } + if (!tmuxContext) { tmuxContext = { paneId: TMUX_PANE }; } return tmuxContext; }; await resolveTmuxContext(); + const sanitizeKey = (value = "") => value.replace(/[^A-Za-z0-9_]/g, "_"); + const paneLocator = () => { + if (!tmuxContext?.sessionName || !tmuxContext?.windowIndex || !tmuxContext?.paneIndex) { + return ""; + } + return `${tmuxContext.sessionName}:${tmuxContext.windowIndex}.${tmuxContext.paneIndex}`; + }; + const persistPaneSessionMap = async (sessionID) => { + const locator = paneLocator(); + if (!sessionID || !locator) return; + const stateFile = `${OP_STATE_DIR}/loc_${sanitizeKey(locator)}`; + try { + mkdirSync(OP_STATE_DIR, { recursive: true }); + const tmpPath = `${stateFile}.tmp`; + writeFileSync(tmpPath, `${sessionID}\n`, "utf8"); + renameSync(tmpPath, stateFile); + } catch (error) { + log("failed to persist pane session", { locator, error: String(error) }); + } + }; + const eventSessionID = (event) => { + return ( + event?.properties?.sessionID || + event?.properties?.session?.id || + event?.properties?.info?.id || + "" + ); + }; + let taskActive = false; let currentSessionID = null; let lastUserMessage = ""; @@ -195,22 +240,27 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => { } } - if (event?.type !== "session.status") return; + if (event?.type !== "session.updated" && event?.type !== "session.status") return; - const sessionID = event?.properties?.sessionID; - const status = event?.properties?.status; - if (!sessionID || !status) return; + const sessionID = eventSessionID(event); + if (!sessionID) return; - // Check if this is a subagent session - const session = await client.session.get({ path: { id: sessionID } }).catch(() => null); - const parentID = session?.data?.parentID; - - // Skip subagent sessions (they have a parentID) - if (parentID) { - return; - } + // Check if this is a subagent session + const session = await client.session.get({ path: { id: sessionID } }).catch(() => null); + const parentID = session?.data?.parentID; - if (status.type === "busy" && !taskActive) { + // Skip subagent sessions (they have a parentID) + if (parentID) { + return; + } + + await persistPaneSessionMap(sessionID); + if (event?.type !== "session.status") return; + + const status = event?.properties?.status; + if (!status) return; + + if (status.type === "busy" && !taskActive) { // Use captured message first, then fall back to API let text = lastUserMessage; if (!text) { diff --git a/tmux/scripts/focus_pane_by_position.sh b/tmux/scripts/focus_pane_by_position.sh new file mode 100755 index 0000000..6ce53c8 --- /dev/null +++ b/tmux/scripts/focus_pane_by_position.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="${1:-}" +window_id="${2:-}" + +if [[ -z "$target" ]]; then + echo "usage: $(basename "$0") [window_id]" >&2 + exit 1 +fi + +if [[ -z "$window_id" ]]; then + window_id="$(tmux display-message -p '#{window_id}')" +fi + +tmux list-panes -t "$window_id" -F '#{pane_id} #{pane_left} #{pane_top}' | python3 -c ' +import sys + +target = sys.argv[1] +panes = [] +for line in sys.stdin: + line = line.strip() + if not line: + continue + pane_id, left, top = line.split() + panes.append((pane_id, int(left), int(top))) + +if not panes: + sys.exit(1) + +if target == "left": + chosen = min(panes, key=lambda p: (p[1], p[2], p[0])) +elif target == "top-right": + max_left = max(p[1] for p in panes) + candidates = [p for p in panes if p[1] == max_left] + chosen = min(candidates, key=lambda p: (p[2], p[0])) +elif target == "bottom-right": + max_left = max(p[1] for p in panes) + candidates = [p for p in panes if p[1] == max_left] + chosen = max(candidates, key=lambda p: (p[2], p[0])) +else: + sys.exit(2) + +print(chosen[0]) +' "$target" diff --git a/tmux/scripts/new_session.sh b/tmux/scripts/new_session.sh index 3e47ff5..6eae33d 100755 --- a/tmux/scripts/new_session.sh +++ b/tmux/scripts/new_session.sh @@ -1,11 +1,22 @@ #!/bin/bash -session_id=$(tmux new-session -d -P -F '#{session_id}' 2>/dev/null) +LOCK="/tmp/tmux-new-session.lock" +current_session_id="${1:-}" + +touch "$LOCK" +session_id=$(tmux new-session -d -P -s 'session' -F '#{session_id}' 2>/dev/null) if [ -z "$session_id" ]; then + rm -f "$LOCK" exit 0 fi -python3 "$HOME/.config/tmux/scripts/session_manager.py" ensure +if [ -n "$current_session_id" ]; then + python3 "$HOME/.config/tmux/scripts/session_manager.py" insert-right "$current_session_id" "$session_id" +else + python3 "$HOME/.config/tmux/scripts/session_manager.py" ensure +fi + +rm -f "$LOCK" tmux switch-client -t "$session_id" diff --git a/tmux/scripts/open_agent_palette.sh b/tmux/scripts/open_agent_palette.sh new file mode 100755 index 0000000..6656a24 --- /dev/null +++ b/tmux/scripts/open_agent_palette.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +client_tty="${1-}" +window_id="${2-}" +agent_id="${3-}" +path_value="${4-}" +session_name="${5-}" +window_name="${6-}" + +exec tmux display-popup -E -c "$client_tty" -d "$path_value" -w 78% -h 80% -T agent \ + ~/.config/agent-tracker/bin/agent palette \ + --window="$window_id" \ + --agent-id="$agent_id" \ + --path="$path_value" \ + --session-name="$session_name" \ + --window-name="$window_name" diff --git a/tmux/scripts/post_resurrect_restore.sh b/tmux/scripts/post_resurrect_restore.sh new file mode 100755 index 0000000..4ff5d6f --- /dev/null +++ b/tmux/scripts/post_resurrect_restore.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +RESTART_OP_SCRIPT="${TMUX_RESTART_OP_SCRIPT:-${XDG_CONFIG_HOME:-$HOME/.config}/tmux/scripts/restart_opencode_pane.sh}" +RESTORE_AGENT_RUN_PANES_SCRIPT="${TMUX_RESTORE_AGENT_RUN_PANES_SCRIPT:-${XDG_CONFIG_HOME:-$HOME/.config}/tmux/scripts/restore_agent_run_panes.py}" +RESTORE_AGENT_TRACKER_SCRIPT="${TMUX_RESTORE_AGENT_TRACKER_SCRIPT:-${XDG_CONFIG_HOME:-$HOME/.config}/tmux/scripts/restore_agent_tracker_mapping.py}" + +resurrect_dir() { + if [[ -d "$HOME/.tmux/resurrect" ]]; then + printf '%s\n' "$HOME/.tmux/resurrect" + else + printf '%s\n' "${XDG_DATA_HOME:-$HOME/.local/share}/tmux/resurrect" + fi +} + +last_file="${TMUX_RESURRECT_LAST_FILE:-$(resurrect_dir)/last}" + +op_pane_locators() { + [[ -e "$last_file" ]] || return 0 + awk -F '\t' ' + $1 == "pane" && (index($7, "OpenCode") > 0 || index($11, "opencode") > 0) { + print $2 ":" $3 "." $6 + } + ' "$last_file" | awk '!seen[$0]++' +} + +resume_opencode_panes() { + [[ -x "$RESTART_OP_SCRIPT" ]] || return 0 + + local locator pane_id + while IFS= read -r locator; do + [[ -n "$locator" ]] || continue + pane_id="$(tmux display-message -p -t "$locator" '#{pane_id}' 2>/dev/null || true)" + [[ -n "$pane_id" ]] || continue + "$RESTART_OP_SCRIPT" "$pane_id" >/dev/null 2>&1 || true + done < <(op_pane_locators) +} + +restore_agent_tracker_mappings() { + [[ -x "$RESTORE_AGENT_TRACKER_SCRIPT" ]] || return 0 + "$RESTORE_AGENT_TRACKER_SCRIPT" >/dev/null 2>&1 || true +} + +restore_agent_run_panes() { + [[ -x "$RESTORE_AGENT_RUN_PANES_SCRIPT" ]] || return 0 + "$RESTORE_AGENT_RUN_PANES_SCRIPT" >/dev/null 2>&1 || true +} + +sleep 1 +resume_opencode_panes +restore_agent_run_panes +restore_agent_tracker_mappings diff --git a/tmux/scripts/restart_opencode_pane.sh b/tmux/scripts/restart_opencode_pane.sh new file mode 100755 index 0000000..13e302a --- /dev/null +++ b/tmux/scripts/restart_opencode_pane.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +pane_id="${1:-}" +if [[ -z "$pane_id" ]]; then + pane_id=$(tmux display-message -p '#{pane_id}') +fi + +if [[ -z "$pane_id" ]]; then + exit 1 +fi + +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/op" +pane_locator=$(tmux display-message -p -t "$pane_id" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null || true) +state_file="$state_dir/loc_${pane_locator//[^a-zA-Z0-9_]/_}" + +if [[ -z "$pane_locator" ]]; then + tmux display-message "op-restart: unable to resolve pane locator" + exit 0 +fi + +if [[ ! -f "$state_file" ]]; then + tmux display-message "op-restart: no saved session for ${pane_locator}" + exit 0 +fi + +IFS= read -r session_id < "$state_file" +if [[ -z "$session_id" ]]; then + tmux display-message "op-restart: invalid session mapping for ${pane_locator}" + exit 0 +fi + +wait_for_shell() { + local cmd i + for ((i = 0; i < 30; i++)); do + cmd=$(tmux display-message -p -t "$pane_id" '#{pane_current_command}' 2>/dev/null || true) + case "$cmd" in + zsh|bash|sh|fish|nu) + return 0 + ;; + esac + sleep 0.1 + done + return 1 +} + +kill_opencode_on_tty() { + local pane_tty tty_name pid + local -a pids=() + + pane_tty=$(tmux display-message -p -t "$pane_id" '#{pane_tty}' 2>/dev/null || true) + if [[ -z "$pane_tty" ]]; then + return 0 + fi + + tty_name="${pane_tty#/dev/}" + while IFS= read -r pid; do + [[ -n "$pid" ]] && pids+=("$pid") + done < <(ps -t "$tty_name" -o pid= -o args= 2>/dev/null | awk '/\/opt\/homebrew\/bin\/opencode|\/opt\/homebrew\/lib\/node_modules\/opencode-ai\/bin\/\.opencode|opencode-darwin-arm64\/bin\/opencode/ { print $1 }') + + if [[ ${#pids[@]} -eq 0 ]]; then + return 0 + fi + + kill -TERM "${pids[@]}" 2>/dev/null || true + sleep 0.3 + + for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + fi + done +} + +tmux send-keys -t "$pane_id" C-c + +if ! wait_for_shell; then + kill_opencode_on_tty + if ! wait_for_shell; then + tmux display-message "op-restart: pane ${pane_id} did not return to shell" + exit 0 + fi +fi + +tmux send-keys -t "$pane_id" "OP_TRACKER_NOTIFY=1 op -s ${session_id}" C-m diff --git a/tmux/scripts/restore_agent_run_panes.py b/tmux/scripts/restore_agent_run_panes.py new file mode 100755 index 0000000..b28e08d --- /dev/null +++ b/tmux/scripts/restore_agent_run_panes.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 + +import os +import re +import shlex +import subprocess +import sys +from pathlib import Path + + +HOME = Path.home() + + +def resurrect_dir() -> Path: + legacy = HOME / ".tmux" / "resurrect" + if legacy.is_dir(): + return legacy + data_home = Path(os.environ.get("XDG_DATA_HOME", str(HOME / ".local" / "share"))) + return data_home / "tmux" / "resurrect" + + +LAST_FILE = Path(os.environ.get("TMUX_RESURRECT_LAST_FILE", str(resurrect_dir() / "last"))) + + +def tmux_output(*args: str) -> str: + return subprocess.check_output(["tmux", *args], text=True).strip() + + +def tmux_run(*args: str) -> None: + subprocess.run(["tmux", *args], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def parse_device(command: str) -> str: + match = re.search(r"flutter run -d (?:\"([^\"]+)\"|'([^']+)'|(\S+))", command) + if not match: + return "" + for group in match.groups(): + if group: + return group.strip() + return "" + + +def iter_targets(): + if not LAST_FILE.exists(): + return + + with LAST_FILE.open() as handle: + for raw_line in handle: + parts = raw_line.rstrip("\n").split("\t") + if len(parts) < 11 or parts[0] != "pane": + continue + if parts[9].strip() != "script": + continue + + workspace = parts[7].lstrip(":").strip() + if "/.agents/" not in workspace: + continue + + device = parse_device(parts[10].lstrip(":").strip()) + if not device: + continue + + ensure_server = Path(workspace) / "ensure-server.sh" + if not ensure_server.is_file(): + continue + + locator = f"{parts[1]}:{parts[2]}.{parts[5]}" + yield locator, workspace, device + + +def main() -> int: + restored = 0 + + try: + targets = list(iter_targets()) + except Exception as exc: + print(f"restore-agent-run-panes: {exc}", file=sys.stderr) + return 1 + + seen = set() + for locator, workspace, device in targets: + if locator in seen: + continue + seen.add(locator) + + try: + current_command = tmux_output("display-message", "-p", "-t", locator, "#{pane_current_command}") + except Exception: + continue + if current_command.strip() != "script": + continue + + command = f"cd {shlex.quote(workspace)} && ./ensure-server.sh {shlex.quote(device)}" + tmux_run("respawn-pane", "-k", "-t", locator, command) + restored += 1 + + if restored: + print(f"restored {restored} agent run pane(s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tmux/scripts/restore_agent_tracker_mapping.py b/tmux/scripts/restore_agent_tracker_mapping.py new file mode 100755 index 0000000..b088cc1 --- /dev/null +++ b/tmux/scripts/restore_agent_tracker_mapping.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import sys +from pathlib import Path + + +HOME = Path.home() +AGENTS_PATH = HOME / ".config/agent-tracker/run/agents.json" +TODOS_PATH = HOME / ".cache/agent/todos.json" + + +def tmux_output(*args: str) -> str: + return subprocess.check_output(["tmux", *args], text=True).strip() + + +def tmux_run(*args: str) -> None: + subprocess.run(["tmux", *args], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def load_json(path: Path): + with path.open() as handle: + return json.load(handle) + + +def save_json(path: Path, payload) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as handle: + json.dump(payload, handle, indent=2) + handle.write("\n") + + +def list_windows(): + out = tmux_output("list-windows", "-a", "-F", "#{session_name}\t#{session_id}\t#{window_name}\t#{window_id}") + windows = [] + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) != 4: + continue + windows.append( + { + "session_name": parts[0].strip(), + "session_id": parts[1].strip(), + "window_name": parts[2].lstrip(":").strip(), + "window_id": parts[3].strip(), + } + ) + return windows + + +def list_panes(): + out = tmux_output("list-panes", "-a", "-F", "#{window_id}\t#{pane_index}\t#{pane_id}\t#{pane_current_path}") + panes = [] + for line in out.splitlines(): + parts = line.split("\t", 3) + if len(parts) != 4: + continue + try: + pane_index = int(parts[1].strip()) + except ValueError: + continue + panes.append( + { + "window_id": parts[0].strip(), + "pane_index": pane_index, + "pane_id": parts[2].strip(), + "path": parts[3].strip(), + } + ) + return panes + + +def detect_agent_id_from_path(path: str) -> str: + clean = os.path.normpath(path.strip()) + needle = f"{os.sep}.agents{os.sep}" + if needle not in clean: + return "" + rest = clean.split(needle, 1)[1] + if not rest: + return "" + return rest.split(os.sep, 1)[0].strip() + + +def merge_items(existing, incoming): + merged = list(existing) + merged.extend(incoming) + return merged + + +def main() -> int: + if not AGENTS_PATH.exists(): + return 0 + + try: + agents_payload = load_json(AGENTS_PATH) + windows = list_windows() + panes = list_panes() + except Exception as exc: + print(f"restore-agent-tracker-mapping: {exc}", file=sys.stderr) + return 1 + + windows_by_key = {(w["session_name"], w["window_name"]): w for w in windows} + panes_by_window = {} + inferred_window_for_agent = {} + for pane in panes: + panes_by_window.setdefault(pane["window_id"], []).append(pane) + agent_id = detect_agent_id_from_path(pane["path"]) + if agent_id and agent_id not in inferred_window_for_agent: + inferred_window_for_agent[agent_id] = pane["window_id"] + + windows_by_id = {w["window_id"]: w for w in windows} + + changed_agents = 0 + state_changed = False + window_migrations = {} + session_migrations = {} + + agents = agents_payload.get("agents", {}) + for agent_id, record in agents.items(): + session_name = str(record.get("tmux_session_name", "")).strip() + old_window_id = str(record.get("tmux_window_id", "")).strip() + old_session_id = str(record.get("tmux_session_id", "")).strip() + + match = windows_by_key.get((session_name, agent_id)) + if match is None: + inferred_window_id = inferred_window_for_agent.get(agent_id, "") + if inferred_window_id: + match = windows_by_id.get(inferred_window_id) + if match is None: + continue + + new_window_id = match["window_id"] + new_session_id = match["session_id"] + new_session_name = match["session_name"] + + if old_window_id and old_window_id != new_window_id: + window_migrations[old_window_id] = new_window_id + if old_session_id and old_session_id != new_session_id: + session_migrations[old_session_id] = new_session_id + + if ( + old_window_id != new_window_id + or old_session_id != new_session_id + or session_name != new_session_name + ): + changed_agents += 1 + state_changed = True + + record["tmux_window_id"] = new_window_id + record["tmux_session_id"] = new_session_id + record["tmux_session_name"] = new_session_name + + tmux_run("set-option", "-w", "-t", new_window_id, "@agent_id", agent_id) + + panes_for_window = sorted(panes_by_window.get(new_window_id, []), key=lambda pane: pane["pane_index"]) + if panes_for_window: + roles = ["ai", "git", "run"] + pane_record = dict(record.get("panes", {})) + for idx, role in enumerate(roles): + if idx >= len(panes_for_window): + break + pane_id = panes_for_window[idx]["pane_id"] + pane_record[role] = pane_id + tmux_run("set-option", "-p", "-t", pane_id, "@agent_role", role) + if pane_record != record.get("panes", {}): + state_changed = True + record["panes"] = pane_record + + if state_changed: + save_json(AGENTS_PATH, agents_payload) + + migrated_window_todos = 0 + migrated_session_todos = 0 + if TODOS_PATH.exists(): + todos_payload = load_json(TODOS_PATH) + windows_store = todos_payload.setdefault("windows", {}) + for old_id, new_id in window_migrations.items(): + if old_id == new_id or old_id not in windows_store: + continue + existing = windows_store.get(new_id, []) + incoming = windows_store.pop(old_id) + windows_store[new_id] = merge_items(existing, incoming) + migrated_window_todos += len(incoming) + + sessions_store = todos_payload.setdefault("sessions", {}) + for old_id, new_id in session_migrations.items(): + if old_id == new_id or old_id not in sessions_store: + continue + existing = sessions_store.get(new_id, []) + incoming = sessions_store.pop(old_id) + sessions_store[new_id] = merge_items(existing, incoming) + migrated_session_todos += len(incoming) + + if migrated_window_todos or migrated_session_todos: + save_json(TODOS_PATH, todos_payload) + + summary = [] + if changed_agents: + summary.append(f"{changed_agents} agent windows") + if migrated_window_todos: + summary.append(f"{migrated_window_todos} window todos") + if migrated_session_todos: + summary.append(f"{migrated_session_todos} session todos") + if summary: + print("restored " + ", ".join(summary)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tmux/scripts/resurrect_op_session.sh b/tmux/scripts/resurrect_op_session.sh new file mode 100755 index 0000000..317e43c --- /dev/null +++ b/tmux/scripts/resurrect_op_session.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Resurrect strategy for `op` (opencode). +# Called by tmux-resurrect as: script +# Returns the command to run in the pane. + +pane_full_command="$1" + +session_name=$(tmux display-message -p '#{session_name}' 2>/dev/null || true) +window_index=$(tmux display-message -p '#{window_index}' 2>/dev/null || true) +pane_index=$(tmux display-message -p '#{pane_index}' 2>/dev/null || true) + +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/op" + +if [[ -n "$session_name" && -n "$window_index" && -n "$pane_index" ]]; then + locator="${session_name}:${window_index}.${pane_index}" + key="${locator//[^a-zA-Z0-9_]/_}" + loc_file="$state_dir/loc_${key}" + if [[ -f "$loc_file" ]]; then + session_id=$(cat "$loc_file") + if [[ -n "$session_id" ]]; then + echo "OP_TRACKER_NOTIFY=1 op -s ${session_id}" + exit 0 + fi + fi +fi + +echo "OP_TRACKER_NOTIFY=1 op" diff --git a/tmux/scripts/session_created.sh b/tmux/scripts/session_created.sh index eba4f5f..5615d98 100755 --- a/tmux/scripts/session_created.sh +++ b/tmux/scripts/session_created.sh @@ -1,3 +1,8 @@ #!/bin/bash +LOCK="/tmp/tmux-new-session.lock" +if [ -f "$LOCK" ]; then + exit 0 +fi + python3 "$HOME/.config/tmux/scripts/session_manager.py" created diff --git a/tmux/scripts/session_manager.py b/tmux/scripts/session_manager.py index 9a4546d..5644770 100755 --- a/tmux/scripts/session_manager.py +++ b/tmux/scripts/session_manager.py @@ -120,6 +120,26 @@ def command_move(direction: str) -> None: apply_order(sessions) +def command_insert_right(anchor_id: str, moving_id: str) -> None: + if not anchor_id or not moving_id or anchor_id == moving_id: + command_ensure() + return + + sessions = list_sessions() + indices = {session["id"]: idx for idx, session in enumerate(sessions)} + if anchor_id not in indices or moving_id not in indices: + command_ensure() + return + + moving_session = sessions.pop(indices[moving_id]) + anchor_pos = next((idx for idx, session in enumerate(sessions) if session["id"] == anchor_id), None) + if anchor_pos is None: + sessions.append(moving_session) + else: + sessions.insert(anchor_pos + 1, moving_session) + apply_order(sessions) + + def command_ensure() -> None: sessions = list_sessions() if sessions: @@ -161,6 +181,8 @@ def main(argv: List[str]) -> None: command_rename(argv[2]) elif command == "move" and len(argv) >= 3: command_move(argv[2]) + elif command == "insert-right" and len(argv) >= 4: + command_insert_right(argv[2], argv[3]) elif command == "ensure": command_ensure() elif command == "created": diff --git a/tmux/scripts/swap_window_in_session.sh b/tmux/scripts/swap_window_in_session.sh new file mode 100644 index 0000000..338129d --- /dev/null +++ b/tmux/scripts/swap_window_in_session.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +direction="${1:-}" +case "$direction" in + left|right) ;; + *) exit 0 ;; +esac + +session_id=$(tmux display-message -p '#{session_id}' 2>/dev/null || true) +window_id=$(tmux display-message -p '#{window_id}' 2>/dev/null || true) +[[ -n "$session_id" && -n "$window_id" ]] || exit 0 + +windows=() +while IFS= read -r window; do + [[ -n "$window" ]] && windows+=("$window") +done < <(tmux list-windows -t "$session_id" -F '#{window_id}' 2>/dev/null || true) +count=${#windows[@]} +(( count >= 2 )) || exit 0 + +current=-1 +for i in "${!windows[@]}"; do + if [[ "${windows[$i]}" == "$window_id" ]]; then + current=$i + break + fi +done +(( current >= 0 )) || exit 0 + +if [[ "$direction" == "left" ]]; then + target_index=$(( current == 0 ? count - 1 : current - 1 )) +else + target_index=$(( current == count - 1 ? 0 : current + 1 )) +fi + +target_window_id="${windows[$target_index]}" +[[ -n "$target_window_id" && "$target_window_id" != "$window_id" ]] || exit 0 + +tmux swap-window -d -s "$window_id" -t "$target_window_id" diff --git a/tmux/scripts/watch_pane.sh b/tmux/scripts/watch_pane.sh index ec448e1..100ce16 100755 --- a/tmux/scripts/watch_pane.sh +++ b/tmux/scripts/watch_pane.sh @@ -6,20 +6,16 @@ window_id="$2" [[ -z "$pane_id" || -z "$window_id" ]] && exit 1 -shells="bash zsh fish sh dash ksh tcsh csh" +pane_pid=$(tmux display-message -p -t "$pane_id" '#{pane_pid}' 2>/dev/null || true) +[[ -z "$pane_pid" ]] && exit 0 -is_shell() { - local cmd="$1" - for s in $shells; do - [[ "$cmd" == "$s" ]] && return 0 - done - return 1 -} +pane_shell=$(ps -o comm= -p "$pane_pid" 2>/dev/null | sed 's|.*/||; s/^-//') +[[ -z "$pane_shell" ]] && exit 0 current_cmd=$(tmux display-message -p -t "$pane_id" '#{pane_current_command}' 2>/dev/null || true) [[ -z "$current_cmd" ]] && exit 0 -if is_shell "$current_cmd"; then +if [[ "$current_cmd" == "$pane_shell" ]]; then exit 0 fi @@ -31,7 +27,7 @@ while true; do watching=$(tmux show -wv -t "$window_id" @watching 2>/dev/null || true) [[ "$watching" != "1" ]] && exit 0 cmd=$(tmux display-message -p -t "$pane_id" '#{pane_current_command}' 2>/dev/null || true) - if [[ -z "$cmd" ]] || is_shell "$cmd"; then + if [[ -z "$cmd" || "$cmd" == "$pane_shell" ]]; then break fi done diff --git a/tmux/tmux-status/left.sh b/tmux/tmux-status/left.sh index 7e4ea9f..737ad4e 100755 --- a/tmux/tmux-status/left.sh +++ b/tmux/tmux-status/left.sh @@ -3,16 +3,11 @@ set -euo pipefail current_session_id="${1:-}" current_session_name="${2:-}" +term_width="${3:-}" +status_bg="${4:-}" -# Single tmux call to get all needed info -IFS=$'\t' read -r detect_session_id detect_session_name term_width status_bg < <( - tmux display-message -p '#{session_id} #{session_name} #{client_width} #{status-bg}' 2>/dev/null || echo "" -) - -[[ -z "$current_session_id" ]] && current_session_id="$detect_session_id" -[[ -z "$current_session_name" ]] && current_session_name="$detect_session_name" [[ -z "$status_bg" || "$status_bg" == "default" ]] && status_bg=black -term_width="${term_width:-100}" +[[ ! "$term_width" =~ ^[0-9]+$ ]] && term_width=100 inactive_bg="#373b41" inactive_fg="#c5c8c6" @@ -52,15 +47,14 @@ extract_index() { + sessions=$(tmux list-sessions -F '#{session_id}::#{session_name}' 2>/dev/null || true) if [[ -z "$sessions" ]]; then exit 0 fi -# Refresh tracker cache (fast - skips if recent) "$HOME/.config/tmux/tmux-status/tracker_cache.sh" 2>/dev/null || true -# Read from cache CACHE_FILE="/tmp/tmux-tracker-cache.json" tracker_state="" if [[ -f "$CACHE_FILE" ]]; then @@ -69,42 +63,40 @@ fi get_session_icon() { local sid="$1" + local has_bell=0 has_watch=0 - # Check for manual @watching on any window in this session - local watching_win - watching_win=$(tmux list-windows -t "$sid" -F '#{@watching}' 2>/dev/null | grep -m1 '^1$' || true) - - # Check for manual @unread on any window in this session local unread_win unread_win=$(tmux list-windows -t "$sid" -F '#{@unread}' 2>/dev/null | grep -m1 '^1$' || true) + [[ -n "$unread_win" ]] && has_bell=1 - if [[ -n "$unread_win" ]]; then + local watching_win + watching_win=$(tmux list-windows -t "$sid" -F '#{@watching}' 2>/dev/null | grep -m1 '^1$' || true) + [[ -n "$watching_win" ]] && has_watch=1 + + if [[ -n "$tracker_state" ]]; then + local result + result=$(echo "$tracker_state" | jq -r --arg sid "$sid" ' + .tasks // [] | .[] | select(.session_id == $sid) | + if .status == "completed" and .acknowledged != true then "waiting" + elif .status == "in_progress" then "in_progress" + else empty end + ' 2>/dev/null | head -1 || true) + case "$result" in + waiting) has_bell=1 ;; + in_progress) has_watch=1 ;; + esac + fi + + if (( has_bell )); then printf '🔔' - return - fi - if [[ -n "$watching_win" ]]; then + elif (( has_watch )); then printf '⏳' - return fi - - [[ -z "$tracker_state" ]] && return - local result - result=$(echo "$tracker_state" | jq -r --arg sid "$sid" ' - .tasks // [] | .[] | select(.session_id == $sid) | - if .status == "in_progress" then "in_progress" - elif .status == "completed" and .acknowledged != true then "waiting" - else empty end - ' 2>/dev/null | head -1 || true) - case "$result" in - in_progress) printf '⏳' ;; - waiting) printf '🔔' ;; - esac } rendered="" prev_bg="" current_session_id_norm=$(normalize_session_id "$current_session_id") -current_session_trimmed=$(trim_label "$current_session_name") while IFS= read -r entry; do [[ -z "$entry" ]] && continue session_id="${entry%%::*}" @@ -116,7 +108,7 @@ while IFS= read -r entry; do segment_fg="$inactive_fg" trimmed_name=$(trim_label "$name") is_current=0 - if [[ "$session_id" == "$current_session_id" || "$session_id_norm" == "$current_session_id_norm" || "$trimmed_name" == "$current_session_trimmed" ]]; then + if [[ "$session_id" == "$current_session_id" || "$session_id_norm" == "$current_session_id_norm" ]]; then is_current=1 segment_bg="$active_bg" segment_fg="$active_fg" @@ -124,7 +116,7 @@ while IFS= read -r entry; do if (( is_narrow == 1 )); then if (( is_current == 1 )); then - label="$trimmed_name" # active: show TITLE (trim N-) + label="$trimmed_name" else idx=$(extract_index "$name") if [[ -n "$idx" ]]; then @@ -134,7 +126,7 @@ while IFS= read -r entry; do fi fi else - label="$trimmed_name" # wide: current behavior (TITLE everywhere) + label="$trimmed_name" fi if (( ${#label} > max_width )); then label="${label:0:max_width-1}…" diff --git a/tmux/tmux-status/mem_usage.sh b/tmux/tmux-status/mem_usage.sh index 366bd12..54c2ca5 100755 --- a/tmux/tmux-status/mem_usage.sh +++ b/tmux/tmux-status/mem_usage.sh @@ -20,10 +20,10 @@ if [[ ! -f "$CACHE_FILE" ]]; then fi wkey="${session}:${window_idx}" -read -r pane_val win_val total_val < <( - jq -r --arg pid "$pane_id" --arg wk "$wkey" \ - '[(.pane[$pid] // ""), (.window[$wk] // ""), (.total // "")] | join(" ")' \ +read -r pane_val win_val sess_val total_val < <( + jq -r --arg pid "$pane_id" --arg wk "$wkey" --arg sess "$session" \ + '[(.pane[$pid] // ""), (.window[$wk] // ""), (.session[$sess] // ""), (.total // "")] | join(" ")' \ "$CACHE_FILE" 2>/dev/null || echo "" ) -printf '%s\n%s\n%s\n' "$pane_val" "$win_val" "$total_val" +printf '%s\n%s\n%s\n%s\n' "$pane_val" "$win_val" "$sess_val" "$total_val" diff --git a/tmux/tmux-status/mem_usage_cache.py b/tmux/tmux-status/mem_usage_cache.py index ad70457..949eac3 100755 --- a/tmux/tmux-status/mem_usage_cache.py +++ b/tmux/tmux-status/mem_usage_cache.py @@ -145,6 +145,7 @@ def _generate(): pane_mem: dict[str, float] = {} window_mem: dict[str, float] = {} + session_mem: dict[str, float] = {} for pane in panes: desc = get_descendants(pane["pane_pid"], children_map) @@ -154,13 +155,15 @@ def _generate(): wkey = f"{pane['session']}:{pane['window_idx']}" window_mem[wkey] = window_mem.get(wkey, 0) + total + session_mem[pane["session"]] = session_mem.get(pane["session"], 0) + total total_tmux = sum(pane_mem.values()) pane_fmt = {k: fmt(v) for k, v in pane_mem.items()} window_fmt = {k: fmt(v) for k, v in window_mem.items()} + session_fmt = {k: fmt(v) for k, v in session_mem.items()} - data = {"ts": time.time(), "pane": pane_fmt, "window": window_fmt, "total": fmt(total_tmux)} + data = {"ts": time.time(), "pane": pane_fmt, "window": window_fmt, "session": session_fmt, "total": fmt(total_tmux)} tmp = CACHE_FILE + ".tmp" with open(tmp, "w") as f: json.dump(data, f) diff --git a/tmux/tmux-status/notes_count.sh b/tmux/tmux-status/notes_count.sh index c5a65fe..181ce02 100755 --- a/tmux/tmux-status/notes_count.sh +++ b/tmux/tmux-status/notes_count.sh @@ -1,23 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -window_id=$(tmux display-message -p '#{window_id}' 2>/dev/null || true) +window_id="${1:-}" [[ -z "$window_id" ]] && exit 0 -CACHE_FILE="/tmp/tmux-tracker-cache.json" -[[ ! -f "$CACHE_FILE" ]] && exit 0 +todo_file="$HOME/.cache/agent/todos.json" +[[ -f "$todo_file" ]] || exit 0 -state=$(cat "$CACHE_FILE" 2>/dev/null || true) -[[ -z "$state" ]] && exit 0 - -count=$(echo "$state" | jq -r --arg wid "$window_id" ' - [.notes // [] | .[] | select( - .archived != true and - .completed != true and - .scope == "window" and - .window_id == $wid - )] | length -' 2>/dev/null || echo "0") +count=$(jq -r --arg wid "$window_id" '[(.windows[$wid] // [])[] | select(.done != true)] | length' "$todo_file" 2>/dev/null || echo "0") if [[ "$count" =~ ^[0-9]+$ ]] && (( count > 0 )); then printf ' %s ' "$count" diff --git a/tmux/tmux-status/right.sh b/tmux/tmux-status/right.sh index 56ae72d..646dc95 100755 --- a/tmux/tmux-status/right.sh +++ b/tmux/tmux-status/right.sh @@ -2,10 +2,13 @@ set -euo pipefail min_width=${TMUX_RIGHT_MIN_WIDTH:-90} -width=$(tmux display-message -p '#{client_width}' 2>/dev/null || true) -if [[ -z "${width:-}" || "$width" == "0" ]]; then - width=$(tmux display-message -p '#{window_width}' 2>/dev/null || true) -fi +width="${1:-}" +status_bg="${2:-}" +current_session="${3:-}" +current_window_index="${4:-}" +current_pane_id="${5:-}" +current_window_id="${6:-}" + if [[ -z "${width:-}" || "$width" == "0" ]]; then width=${COLUMNS:-} fi @@ -15,7 +18,6 @@ if [[ -n "${width:-}" && "$width" =~ ^[0-9]+$ ]]; then fi fi -status_bg=$(tmux show -gqv status-bg) if [[ -z "$status_bg" || "$status_bg" == "default" ]]; then status_bg=black fi @@ -34,21 +36,27 @@ mem_pane_bg="#5e81ac" mem_pane_fg="#eceff4" mem_win_bg="#4c566a" mem_win_fg="#eceff4" +mem_sess_bg="#434c5e" +mem_sess_fg="#eceff4" mem_total_bg="#3b4252" mem_total_fg="#eceff4" mem_pane_val="" mem_win_val="" +mem_sess_val="" mem_total_val="" mem_script="$HOME/.config/tmux/tmux-status/mem_usage.sh" -if [[ -x "$mem_script" ]]; then - IFS=$'\t' read -r _sess _widx _pid < <( - tmux display-message -p '#{session_name} #{window_index} #{pane_id}' 2>/dev/null || echo "" - ) - if [[ -n "${_sess:-}" && -n "${_widx:-}" && -n "${_pid:-}" ]]; then - mem_output=$("$mem_script" "$_sess" "$_widx" "$_pid" 2>/dev/null || true) +memory_toggle="${TMUX_STATUS_MEMORY:-1}" +case "$memory_toggle" in + 0|false|FALSE|off|OFF|no|NO) memory_toggle="0" ;; + *) memory_toggle="1" ;; +esac +if [[ "$memory_toggle" == "1" ]] && [[ -x "$mem_script" ]]; then + if [[ -n "$current_session" && -n "$current_window_index" && -n "$current_pane_id" ]]; then + mem_output=$("$mem_script" "$current_session" "$current_window_index" "$current_pane_id" 2>/dev/null || true) mem_pane_val=$(sed -n '1p' <<< "$mem_output") mem_win_val=$(sed -n '2p' <<< "$mem_output") - mem_total_val=$(sed -n '3p' <<< "$mem_output") + mem_sess_val=$(sed -n '3p' <<< "$mem_output") + mem_total_val=$(sed -n '4p' <<< "$mem_output") fi fi @@ -74,10 +82,34 @@ fi notes_output="" notes_count_script="$HOME/.config/tmux/tmux-status/notes_count.sh" if [[ -x "$notes_count_script" ]]; then - notes_output=$("$notes_count_script" 2>/dev/null || true) + notes_output=$("$notes_count_script" "$current_window_id" 2>/dev/null || true) fi -# --- Segment building (left to right: rainbarf | pane_mem | win_mem | notes | host) --- +# Flash-MoE +flashmoe_phase="" +flashmoe_tok_s="" +flashmoe_prompt_tokens="" +flashmoe_updated_ms="" +flashmoe_metrics="$HOME/.flash-moe/tmux_metrics" +if [[ -r "$flashmoe_metrics" ]]; then + while IFS='=' read -r key value; do + case "$key" in + phase) flashmoe_phase="$value" ;; + tok_s) flashmoe_tok_s="$value" ;; + prompt_tokens) flashmoe_prompt_tokens="$value" ;; + updated_ms) flashmoe_updated_ms="$value" ;; + esac + done < "$flashmoe_metrics" +fi +if [[ -n "$flashmoe_updated_ms" && "$flashmoe_updated_ms" =~ ^[0-9]+$ ]]; then + now_ms=$(( $(date +%s) * 1000 )) + age_ms=$(( now_ms - flashmoe_updated_ms )) + if (( age_ms > 10000 )) && [[ "$flashmoe_phase" == "gen" || "$flashmoe_phase" == "prefill" ]]; then + flashmoe_phase="idle" + fi +fi + +# --- Segment building (left to right: rainbarf | pane_mem | win_mem | notes | flashmoe | host) --- # Track the rightmost background for connector chaining prev_bg="$status_bg" @@ -99,6 +131,14 @@ if [[ -n "$mem_win_val" ]]; then prev_bg="$mem_win_bg" fi +mem_sess_segment="" +if [[ -n "$mem_sess_val" ]]; then + mem_sess_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s] %s ' \ + "$mem_sess_bg" "$prev_bg" "$separator" \ + "$mem_sess_fg" "$mem_sess_bg" "$mem_sess_val") + prev_bg="$mem_sess_bg" +fi + mem_total_segment="" if [[ -n "$mem_total_val" ]]; then mem_total_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s] %s ' \ @@ -117,16 +157,44 @@ if [[ -n "$notes_output" ]]; then prev_bg="$notes_bg" fi +flashmoe_segment="" +flashmoe_bg="#88c0d0" +flashmoe_fg="#1d1f21" +flashmoe_label="" +if [[ "$flashmoe_phase" == "prefill" ]]; then + flashmoe_bg="#ebcb8b" + if [[ "$flashmoe_prompt_tokens" =~ ^[0-9]+$ ]] && (( flashmoe_prompt_tokens > 0 )); then + flashmoe_label=" FM prefill:${flashmoe_prompt_tokens} " + else + flashmoe_label=" FM prefill " + fi +elif [[ "$flashmoe_phase" == "gen" ]]; then + flashmoe_bg="#a3be8c" + if [[ -n "$flashmoe_tok_s" && "$flashmoe_tok_s" != "0.00" ]]; then + flashmoe_label=" FM ${flashmoe_tok_s} tok/s " + else + flashmoe_label=" FM gen " + fi +fi +if [[ -n "$flashmoe_label" ]]; then + flashmoe_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s,bold]%s' \ + "$flashmoe_bg" "$prev_bg" "$separator" \ + "$flashmoe_fg" "$flashmoe_bg" "$flashmoe_label") + prev_bg="$flashmoe_bg" +fi + host_prefix=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s] ' \ "$host_bg" "$prev_bg" "$separator" \ "$host_fg" "$host_bg") -printf '%s%s%s%s%s%s%s #[fg=%s,bg=%s]%s' \ +printf '%s%s%s%s%s%s%s%s%s #[fg=%s,bg=%s]%s' \ "$rainbarf_segment" \ "$mem_pane_segment" \ "$mem_win_segment" \ + "$mem_sess_segment" \ "$mem_total_segment" \ "$notes_segment" \ + "$flashmoe_segment" \ "$host_prefix" \ "$hostname" \ "$host_bg" "$status_bg" "$right_cap" diff --git a/tmux/tmux-status/session_task_icon.sh b/tmux/tmux-status/session_task_icon.sh index 3ddfd79..8814348 100755 --- a/tmux/tmux-status/session_task_icon.sh +++ b/tmux/tmux-status/session_task_icon.sh @@ -4,10 +4,10 @@ set -euo pipefail session_id="$1" [[ -z "$session_id" ]] && exit 0 -tracker_client="$HOME/.config/agent-tracker/bin/tracker-client" -[[ ! -x "$tracker_client" ]] && exit 0 +agent_bin="$HOME/.config/agent-tracker/bin/agent" +[[ ! -x "$agent_bin" ]] && exit 0 -state=$("$tracker_client" state 2>/dev/null || true) +state=$("$agent_bin" tracker state 2>/dev/null || true) [[ -z "$state" ]] && exit 0 # Check for in_progress or unacknowledged completed tasks in this session diff --git a/tmux/tmux-status/tracker_cache.sh b/tmux/tmux-status/tracker_cache.sh index 7c47d40..5dd7e75 100755 --- a/tmux/tmux-status/tracker_cache.sh +++ b/tmux/tmux-status/tracker_cache.sh @@ -4,7 +4,7 @@ set -euo pipefail CACHE_FILE="/tmp/tmux-tracker-cache.json" CACHE_MAX_AGE=1 -tracker_client="$HOME/.config/agent-tracker/bin/tracker-client" +agent_bin="$HOME/.config/agent-tracker/bin/agent" # Check if cache is fresh enough if [[ -f "$CACHE_FILE" ]]; then @@ -25,8 +25,8 @@ if ! mkdir "$LOCK_DIR" 2>/dev/null; then fi trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT -if [[ -x "$tracker_client" ]]; then - "$tracker_client" state 2>/dev/null > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" +if [[ -x "$agent_bin" ]]; then + "$agent_bin" tracker state 2>/dev/null > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" else echo '{}' > "$CACHE_FILE" fi diff --git a/tmux/tmux-status/window_task_icon.sh b/tmux/tmux-status/window_task_icon.sh index 67b4d50..7a218c5 100755 --- a/tmux/tmux-status/window_task_icon.sh +++ b/tmux/tmux-status/window_task_icon.sh @@ -1,37 +1,37 @@ #!/usr/bin/env bash set -euo pipefail -window_id="$1" +window_id="${1:-}" +unread="${2:-0}" +watching="${3:-0}" [[ -z "$window_id" ]] && exit 0 -# Check manual @watching flag -watching=$(tmux show -wv -t "$window_id" @watching 2>/dev/null || true) -if [[ "$watching" == "1" ]]; then - printf '⏳' - exit 0 -fi +has_bell=0 +has_watch=0 -# Check manual @unread flag -unread=$(tmux show -wv -t "$window_id" @unread 2>/dev/null || true) -if [[ "$unread" == "1" ]]; then - printf '🔔' - exit 0 -fi +[[ "$unread" == "1" ]] && has_bell=1 + +[[ "$watching" == "1" ]] && has_watch=1 CACHE_FILE="/tmp/tmux-tracker-cache.json" -[[ ! -f "$CACHE_FILE" ]] && exit 0 +if [[ -f "$CACHE_FILE" ]]; then + state=$(cat "$CACHE_FILE" 2>/dev/null || true) + if [[ -n "$state" ]]; then + result=$(echo "$state" | jq -r --arg wid "$window_id" ' + .tasks // [] | .[] | select(.window_id == $wid) | + if .status == "completed" and .acknowledged != true then "waiting" + elif .status == "in_progress" then "in_progress" + else empty end + ' 2>/dev/null | head -1 || true) + case "$result" in + waiting) has_bell=1 ;; + in_progress) has_watch=1 ;; + esac + fi +fi -state=$(cat "$CACHE_FILE" 2>/dev/null || true) -[[ -z "$state" ]] && exit 0 - -result=$(echo "$state" | jq -r --arg wid "$window_id" ' - .tasks // [] | .[] | select(.window_id == $wid) | - if .status == "in_progress" then "in_progress" - elif .status == "completed" and .acknowledged != true then "waiting" - else empty end -' 2>/dev/null | head -1 || true) - -case "$result" in - in_progress) printf '⏳' ;; - waiting) printf '🔔' ;; -esac +if (( has_bell )); then + printf '🔔' +elif (( has_watch )); then + printf '⏳' +fi diff --git a/zsh/functions/_op_common.zsh b/zsh/functions/_op_common.zsh index 5c031c4..5061bb2 100644 --- a/zsh/functions/_op_common.zsh +++ b/zsh/functions/_op_common.zsh @@ -111,35 +111,12 @@ _op_run() { fi fi - local explicit_session="" - local i - for (( i=1; i<=${#opencode_cmd[@]}; i++ )); do - case "${opencode_cmd[$i]}" in - -s|--session) explicit_session="${opencode_cmd[$((i+1))]}"; break ;; - -s=*|--session=*) explicit_session="${opencode_cmd[$i]#*=}"; break ;; - esac - done - OPENCODE_CONFIG_DIR="$tmp_home" \ + OP_TRACKER_NOTIFY="${OP_TRACKER_NOTIFY:-0}" \ RIPGREP_CONFIG_PATH="${RIPGREP_CONFIG_PATH:-$HOME/.ripgreprc}" \ "${opencode_cmd[@]}" local exit_code=$? - local session_to_save="" - if [ -n "$explicit_session" ]; then - session_to_save="$explicit_session" - else - session_to_save=$(OPENCODE_CONFIG_DIR="$tmp_home" opencode session list 2>/dev/null | awk 'NR==3{print $1}') - fi - - if [ -n "$session_to_save" ] && [ -n "$TMUX" ]; then - local win_key - win_key=$(tmux display-message -p '#{session_name}:#{window_name}') - local state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/op" - mkdir -p "$state_dir" - printf '%s\n%s\n' "$session_to_save" "$tag" > "$state_dir/window_${win_key//[^a-zA-Z0-9_]/_}" - fi - trap - EXIT INT TERM eval "$cleanup_cmd" return $exit_code diff --git a/zsh/functions/op.zsh b/zsh/functions/op.zsh index 7fa9619..b15f258 100644 --- a/zsh/functions/op.zsh +++ b/zsh/functions/op.zsh @@ -13,5 +13,5 @@ op() { source "$common" || return 1 fi - _op_run op "$@" + OP_TRACKER_NOTIFY=1 _op_run op "$@" } diff --git a/zsh/functions/opr.zsh b/zsh/functions/opr.zsh index 6fd0219..f34de8e 100644 --- a/zsh/functions/opr.zsh +++ b/zsh/functions/opr.zsh @@ -4,24 +4,36 @@ opr() { return 1 fi - local win_key - win_key=$(tmux display-message -p '#{session_name}:#{window_name}') - local state_file="${XDG_STATE_HOME:-$HOME/.local/state}/op/window_${win_key//[^a-zA-Z0-9_]/_}" - - if [ ! -f "$state_file" ]; then - print -u2 "opr: no previous session for this tmux window" + local pane_target="${TMUX_PANE:-}" + if [ -z "$pane_target" ]; then + pane_target=$(tmux display-message -p '#{pane_id}') + fi + if [ -z "$pane_target" ]; then + print -u2 "opr: unable to determine tmux pane id" return 1 fi - local session_id tag - { read -r session_id; read -r tag; } < "$state_file" + local pane_locator + pane_locator=$(tmux display-message -p -t "$pane_target" '#{session_name}:#{window_index}.#{pane_index}') + if [ -z "$pane_locator" ]; then + print -u2 "opr: unable to determine tmux pane locator" + return 1 + fi + local state_file="${XDG_STATE_HOME:-$HOME/.local/state}/op/loc_${pane_locator//[^a-zA-Z0-9_]/_}" + + if [ ! -f "$state_file" ]; then + print -u2 "opr: no previous session for this tmux pane locator" + return 1 + fi + + local session_id + IFS= read -r session_id < "$state_file" if [ -z "$session_id" ]; then print -u2 "opr: no previous session to resume" return 1 fi - tag="${tag:-op}" print -u2 "opr: resuming session $session_id" if ! typeset -f _op_run >/dev/null; then @@ -38,5 +50,5 @@ opr() { source "$common" || return 1 fi - _op_run "$tag" --session "$session_id" "$@" + _op_run op --session "$session_id" "$@" }