Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions api/v1/handlers_ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ func (mlh *MindloopHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
// Re-initialize AI service with current DB
aiService := ai.NewService(mlh.journal.DB) // hack: accessing DB via a service that has it
provider, model, token, _ := aiService.GetSettings()

hasToken := token != ""

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"provider": provider,
Expand Down Expand Up @@ -71,7 +71,7 @@ func (mlh *MindloopHandler) HandleTestAIConnection(w http.ResponseWriter, r *htt
}

aiService := ai.NewService(mlh.journal.DB)

// If token is empty in the request, fallback to the saved token
if req.Token == "" {
_, _, savedToken, _ := aiService.GetSettings()
Expand Down
2 changes: 1 addition & 1 deletion cmd/cli/journal.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ func init() {
journalCmd.AddCommand(journalListCmd)
journalCmd.AddCommand(journalViewCmd)
journalCmd.AddCommand(journalDeleteCmd)

generateCmd.Flags().BoolP("daily", "d", false, "Generate daily summary")
generateCmd.Flags().BoolP("weekly", "w", false, "Generate weekly summary")
generateCmd.Flags().BoolP("yearly", "y", false, "Generate yearly summary")
Expand Down
150 changes: 150 additions & 0 deletions cmd/cli/void.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package cli

import (
"fmt"
"math"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"

"github.com/snehmatic/mindloop/internal/utils"
"github.com/spf13/cobra"
)

var voidCmd = &cobra.Command{
Use: "void [minutes]",
Short: "Enter the void",
Long: `Enter the void for a specified number of minutes to focus on your breathing. Default is 5 minutes.`,
Example: `mindloop void 5`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
minutes := 5
if len(args) > 0 {
var err error
minutes, err = strconv.Atoi(args[0])
if err != nil || minutes <= 0 {
utils.PrintErrorln("Minutes must be a positive integer.")
return
}
}

duration := time.Duration(minutes) * time.Minute
utils.PrintRocketln(fmt.Sprintf("Entering the void for %d minutes...", minutes))
time.Sleep(1 * time.Second)

// Hide cursor
fmt.Print("\033[?25l")
// Ensure cursor is shown on exit
defer fmt.Print("\033[?25h")

// Handle interrupt signals (Ctrl+C) gracefully
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
fmt.Print("\033[?25h") // Show cursor
fmt.Print("\033[H\033[2J") // Clear screen
fmt.Println("\nThe void demands your attention.")
os.Exit(0)
}()

end := time.Now().Add(duration)
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()

start := time.Now()

// Clear screen to enter the void
fmt.Print("\033[H\033[2J")

for {
now := time.Now()
if now.After(end) {
break
}
remaining := end.Sub(now)
elapsed := now.Sub(start)

// 16-second Box Breathing cycle
// Inhale: 4s, Hold: 4s, Exhale: 4s, Hold: 4s. Total: 16s
cycle := math.Mod(elapsed.Seconds(), 16.0)

var phase string
var progress float64

if cycle < 4.0 {
phase = "INHALE"
t := cycle / 4.0
invT := 1.0 - t
progress = 1.0 - (invT * invT * invT) // ease out
} else if cycle < 8.0 {
phase = "HOLD"
progress = 1.0
} else if cycle < 12.0 {
phase = "EXHALE"
t := (cycle - 8.0) / 4.0
progress = 1.0 - (t * t * (3.0 - 2.0*t)) // smoothstep
if progress < 0 {
progress = 0
}
} else {
phase = "HOLD"
progress = 0.0
}

// Render the UI
// Move cursor to top-left to redraw smoothly
fmt.Print("\033[H")

fmt.Print("\033[K\n\033[K\n\033[K\n")
// 15 chars, centered at 24 = pad 17
fmt.Print(" \033[1;37mT H E V O I D\033[0m\033[K\n")
// 19 chars, centered at 24 = pad 15
fmt.Print(" \033[38;5;238m───────────────────\033[0m\033[K\n\033[K\n\033[K\n")

// Center the phase text around column 24
paddingPhase := 24 - (len(phase) / 2)
fmt.Printf("%s\033[1;36m%s\033[0m\033[K\n\033[K\n\033[K\n", strings.Repeat(" ", paddingPhase), phase)

// Breathing visual
minWidth := 3
maxWidth := 12

// Interpolate between minWidth and maxWidth
currentWidth := minWidth + int(math.Round(progress*float64(maxWidth-minWidth)))

// Each block is "█ ". We trim the trailing space to ensure perfect centering.
// Visual width is currentWidth * 2 - 1
visualWidth := currentWidth*2 - 1
paddingBar := 24 - (visualWidth / 2)

barStr := strings.TrimSpace(strings.Repeat("█ ", currentWidth))
bar := strings.Repeat(" ", paddingBar) + barStr

fmt.Printf("\033[1;36m%s\033[0m\033[K\n\033[K\n\033[K\n", bar)

// Timer
paddingTimer := 24 - 2 // 22 spaces
fmt.Printf("%s\033[38;5;245m%02d:%02d\033[0m\033[K\n\033[K\n", strings.Repeat(" ", paddingTimer), int(remaining.Minutes()), int(remaining.Seconds())%60)

// Exit text
paddingExit := 24 - 12 // 12 spaces
fmt.Printf("%s\033[38;5;238m[ Press Ctrl+C to exit ]\033[0m\033[K\n", strings.Repeat(" ", paddingExit))
// Clear rest of screen
fmt.Print("\033[J")

<-ticker.C
}

// Clear screen on exit
fmt.Print("\033[H\033[2J")
utils.PrintSuccessln("You have successfully returned from the void.")
},
}

func init() {
rootCmd.AddCommand(voidCmd)
}
13 changes: 7 additions & 6 deletions internal/core/ai/ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func (s *Service) GetSettings() (provider, model, token string, err error) {
func (s *Service) SaveSettings(provider, model, token string) error {
s.saveOrUpdate(SettingKeyAIProvider, provider)
s.saveOrUpdate(SettingKeyAIModel, model)

if token != "" {
encrypted, err := utils.Encrypt(token)
if err != nil {
Expand Down Expand Up @@ -113,7 +113,7 @@ func (s *Service) listGeminiModels(token string) ([]string, error) {

var result struct {
Models []struct {
Name string `json:"name"`
Name string `json:"name"`
SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
} `json:"models"`
}
Expand Down Expand Up @@ -168,6 +168,7 @@ func (s *Service) listOpenAIModels(token string) ([]string, error) {
}
return models, nil
}

// TestConnection sends a minimal prompt to verify the configuration works
func (s *Service) TestConnection(provider, model, token string) error {
if token == "" {
Expand Down Expand Up @@ -216,7 +217,7 @@ func (s *Service) generateGemini(model, token, contextData string) (string, erro
}
model = strings.TrimPrefix(model, "models/")
url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", model, token)

reqBody := map[string]interface{}{
"system_instruction": map[string]interface{}{
"parts": map[string]interface{}{"text": JournalSystemPrompt},
Expand All @@ -229,7 +230,7 @@ func (s *Service) generateGemini(model, token, contextData string) (string, erro
},
},
}

bodyBytes, _ := json.Marshal(reqBody)
resp, err := http.Post(url, "application/json", bytes.NewBuffer(bodyBytes))
if err != nil {
Expand Down Expand Up @@ -268,15 +269,15 @@ func (s *Service) generateOpenAI(model, token, contextData string) (string, erro
model = "gpt-4o-mini"
}
url := "https://api.openai.com/v1/chat/completions"

reqBody := map[string]interface{}{
"model": model,
"messages": []map[string]interface{}{
{"role": "system", "content": JournalSystemPrompt},
{"role": "user", "content": "Here is my activity summary data:\n" + contextData},
},
}

bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
Expand Down