package ui import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/your-username/todo/src/ai" "github.com/your-username/todo/src/database" "github.com/your-username/todo/src/models" ) var ( titleStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("62")) completedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("42")) // Green color taskStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")). Padding(1, 2) statusStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("34")) // Blue color dimmedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) // Dimmed gray color ) type Model struct { list list.Model // Changed from *list.Model to list.Model titleInput textinput.Model bodyInput textinput.Model searchInput textinput.Model spinner spinner.Model db database.Database aiService *ai.AIService state State selected *database.Task errorMsg string filter string currentPage int itemsPerPage int showCompleted bool analysisResults map[int64]*database.Analysis isLoading bool // Track loading state analyzingTasks map[int]bool analysisTimeout time.Duration } type State int const ( StateNormal State = iota StateCreating StateEditing StateSearching StateViewing StateAnalyzing ) func New(db database.Database, aiService *ai.AIService) Model { ti := textinput.New() ti.Placeholder = "Task title" ti.Focus() bi := textinput.New() bi.Placeholder = "Task description (optional)" si := textinput.New() si.Placeholder = "Search tasks..." delegate := list.NewDefaultDelegate() delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("62")) delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("241")) taskList := list.New(nil, delegate, 0, 0) taskList.Title = "Todo Tasks" taskList.SetShowTitle(true) taskList.SetFilteringEnabled(true) s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) m := Model{ list: taskList, titleInput: ti, bodyInput: bi, searchInput: si, spinner: s, db: db, aiService: aiService, state: StateNormal, itemsPerPage: 10, analysisResults: make(map[int64]*database.Analysis), isLoading: true, analyzingTasks: make(map[int]bool), showCompleted: true, // Show all tasks by default analysisTimeout: 5 * time.Second, } return m } func (m Model) Init() tea.Cmd { m.isLoading = true // Set initial loading state return tea.Batch(m.loadTasks, m.spinner.Tick) } func (m Model) loadTasks() tea.Msg { dbTasks, err := m.db.GetTasks(context.Background(), m.showCompleted) if err != nil { return errMsg{err} } // Convert database.Task to models.Task var items []list.Item for _, t := range dbTasks { items = append(items, taskItem{ task: models.Task{ ID: t.ID, Title: t.Title, Completed: t.Completed, CreatedAt: t.CreatedAt, }, analyzing: m.analyzingTasks[int(t.ID)], }) } return tasksLoadedMsg{items: items} } func (m Model) updateTaskList(tasks []models.Task) { items := make([]list.Item, len(tasks)) for i, task := range tasks { items[i] = taskItem{ task: task, analyzing: m.analyzingTasks[int(task.ID)], } } m.list.SetItems(items) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case nil: // Initial update, load tasks m.isLoading = true return m, m.loadTasks case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case tasksLoadedMsg: m.isLoading = false m.list.SetItems(msg.items) if len(msg.items) > 0 { m.list.Select(0) } return m, nil case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "n": if m.state == StateNormal { m.state = StateCreating m.titleInput.Focus() return m, textinput.Blink } case "/": if m.state == StateNormal { m.state = StateSearching m.searchInput.Focus() return m, textinput.Blink } case "e": if m.state == StateNormal { if i, ok := m.list.SelectedItem().(taskItem); ok { m.state = StateEditing m.selected = &database.Task{ID: i.task.ID, Title: i.task.Title, Completed: i.task.Completed} m.titleInput.SetValue(i.task.Title) m.titleInput.Focus() return m, textinput.Blink } } case "tab": if m.state == StateCreating || m.state == StateEditing { if m.titleInput.Focused() { m.titleInput.Blur() m.bodyInput.Focus() return m, textinput.Blink } else if m.bodyInput.Focused() { m.bodyInput.Blur() m.titleInput.Focus() return m, textinput.Blink } } m.showCompleted = !m.showCompleted m.filter = "" // Clear filter when toggling completed tasks m.isLoading = true return m, m.loadTasks case "shift+tab": if m.state == StateCreating || m.state == StateEditing { if m.titleInput.Focused() { m.titleInput.Blur() m.bodyInput.Focus() return m, textinput.Blink } else if m.bodyInput.Focused() { m.bodyInput.Blur() m.titleInput.Focus() return m, textinput.Blink } } case "c": if m.state == StateNormal { if i, ok := m.list.SelectedItem().(taskItem); ok { err := m.db.CompleteTask(context.Background(), int(i.task.ID)) if err != nil { m.errorMsg = fmt.Sprintf("Failed to complete task: %v", err) } m.showCompleted = true // Show completed tasks after completing one m.filter = "" // Clear filter when showing completed tasks m.isLoading = true return m, m.loadTasks } } case "a": if m.state == StateNormal || m.state == StateViewing { if i, ok := m.list.SelectedItem().(taskItem); ok { m.state = StateAnalyzing m.selected = &database.Task{ID: i.task.ID, Title: i.task.Title, Completed: i.task.Completed} m.analyzingTasks[int(i.task.ID)] = true return m, m.analyzeTask(i.task) } } return m, nil case "enter": switch m.state { case StateCreating: if m.titleInput.Focused() && m.titleInput.Value() != "" { m.titleInput.Blur() m.bodyInput.Focus() return m, textinput.Blink } else if m.bodyInput.Focused() { if m.titleInput.Value() != "" { _, err := m.db.AddTask(context.Background(), m.titleInput.Value(), m.bodyInput.Value()) if err != nil { m.errorMsg = fmt.Sprintf("Failed to add task: %v", err) } m.titleInput.Reset() m.bodyInput.Reset() m.state = StateNormal m.isLoading = true return m, m.loadTasks } } case StateEditing: if m.titleInput.Focused() && m.titleInput.Value() != "" { m.titleInput.Blur() m.bodyInput.Focus() return m, textinput.Blink } else if m.bodyInput.Focused() && m.selected != nil { err := m.db.UpdateTask(context.Background(), m.selected.ID, m.titleInput.Value(), m.bodyInput.Value()) if err != nil { m.errorMsg = fmt.Sprintf("Failed to update task: %v", err) } m.titleInput.Reset() m.bodyInput.Reset() m.selected = nil m.state = StateNormal m.isLoading = true return m, m.loadTasks } case StateSearching: m.filter = m.searchInput.Value() m.searchInput.Reset() m.state = StateNormal m.isLoading = true return m, m.loadTasks case StateNormal: if i, ok := m.list.SelectedItem().(taskItem); ok { m.selected = &database.Task{ID: i.task.ID, Title: i.task.Title, Completed: i.task.Completed} m.state = StateViewing // Load existing analysis when viewing task analysis, err := m.db.GetTaskAnalysis(context.Background(), int(i.task.ID)) if err == nil && analysis != nil { if m.analysisResults == nil { m.analysisResults = make(map[int64]*database.Analysis) } m.analysisResults[i.task.ID] = analysis return m, nil } // If no analysis exists, run the analysis return m, m.analyzeTask(i.task) } } case "esc": if m.state != StateNormal { m.state = StateNormal m.errorMsg = "" m.selected = nil m.titleInput.Reset() m.bodyInput.Reset() if m.state == StateSearching { m.filter = "" } m.isLoading = true return m, m.loadTasks } } case tea.WindowSizeMsg: m.list.SetWidth(msg.Width) m.list.SetHeight(msg.Height) return m, nil case analysisMsg: if m.state == StateAnalyzing { // Get the currently selected task if i, ok := m.list.SelectedItem().(taskItem); ok { // Create clean, properly formatted JSON analysis := &database.Analysis{ TaskID: i.task.ID, Result: fmt.Sprintf(`{"priority":%d,"category":"%s","estimate":%d,"related":"%s","summary":"%s"}`, msg.result.Priority, msg.result.Category, msg.result.TimeEstimate, msg.result.RelatedTasks, msg.result.Summary), AnalyzedAt: time.Now(), } if err := m.db.StoreAnalysis(context.Background(), analysis); err != nil { m.errorMsg = fmt.Sprintf("Failed to store analysis: %v", err) } else { if m.analysisResults == nil { m.analysisResults = make(map[int64]*database.Analysis) } m.analysisResults[i.task.ID] = analysis } delete(m.analyzingTasks, int(i.task.ID)) } m.state = StateViewing return m, nil } case errMsg: m.errorMsg = msg.Error() return m, nil } // Update the appropriate input based on current state var cmd tea.Cmd switch m.state { case StateAnalyzing: var spinnerCmd tea.Cmd m.spinner, spinnerCmd = m.spinner.Update(msg) cmds = append(cmds, spinnerCmd) case StateCreating, StateEditing: m.titleInput, cmd = m.titleInput.Update(msg) cmds = append(cmds, cmd) m.bodyInput, cmd = m.bodyInput.Update(msg) cmds = append(cmds, cmd) case StateSearching: m.searchInput, cmd = m.searchInput.Update(msg) cmds = append(cmds, cmd) case StateNormal: if !m.isLoading { m.list, cmd = m.list.Update(msg) cmds = append(cmds, cmd) } var spinnerCmd tea.Cmd m.spinner, spinnerCmd = m.spinner.Update(msg) cmds = append(cmds, spinnerCmd) } return m, tea.Batch(cmds...) } func (m Model) View() string { var b strings.Builder if m.errorMsg != "" { b.WriteString(fmt.Sprintf("Error: %s\n\n", m.errorMsg)) } switch m.state { case StateCreating: b.WriteString("Add new task:\n\n") b.WriteString("Title:\n") b.WriteString(m.titleInput.View()) b.WriteString("\n\nDescription:\n") b.WriteString(m.bodyInput.View()) b.WriteString("\n\n(Tab/Shift+Tab to switch fields, Enter to move to description or save, ESC to cancel)") case StateEditing: b.WriteString("Edit task:\n\n") b.WriteString("Title:\n") b.WriteString(m.titleInput.View()) b.WriteString("\n\nDescription:\n") b.WriteString(m.bodyInput.View()) b.WriteString("\n\n(Tab/Shift+Tab to switch fields, Enter to move to description or save, ESC to cancel)") case StateSearching: b.WriteString("Search tasks:\n\n") b.WriteString(m.searchInput.View()) b.WriteString("\n\n(ESC to cancel)") case StateViewing: if m.selected != nil { b.WriteString(m.renderTaskDetail()) return b.String() } case StateAnalyzing: b.WriteString(fmt.Sprintf("\n\n%s Analyzing task...\n", m.spinner.View())) return taskStyle.Render(b.String()) default: header := fmt.Sprintf( "%s\n\nPress 'n' for new task, 'e' to edit task, '/' to search, TAB to toggle completed, 'c' to complete task\n", titleStyle.Render("Your Tasks"), ) b.WriteString(header) if m.isLoading { b.WriteString(fmt.Sprintf("\n%s Loading tasks...\n", m.spinner.View())) } else { b.WriteString(m.list.View()) } } return b.String() } func (m Model) renderTaskDetail() string { var b strings.Builder if m.selected == nil { return "" } title := m.selected.Title if m.selected.Completed { title = completedStyle.Render(title) } b.WriteString(fmt.Sprintf("Title: %s\n\n", title)) // Add analysis results if available if analysis, ok := m.analysisResults[m.selected.ID]; ok { var result ai.AnalysisResult if err := json.Unmarshal([]byte(analysis.Result), &result); err == nil { b.WriteString("\nAnalysis Results:\n") b.WriteString(fmt.Sprintf("Priority: %d/5\n", result.Priority)) b.WriteString(fmt.Sprintf("Category: %s\n", result.Category)) b.WriteString(fmt.Sprintf("Time Estimate: %d minutes\n", result.TimeEstimate)) if result.RelatedTasks != "" { b.WriteString(fmt.Sprintf("Related Tasks: %s\n", result.RelatedTasks)) } if result.Summary != "" && result.Summary != title { b.WriteString(fmt.Sprintf("Summary: %s\n", result.Summary)) } b.WriteString(fmt.Sprintf("\nAnalyzed at: %s", analysis.AnalyzedAt.Format("2006-01-02 15:04:05"))) } } b.WriteString("\n\nPress 'a' to analyze task, ESC to return") return taskStyle.Render(b.String()) } func (m Model) analyzeTask(task models.Task) tea.Cmd { return func() tea.Msg { // Create a timeout channel done := make(chan struct{}) var result *ai.AnalysisResult var err error // Run analysis in a goroutine go func() { result, err = m.aiService.AnalyzeTask(context.Background(), task.Title) close(done) }() // Wait for either completion or timeout select { case <-done: if err != nil { return errMsg{err} } return analysisMsg{result} case <-time.After(m.analysisTimeout): return analysisMsg{&ai.AnalysisResult{ Priority: 3, Category: "task", TimeEstimate: 30, RelatedTasks: "", Summary: "Analysis timed out for: " + task.Title, }} } } } type taskItem struct { task models.Task analyzing bool } func (i taskItem) Title() string { status := "[ ]" if i.task.Completed { status = "[x]" } aiStatus := "" if i.analyzing { aiStatus = " (analyzing...)" } return fmt.Sprintf("%s %s%s", status, i.task.Title, aiStatus) } func (i taskItem) Description() string { return "" } func (i taskItem) FilterValue() string { return i.task.Title } type tasksLoadedMsg struct { items []list.Item } type analysisMsg struct { result *ai.AnalysisResult } type errMsg struct{ error }