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
48 changes: 43 additions & 5 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"syscall"
"time"

"github.com/bborn/workflow/internal/autocomplete"
"github.com/bborn/workflow/internal/config"
"github.com/bborn/workflow/internal/db"
"github.com/bborn/workflow/internal/executor"
Expand Down Expand Up @@ -361,23 +362,35 @@ Tasks will automatically reconnect to their Claude sessions when viewed.`,

// Create subcommand - create a new task from command line
createCmd := &cobra.Command{
Use: "create <title>",
Use: "create [title]",
Short: "Create a new task",
Long: `Create a new task from the command line.

Title is optional if --body is provided; AI will generate a title from the body.

Examples:
task create "Fix login bug"
task create "Add dark mode" --type code --project myapp
task create "Write documentation" --body "Document the API endpoints" --execute`,
Args: cobra.ExactArgs(1),
task create "Write documentation" --body "Document the API endpoints" --execute
task create --body "The login button is broken on mobile devices" # AI generates title`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
title := args[0]
var title string
if len(args) > 0 {
title = args[0]
}
body, _ := cmd.Flags().GetString("body")
taskType, _ := cmd.Flags().GetString("type")
project, _ := cmd.Flags().GetString("project")
execute, _ := cmd.Flags().GetBool("execute")
outputJSON, _ := cmd.Flags().GetBool("json")

// Validate that either title or body is provided
if strings.TrimSpace(title) == "" && strings.TrimSpace(body) == "" {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: either title or --body must be provided"))
os.Exit(1)
}

// Set defaults
if taskType == "" {
taskType = db.TypeCode
Expand Down Expand Up @@ -423,6 +436,31 @@ Examples:
}
}

// Generate title from body if title is empty
if strings.TrimSpace(title) == "" && strings.TrimSpace(body) != "" {
var apiKey string
apiKey, _ = database.GetSetting("anthropic_api_key")
svc := autocomplete.NewService(apiKey)
if svc.IsAvailable() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if generatedTitle, genErr := svc.GenerateTitle(ctx, body, project); genErr == nil && generatedTitle != "" {
title = generatedTitle
if !outputJSON {
fmt.Println(dimStyle.Render("Generated title: " + title))
}
}
cancel()
}
// Fallback if generation failed
if strings.TrimSpace(title) == "" {
firstLine := strings.Split(strings.TrimSpace(body), "\n")[0]
if len(firstLine) > 50 {
firstLine = firstLine[:50] + "..."
}
title = firstLine
}
}

// Set initial status
status := db.StatusBacklog
if execute {
Expand Down Expand Up @@ -462,7 +500,7 @@ Examples:
}
},
}
createCmd.Flags().String("body", "", "Task body/description")
createCmd.Flags().String("body", "", "Task body/description (if no title, AI generates from body)")
createCmd.Flags().StringP("type", "t", "", "Task type: code, writing, thinking (default: code)")
createCmd.Flags().StringP("project", "p", "", "Project name (auto-detected from cwd if not specified)")
createCmd.Flags().BoolP("execute", "x", false, "Queue task for immediate execution")
Expand Down
45 changes: 45 additions & 0 deletions internal/autocomplete/autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,51 @@ func (s *Service) log(format string, args ...interface{}) {
fmt.Fprintf(f, "[%s] %s\n", time.Now().Format("15:04:05"), msg)
}

// GenerateTitle generates a concise task title from the description/body.
// This is used when the user provides a description but no title.
func (s *Service) GenerateTitle(ctx context.Context, body, project string) (string, error) {
if s.apiKey == "" {
return "", fmt.Errorf("no API key available")
}

if strings.TrimSpace(body) == "" {
return "", fmt.Errorf("body is empty")
}

prompt := buildTitleGenerationPrompt(body, project)
title, err := s.callAPI(ctx, prompt)
if err != nil {
return "", err
}

// Clean up the title
title = strings.TrimSpace(title)
title = strings.Trim(title, "\"'")

// Ensure title isn't too long (max 100 chars)
if len(title) > 100 {
// Truncate at last word boundary
title = title[:100]
if lastSpace := strings.LastIndex(title, " "); lastSpace > 50 {
title = title[:lastSpace]
}
}

return title, nil
}

func buildTitleGenerationPrompt(body, project string) string {
var sb strings.Builder
sb.WriteString("Generate a concise task title (3-8 words) from this description. ")
sb.WriteString("Use imperative form (e.g., 'Fix login bug', 'Add dark mode support'). ")
sb.WriteString("Output ONLY the title text, no explanations or quotes.\n\n")
if project != "" && project != "personal" {
sb.WriteString(fmt.Sprintf("Project: %s\n", project))
}
sb.WriteString(fmt.Sprintf("Description:\n%s\n\nTitle:", body))
return sb.String()
}

func (s *Service) callAPI(ctx context.Context, prompt string) (string, error) {
s.log("REQUEST: %s", prompt[:min(80, len(prompt))])

Expand Down
65 changes: 65 additions & 0 deletions internal/autocomplete/autocomplete_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package autocomplete

import (
"context"
"strings"
"testing"
)
Expand Down Expand Up @@ -399,3 +400,67 @@ func TestService_Warmup(t *testing.T) {
// Calling warmup twice should be safe
svc.Warmup()
}

func TestBuildTitleGenerationPrompt(t *testing.T) {
tests := []struct {
name string
body string
project string
wantContains []string
}{
{
name: "basic title generation",
body: "The login button is broken on mobile devices",
project: "",
wantContains: []string{"Generate a concise task title", "login button is broken", "imperative form"},
},
{
name: "title generation with project",
body: "Fix the authentication bug",
project: "myapp",
wantContains: []string{"Project: myapp", "authentication bug"},
},
{
name: "personal project excluded",
body: "Update documentation",
project: "personal",
wantContains: []string{"Update documentation"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildTitleGenerationPrompt(tt.body, tt.project)
for _, want := range tt.wantContains {
if !strings.Contains(result, want) {
t.Errorf("buildTitleGenerationPrompt() = %q, want to contain %q", result, want)
}
}
// Personal project should not appear in prompt
if tt.project == "personal" && strings.Contains(result, "Project: personal") {
t.Error("buildTitleGenerationPrompt() should not include 'Project: personal'")
}
})
}
}

func TestGenerateTitle_NoAPIKey(t *testing.T) {
svc := NewService("")
_, err := svc.GenerateTitle(context.TODO(), "Some description", "")
if err == nil {
t.Error("GenerateTitle() should return error when no API key")
}
}

func TestGenerateTitle_EmptyBody(t *testing.T) {
svc := NewService("test-api-key")
_, err := svc.GenerateTitle(context.TODO(), "", "")
if err == nil {
t.Error("GenerateTitle() should return error when body is empty")
}

_, err = svc.GenerateTitle(context.TODO(), " ", "")
if err == nil {
t.Error("GenerateTitle() should return error when body is whitespace only")
}
}
27 changes: 27 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"time"

"github.com/bborn/workflow/internal/autocomplete"
"github.com/bborn/workflow/internal/db"
"github.com/bborn/workflow/internal/executor"
"github.com/bborn/workflow/internal/github"
Expand Down Expand Up @@ -2687,6 +2688,32 @@ func (m *AppModel) createTaskWithAttachments(t *db.Task, attachmentPaths []strin
exec := m.executor
database := m.db
return func() tea.Msg {
// Generate title from body if title is empty but body is provided
if strings.TrimSpace(t.Title) == "" && strings.TrimSpace(t.Body) != "" {
// Try to generate title using LLM
var apiKey string
if database != nil {
apiKey, _ = database.GetSetting("anthropic_api_key")
}
svc := autocomplete.NewService(apiKey)
if svc.IsAvailable() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if title, err := svc.GenerateTitle(ctx, t.Body, t.Project); err == nil && title != "" {
t.Title = title
}
}
// If generation failed, use a fallback
if strings.TrimSpace(t.Title) == "" {
// Use first line of body, truncated
firstLine := strings.Split(strings.TrimSpace(t.Body), "\n")[0]
if len(firstLine) > 50 {
firstLine = firstLine[:50] + "..."
}
t.Title = firstLine
}
}

err := database.CreateTask(t)
if err != nil {
return taskCreatedMsg{task: t, err: err}
Expand Down
13 changes: 9 additions & 4 deletions internal/ui/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,15 +198,15 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int) *FormMo

// Title input - pre-populate with existing title
m.titleInput = textinput.New()
m.titleInput.Placeholder = "What needs to be done?"
m.titleInput.Placeholder = "What needs to be done? (optional if details provided)"
m.titleInput.Prompt = ""
m.titleInput.Cursor.SetMode(cursor.CursorStatic)
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.Placeholder = "Details (AI generates title if left empty above)"
m.bodyInput.Prompt = ""
m.bodyInput.ShowLineNumbers = false
m.bodyInput.Cursor.SetMode(cursor.CursorStatic)
Expand Down Expand Up @@ -327,14 +327,14 @@ func NewFormModel(database *db.DB, width, height int, workingDir string) *FormMo

// Title input
m.titleInput = textinput.New()
m.titleInput.Placeholder = "What needs to be done?"
m.titleInput.Placeholder = "What needs to be done? (optional if details provided)"
m.titleInput.Prompt = ""
m.titleInput.Cursor.SetMode(cursor.CursorStatic)
m.titleInput.Width = width - 24

// Body textarea
m.bodyInput = textarea.New()
m.bodyInput.Placeholder = "Additional context (optional)"
m.bodyInput.Placeholder = "Details (AI generates title if left empty above)"
m.bodyInput.Prompt = ""
m.bodyInput.ShowLineNumbers = false
m.bodyInput.Cursor.SetMode(cursor.CursorStatic)
Expand Down Expand Up @@ -1216,6 +1216,11 @@ func (m *FormModel) View() string {
titleView = strings.TrimRight(titleView, " ") + ghostStyle.Render(m.ghostText)
}
b.WriteString(cursor + " " + labelStyle.Render("Title") + titleView)
// Show indicator when title will be auto-generated
if strings.TrimSpace(m.titleInput.Value()) == "" && strings.TrimSpace(m.bodyInput.Value()) != "" {
autoGenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Italic(true)
b.WriteString(" " + autoGenStyle.Render("✨ AI will generate title"))
}
b.WriteString("\n\n")

// Body (textarea)
Expand Down