todo/src/ui/tui.go
2025-05-08 23:45:33 +02:00

553 lines
14 KiB
Go

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 }