diff --git a/internal/ui/app.go b/internal/ui/app.go index 3ff103c4..140d562d 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -27,6 +27,7 @@ const ( ViewDetail ViewNewTask ViewNewTaskConfirm + ViewEditTask ViewDeleteConfirm ViewQuitConfirm ViewWatch @@ -45,6 +46,7 @@ type KeyMap struct { Enter key.Binding Back key.Binding New key.Binding + Edit key.Binding Queue key.Binding Retry key.Binding Close key.Binding @@ -115,6 +117,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("n"), key.WithHelp("n", "new"), ), + Edit: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit"), + ), Queue: key.NewBinding( key.WithKeys("x"), key.WithHelp("x", "execute"), @@ -247,6 +253,10 @@ type AppModel struct { queueConfirm *huh.Form queueValue bool + // Edit task form state + editTaskForm *FormModel + editingTask *db.Task + // Delete confirmation state deleteConfirm *huh.Form deleteConfirmValue bool @@ -352,6 +362,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentView == ViewNewTask && m.newTaskForm != nil { return m.updateNewTaskForm(msg) } + if m.currentView == ViewEditTask && m.editTaskForm != nil { + return m.updateEditTaskForm(msg) + } if m.currentView == ViewNewTaskConfirm && m.queueConfirm != nil { return m.updateNewTaskConfirm(msg) } @@ -511,6 +524,20 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err } + case taskUpdatedMsg: + if msg.err == nil { + // Update the selected task if we're in detail view + if m.selectedTask != nil && msg.task != nil && m.selectedTask.ID == msg.task.ID { + m.selectedTask = msg.task + if m.detailView != nil { + m.detailView.UpdateTask(msg.task) + } + } + cmds = append(cmds, m.loadTasks()) + } else { + m.err = msg.err + } + case taskQueuedMsg, taskClosedMsg, taskDeletedMsg, taskRetriedMsg, taskInterruptedMsg: cmds = append(cmds, m.loadTasks()) @@ -618,6 +645,10 @@ func (m *AppModel) View() string { if m.newTaskForm != nil { return m.newTaskForm.View() } + case ViewEditTask: + if m.editTaskForm != nil { + return m.editTaskForm.View() + } case ViewNewTaskConfirm: return m.viewNewTaskConfirm() case ViewDeleteConfirm: @@ -1026,6 +1057,13 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.currentView = ViewAttachments return m, nil } + if key.Matches(keyMsg, m.keys.Edit) && m.selectedTask != nil { + m.editingTask = m.selectedTask + m.editTaskForm = NewEditFormModel(m.db, m.selectedTask, m.width, m.height) + m.previousView = m.currentView + m.currentView = ViewEditTask + return m, m.editTaskForm.Init() + } if m.detailView != nil { var cmd tea.Cmd @@ -1118,6 +1156,48 @@ func (m *AppModel) updateNewTaskConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +func (m *AppModel) updateEditTaskForm(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle escape to cancel + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "esc" { + m.currentView = m.previousView + m.editTaskForm = nil + m.editingTask = nil + return m, nil + } + } + + // Pass all messages to the form + model, cmd := m.editTaskForm.Update(msg) + if form, ok := model.(*FormModel); ok { + m.editTaskForm = form + if form.submitted { + // Get updated task data from form + updatedTask := form.GetDBTask() + // Preserve the original task's ID and other fields + updatedTask.ID = m.editingTask.ID + updatedTask.Status = m.editingTask.Status + updatedTask.WorktreePath = m.editingTask.WorktreePath + updatedTask.BranchName = m.editingTask.BranchName + updatedTask.CreatedAt = m.editingTask.CreatedAt + updatedTask.StartedAt = m.editingTask.StartedAt + updatedTask.CompletedAt = m.editingTask.CompletedAt + + m.editTaskForm = nil + m.editingTask = nil + m.currentView = m.previousView + return m, m.updateTask(updatedTask) + } + if form.cancelled { + m.currentView = m.previousView + m.editTaskForm = nil + m.editingTask = nil + return m, nil + } + } + return m, cmd +} + func (m *AppModel) showDeleteConfirm(task *db.Task) (tea.Model, tea.Cmd) { m.pendingDeleteTask = task m.deleteConfirmValue = false @@ -1396,6 +1476,11 @@ type taskCreatedMsg struct { err error } +type taskUpdatedMsg struct { + task *db.Task + err error +} + type taskQueuedMsg struct { err error } @@ -1462,6 +1547,18 @@ func (m *AppModel) createTask(t *db.Task) tea.Cmd { } } +func (m *AppModel) updateTask(t *db.Task) tea.Cmd { + database := m.db + exec := m.executor + return func() tea.Msg { + err := database.UpdateTask(t) + if err == nil { + exec.NotifyTaskChange("updated", t) + } + return taskUpdatedMsg{task: t, err: err} + } +} + func (m *AppModel) createTaskWithAttachments(t *db.Task, attachmentPaths []string) tea.Cmd { exec := m.executor database := m.db diff --git a/internal/ui/detail.go b/internal/ui/detail.go index a223f500..4e982259 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -460,6 +460,7 @@ func (m *DetailModel) renderHelp() string { key string desc string }{ + {"e", "edit"}, {"r", "retry"}, {"c", "close"}, {"d", "delete"}, diff --git a/internal/ui/form.go b/internal/ui/form.go index 218065fc..4e5332fe 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -32,6 +32,7 @@ type FormModel struct { height int submitted bool cancelled bool + isEdit bool // true when editing an existing task // Current field focused FormField @@ -55,6 +56,83 @@ type FormModel struct { attachments []string // Parsed file paths } +// NewEditFormModel creates a form model pre-populated with an existing task's data for editing. +func NewEditFormModel(database *db.DB, task *db.Task, width, height int) *FormModel { + m := &FormModel{ + db: database, + width: width, + height: height, + focused: FieldTitle, + types: []string{"", "code", "writing", "thinking"}, + priorities: []string{"normal", "high", "low"}, + priority: task.Priority, + taskType: task.Type, + project: task.Project, + isEdit: true, + } + + // Set priority index + for i, p := range m.priorities { + if p == task.Priority { + m.priorityIdx = i + break + } + } + + // Set type index + for i, t := range m.types { + if t == task.Type { + m.typeIdx = i + break + } + } + + // Load projects + m.projects = []string{""} + if database != nil { + if projs, err := database.ListProjects(); err == nil { + for _, p := range projs { + m.projects = append(m.projects, p.Name) + } + } + } + + // Set project index + for i, p := range m.projects { + if p == task.Project { + m.projectIdx = i + break + } + } + + // Title input - pre-populate with existing title + m.titleInput = textinput.New() + m.titleInput.Placeholder = "What needs to be done?" + m.titleInput.Prompt = "" + m.titleInput.Focus() + m.titleInput.Width = width - 24 + m.titleInput.SetValue(task.Title) + + // Body textarea - pre-populate with existing body + m.bodyInput = textarea.New() + m.bodyInput.Placeholder = "Additional context (optional)" + m.bodyInput.Prompt = "" + m.bodyInput.ShowLineNumbers = false + m.bodyInput.SetWidth(width - 24) + m.bodyInput.SetHeight(4) + m.bodyInput.FocusedStyle.CursorLine = lipgloss.NewStyle() + m.bodyInput.BlurredStyle.CursorLine = lipgloss.NewStyle() + m.bodyInput.SetValue(task.Body) + + // Attachments input + m.attachmentsInput = textinput.New() + m.attachmentsInput.Placeholder = "Drag files here" + m.attachmentsInput.Prompt = "" + m.attachmentsInput.Width = width - 24 + + return m +} + // NewFormModel creates a new form model. func NewFormModel(database *db.DB, width, height int, workingDir string) *FormModel { m := &FormModel{ @@ -347,10 +425,14 @@ func (m *FormModel) View() string { cursorStyle := lipgloss.NewStyle().Foreground(ColorPrimary) // Header + headerText := "New Task" + if m.isEdit { + headerText = "Edit Task" + } header := lipgloss.NewStyle(). Bold(true). Foreground(ColorPrimary). - Render("New Task") + Render(headerText) b.WriteString(header) b.WriteString("\n\n")