diff --git a/internal/ai/router.go b/internal/ai/router.go index ff05546..2b817da 100644 --- a/internal/ai/router.go +++ b/internal/ai/router.go @@ -14,13 +14,21 @@ const routerSystemPrompt = `You are a task router. Given a task description, rep without asking clarifying questions? A task needs a clear action and enough detail to produce a concrete output. -2. If actionable, where should it run? +2. If actionable, where should it run? A task can span multiple repositories when the + change genuinely needs to land in several codebases simultaneously (e.g. a shared + library update that must be reflected in both a backend and a frontend repo, or an + API change that requires coordinated updates in an SDK and a service). Only assign + multiple repos when the work truly requires parallel changes across them — do not + split a task that belongs to a single repo. Respond with a JSON object (no markdown fences, no explanation): -If it maps to a configured repo (code, infra, data pipelines in that codebase): +If it maps to a single configured repo (code, infra, data pipelines in that codebase): {"repo": "repo-name", "actionable": true} +If it genuinely spans multiple configured repos (parallel changes required in each): +{"repos": ["repo-a", "repo-b"], "actionable": true} + If the primary deliverable is in an external system via a configured MCP (Notion page, Linear issue, etc.): {"mcp": "mcp-name", "actionable": true} @@ -33,22 +41,26 @@ If NOT actionable (too vague, missing context): {"actionable": false, "missing": "brief description of what's missing"} Routing priority: prefer repo when the task is mainly code/config in that repo. -Prefer MCP when the task is mainly creating/updating records in that external tool. -Use generic only when neither applies. Never invent repo or MCP names. +Use repos (plural) only when the task explicitly requires coordinated changes across +multiple repos. Prefer MCP when the task is mainly creating/updating records in that +external tool. Use generic only when neither applies. Never invent repo or MCP names. Examples: - "create Airflow DAG for hourly dbt run" with repo "data-pipelines" → {"repo": "data-pipelines", "actionable": true} +- "bump shared-lib to v2 in both backend and frontend" with repos "backend", "frontend" → {"repos": ["backend", "frontend"], "actionable": true} +- "add auth middleware and update the SDK to use the new token format" with repos "api", "sdk" → {"repos": ["api", "sdk"], "actionable": true} - "create a Notion page for Q2 planning" with mcp "notion" → {"mcp": "notion", "actionable": true} - "file Linear issue for broken DAG alert" with mcp "linear" → {"mcp": "linear", "actionable": true} - "write a SQL procedure to migrate historical data" with no matching repo → {"generic": true, "actionable": true} - "check if we got updates" → {"actionable": false, "missing": "which system, what kind of updates, what to do with them"}` type RouteResult struct { - Repo string `json:"repo"` - MCP string `json:"mcp"` - Generic bool `json:"generic"` - Actionable bool `json:"actionable"` - Missing string `json:"missing,omitempty"` + Repo string `json:"repo"` + Repos []string `json:"repos,omitempty"` + MCP string `json:"mcp"` + Generic bool `json:"generic"` + Actionable bool `json:"actionable"` + Missing string `json:"missing,omitempty"` } func (c *Client) Route(taskText string, repos []config.Repo, mcps []config.MCP) (*RouteResult, error) { @@ -95,8 +107,16 @@ func (c *Client) Route(taskText string, repos []config.Repo, mcps []config.MCP) // MCP takes precedence over accidental repo field with same name. if route.MCP != "" { route.Repo = "" + route.Repos = nil route.Generic = false } + // Unify single-repo "repo" field into the "repos" slice so callers always + // iterate Repos and never need to check both fields. + if route.Repo != "" && len(route.Repos) == 0 { + route.Repos = []string{route.Repo} + route.Repo = "" + } + return &route, nil } diff --git a/internal/taskfile/parser.go b/internal/taskfile/parser.go index 6a3ec0d..92b07dc 100644 --- a/internal/taskfile/parser.go +++ b/internal/taskfile/parser.go @@ -43,6 +43,22 @@ func (t Task) FullText() string { return t.Text + "\n" + t.Description } +// Targets returns the list of assigned target names. +// A task can have multiple targets separated by commas (e.g. "repo-a,repo-b"). +func (t Task) Targets() []string { + if t.Target == "" { + return nil + } + parts := strings.Split(t.Target, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + return out +} + var taskRe = regexp.MustCompile(`^-\s*\[([ xX])\]\s*(.*)$`) func Parse(path string) ([]Task, error) { diff --git a/internal/taskfile/parser_test.go b/internal/taskfile/parser_test.go index 7b0bc35..c16a742 100644 --- a/internal/taskfile/parser_test.go +++ b/internal/taskfile/parser_test.go @@ -121,6 +121,68 @@ func TestTask_FullText(t *testing.T) { } } +func TestTask_Targets(t *testing.T) { + tests := []struct { + name string + target string + want []string + }{ + {"empty", "", nil}, + {"single", "my-repo", []string{"my-repo"}}, + {"multi", "repo-a,repo-b", []string{"repo-a", "repo-b"}}, + {"multi three", "repo-a,repo-b,generic", []string{"repo-a", "repo-b", "generic"}}, + {"trims spaces", "repo-a, repo-b", []string{"repo-a", "repo-b"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task := Task{Target: tt.target} + got := task.Targets() + if len(got) != len(tt.want) { + t.Fatalf("got %v want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("[%d] got %q want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestParse_MultiTarget(t *testing.T) { + path := writeTemp(t, "- [ ] @agent:repo-a,repo-b do the thing\n") + tasks, err := Parse(path) + if err != nil { + t.Fatal(err) + } + if len(tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(tasks)) + } + task := tasks[0] + if task.Tag != TagAgent { + t.Errorf("tag: got %q want %q", task.Tag, TagAgent) + } + if task.Target != "repo-a,repo-b" { + t.Errorf("target: got %q want %q", task.Target, "repo-a,repo-b") + } + if task.Text != "do the thing" { + t.Errorf("text: got %q want %q", task.Text, "do the thing") + } + targets := task.Targets() + if len(targets) != 2 || targets[0] != "repo-a" || targets[1] != "repo-b" { + t.Errorf("targets: got %v", targets) + } +} + +func TestFormatTaskLine_MultiTarget(t *testing.T) { + task := Task{Status: Open, Tag: TagAgent, Target: "repo-a,repo-b", Text: "do the thing"} + got := FormatTaskLine(task) + want := "- [ ] @agent:repo-a,repo-b do the thing" + if got != want { + t.Errorf("got %q want %q", got, want) + } +} + func TestUpdateTask(t *testing.T) { path := writeTemp(t, `## Tasks - [ ] Old title diff --git a/internal/tracker/store.go b/internal/tracker/store.go index 9b7945f..2eec5de 100644 --- a/internal/tracker/store.go +++ b/internal/tracker/store.go @@ -9,6 +9,7 @@ import ( "sort" "sync" "syscall" + "time" ) type Store struct { @@ -45,6 +46,12 @@ func TaskID(repoName, taskText string) string { return fmt.Sprintf("%x", h[:8]) } +// DispatchID returns a unique id for each launch (tracker entry, log file, branch suffix). +func DispatchID(repoName, taskText string) string { + h := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", repoName, taskText, time.Now().UnixNano()))) + return fmt.Sprintf("%x", h[:8]) +} + func (s *Store) load() *state { st := &state{Tasks: make(map[string]*Task)} data, err := os.ReadFile(s.statePath()) diff --git a/internal/tracker/types.go b/internal/tracker/types.go index 82f29be..3266785 100644 --- a/internal/tracker/types.go +++ b/internal/tracker/types.go @@ -56,14 +56,18 @@ func (t Task) WorktreePath() string { var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`) -// BranchName generates a branch name like "td/create-snowflake-task". -func BranchName(taskText string) string { +const branchIDSuffixLen = 9 // "-" + 8-char dispatch id + +// BranchName generates a branch name like "td/create-snowflake-task-a1b2c3d4". +// dispatchID must be unique per launch so concurrent dispatches never share a branch. +func BranchName(taskText, dispatchID string) string { s := strings.ToLower(taskText) s = nonAlnum.ReplaceAllString(s, "-") s = strings.Trim(s, "-") - if len(s) > 60 { - s = s[:60] + maxSlug := 60 - branchIDSuffixLen + if len(s) > maxSlug { + s = s[:maxSlug] s = strings.TrimRight(s, "-") } - return "td/" + s + return "td/" + s + "-" + dispatchID } diff --git a/internal/tracker/types_test.go b/internal/tracker/types_test.go index a2398bd..1ca42a3 100644 --- a/internal/tracker/types_test.go +++ b/internal/tracker/types_test.go @@ -7,25 +7,31 @@ import ( ) func TestBranchName(t *testing.T) { + const id = "a1b2c3d4" tests := []struct { name string in string want string maxLen int // 0 = exact match only }{ - {"simple phrase", "Create Snowflake task", "td/create-snowflake-task", 0}, - {"normalizes whitespace and case", " Mixed CASE ", "td/mixed-case", 0}, - {"empty input", "", "td/", 0}, + {"simple phrase", "Create Snowflake task", "td/create-snowflake-task-a1b2c3d4", 0}, + {"normalizes whitespace and case", " Mixed CASE ", "td/mixed-case-a1b2c3d4", 0}, + {"empty input", "", "td/-a1b2c3d4", 0}, { name: "truncates long slug", in: strings.Repeat("word-", 20) + "end", maxLen: len("td/") + 60, }, + { + name: "different dispatch ids produce different branches", + in: "Same task text", + want: "td/same-task-text-a1b2c3d4", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := BranchName(tt.in) + got := BranchName(tt.in, id) if tt.maxLen > 0 { if len(got) > tt.maxLen { t.Errorf("len %d exceeds max %d: %q", len(got), tt.maxLen, got) @@ -39,6 +45,14 @@ func TestBranchName(t *testing.T) { } } +func TestBranchName_uniquePerDispatch(t *testing.T) { + a := BranchName("Fix login bug", "11111111") + b := BranchName("Fix login bug", "22222222") + if a == b { + t.Errorf("expected different branches, both %q", a) + } +} + func TestTask_WorktreePath(t *testing.T) { tests := []struct { name string @@ -47,8 +61,8 @@ func TestTask_WorktreePath(t *testing.T) { }{ { name: "slashes become plus", - task: Task{RepoPath: "/repos/foo", Branch: "td/my-feature"}, - wantPath: "/repos/foo/.claude/worktrees/td+my-feature", + task: Task{RepoPath: "/repos/foo", Branch: "td/my-feature-a1b2c3d4"}, + wantPath: "/repos/foo/.claude/worktrees/td+my-feature-a1b2c3d4", }, } @@ -126,3 +140,11 @@ func TestTaskID(t *testing.T) { }) } } + +func TestDispatchID_uniquePerCall(t *testing.T) { + a := DispatchID("repo", "task") + b := DispatchID("repo", "task") + if a == b { + t.Errorf("expected different dispatch ids, both %q", a) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 10984dc..00cd5df 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -36,9 +36,9 @@ type routeDoneMsg struct { } type dispatchDoneMsg struct { - repoName string - taskIdx int - err error + repoNames []string + taskIdx int + err error } type configReloadedMsg struct { @@ -91,13 +91,15 @@ type Model struct { err error autoEdit bool - dispatch dstate - dispatchTarget *config.Repo - dispatchIdx int + dispatch dstate + dispatchTargets []*config.Repo + dispatchIdx int pickerInput string pickerCursor int pickerForDispatch bool + pickerMulti bool + pickerSelected map[string]bool viewMode viewMode showHelp bool @@ -276,25 +278,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = fmt.Errorf("%s", hint) return m, nil } - var repo *config.Repo + var repos []*config.Repo switch { case r.MCP != "": - repo = m.findRepo(r.MCP) + if repo := m.findRepo(r.MCP); repo != nil { + repos = []*config.Repo{repo} + } case r.Generic: - repo = m.genericRepo() + repos = []*config.Repo{m.genericRepo()} default: - repo = m.findRepo(r.Repo) + for _, name := range r.Repos { + if repo := m.findRepo(name); repo != nil { + repos = append(repos, repo) + } + } } - if repo == nil { + if len(repos) == 0 { m.status = "No repo match — pick one" m.dispatchIdx = msg.taskIdx m.dispatch = dstatePicker m.pickerInput = "" m.pickerCursor = 0 m.pickerForDispatch = true + m.pickerMulti = false return m, nil } - m.dispatchTarget = repo + m.dispatchTargets = repos m.dispatchIdx = msg.taskIdx m.dispatch = dstateConfirm return m, nil @@ -303,7 +312,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = false if msg.err != nil { m.err = msg.err - m.dispatchTarget = nil + m.dispatchTargets = nil return m, nil } t := &m.tasks[msg.taskIdx] @@ -312,9 +321,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err := taskfile.UpdateTask(m.filePath, *t); err != nil { m.err = err } else { - m.status = fmt.Sprintf("Dispatched to %s", msg.repoName) + m.status = fmt.Sprintf("Dispatched to %s", strings.Join(msg.repoNames, ", ")) } - m.dispatchTarget = nil + m.dispatchTargets = nil m.rebuildOrder() return m, m.scheduleTrackerTick() @@ -513,6 +522,14 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.pickerInput = "" m.pickerCursor = 0 m.pickerForDispatch = false + m.pickerMulti = true + m.pickerSelected = make(map[string]bool) + // Pre-select any existing targets so the user can see/toggle them. + if t := m.currentTask(); t != nil { + for _, tgt := range t.Targets() { + m.pickerSelected[tgt] = true + } + } return m, nil case "f": @@ -535,13 +552,17 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } realIdx := m.taskIdx() - if t.Target != "" { - repo := m.findRepo(t.Target) - if repo == nil { - m.err = fmt.Errorf("target %q not found in config (repos, mcp, or generic)", t.Target) - return m, nil + if targets := t.Targets(); len(targets) > 0 { + repos := make([]*config.Repo, 0, len(targets)) + for _, name := range targets { + repo := m.findRepo(name) + if repo == nil { + m.err = fmt.Errorf("target %q not found in config (repos, mcp, or generic)", name) + return m, nil + } + repos = append(repos, repo) } - m.dispatchTarget = repo + m.dispatchTargets = repos m.dispatchIdx = realIdx m.dispatch = dstateConfirm return m, nil @@ -562,14 +583,17 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleDispatchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": - repoName := m.dispatchTarget.Name + names := make([]string, len(m.dispatchTargets)) + for i, r := range m.dispatchTargets { + names[i] = r.Name + } m.dispatch = dstateNone m.loading = true - m.loadMsg = fmt.Sprintf("Launching in %s...", repoName) + m.loadMsg = fmt.Sprintf("Launching in %s...", strings.Join(names, ", ")) return m, tea.Batch(m.spinner.Tick, m.launchDispatch()) case "esc": m.dispatch = dstateNone - m.dispatchTarget = nil + m.dispatchTargets = nil return m, nil case "ctrl+c": return m, tea.Quit @@ -686,17 +710,23 @@ func (m Model) routeTask(idx int) tea.Cmd { } func (m Model) launchDispatch() tea.Cmd { - target := *m.dispatchTarget + targets := m.dispatchTargets task := m.tasks[m.dispatchIdx] idx := m.dispatchIdx store := m.trackerStore mcps := m.cfg.MCPs return func() tea.Msg { if store == nil { - return dispatchDoneMsg{repoName: target.Name, taskIdx: idx, err: fmt.Errorf("tracker store not initialized")} + return dispatchDoneMsg{taskIdx: idx, err: fmt.Errorf("tracker store not initialized")} + } + var launched []string + for _, target := range targets { + if _, err := launchHeadless(store, target.Path, target.Name, task.FullText(), mcps); err != nil { + return dispatchDoneMsg{repoNames: launched, taskIdx: idx, err: err} + } + launched = append(launched, target.Name) } - _, err := launchHeadless(store, target.Path, target.Name, task.FullText(), mcps) - return dispatchDoneMsg{repoName: target.Name, taskIdx: idx, err: err} + return dispatchDoneMsg{repoNames: launched, taskIdx: idx} } } diff --git a/internal/tui/dispatch.go b/internal/tui/dispatch.go index 6c0d4b6..031f931 100644 --- a/internal/tui/dispatch.go +++ b/internal/tui/dispatch.go @@ -11,6 +11,9 @@ import ( "td/internal/tracker" ) +// launchHeadless starts a headless Claude agent for taskText in repoPath. +// The agent runs with cmd.Dir = repoPath so it picks up that repo's CLAUDE.md, +// slash commands, and settings automatically. func launchHeadless(store *tracker.Store, repoPath, repoName, taskText string, mcps []config.MCP) (*tracker.Task, error) { generic := repoName == config.GenericRepoName mcp := findMCP(mcps, repoName) @@ -25,8 +28,8 @@ func launchHeadless(store *tracker.Store, repoPath, repoName, taskText string, m } } - id := tracker.TaskID(repoName, taskText) - branch := tracker.BranchName(taskText) + id := tracker.DispatchID(repoName, taskText) + branch := tracker.BranchName(taskText, id) logPath := store.LogPath(id) logFile, err := os.Create(logPath) @@ -116,9 +119,9 @@ func findMCP(mcps []config.MCP, name string) *config.MCP { return nil } -func buildDispatchPrompt(generic, isMCP bool, mcpName, branch, taskText string) string { +func buildDispatchPrompt(generic, isMCP bool, repoName, branch, taskText string) string { if isMCP { - return fmt.Sprintf(mcpPromptTemplate, mcpName, mcpName, mcpName, taskText) + return fmt.Sprintf(mcpPromptTemplate, repoName, repoName, repoName, taskText) } if generic { return fmt.Sprintf(genericPromptTemplate, taskText) diff --git a/internal/tui/dispatch_test.go b/internal/tui/dispatch_test.go new file mode 100644 index 0000000..42da662 --- /dev/null +++ b/internal/tui/dispatch_test.go @@ -0,0 +1,61 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestBuildDispatchPrompt_repo(t *testing.T) { + prompt := buildDispatchPrompt(false, false, "backend", "td/fix-login-a1b2c3d4", "Fix login bug") + + if !strings.Contains(prompt, "td/fix-login-a1b2c3d4") { + t.Errorf("expected branch name in prompt, got: %q", prompt) + } + if !strings.Contains(prompt, "Fix login bug") { + t.Errorf("expected task text in prompt, got: %q", prompt) + } +} + +func TestBuildDispatchPrompt_generic(t *testing.T) { + prompt := buildDispatchPrompt(true, false, "generic", "", "Write a design doc") + + if !strings.Contains(prompt, "Write a design doc") { + t.Errorf("expected task text in prompt, got: %q", prompt) + } + // Generic prompt should not mention branch creation. + if strings.Contains(prompt, "git branch") { + t.Errorf("generic prompt should not mention git branch, got: %q", prompt) + } +} + +func TestBuildDispatchPrompt_mcp(t *testing.T) { + prompt := buildDispatchPrompt(false, true, "notion", "", "Create Q3 planning page") + + if !strings.Contains(prompt, "notion") { + t.Errorf("expected MCP name in prompt, got: %q", prompt) + } + if !strings.Contains(prompt, "Create Q3 planning page") { + t.Errorf("expected task text in prompt, got: %q", prompt) + } +} + +func TestBuildDispatchPrompt_noMultiAgentPreamble(t *testing.T) { + forbidden := []string{"parallel", "siblings", "other repositories"} + + cases := []struct { + name string + prompt string + }{ + {"repo", buildDispatchPrompt(false, false, "backend", "td/branch-abc", "Do thing")}, + {"generic", buildDispatchPrompt(true, false, "generic", "", "Do thing")}, + {"mcp", buildDispatchPrompt(false, true, "linear", "", "Do thing")}, + } + + for _, c := range cases { + for _, f := range forbidden { + if strings.Contains(strings.ToLower(c.prompt), f) { + t.Errorf("%s prompt contains %q (multi-agent preamble leak): %q", c.name, f, c.prompt) + } + } + } +} diff --git a/internal/tui/picker.go b/internal/tui/picker.go index b7d2338..5edb6ab 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -18,11 +18,44 @@ func (m Model) handlePickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(filtered) == 0 || m.pickerCursor >= len(filtered) { return m, nil } - repo := filtered[m.pickerCursor] t := m.currentTask() if t == nil { return m, nil } + + if m.pickerMulti { + // Confirm multi-select: use all toggled items, or highlighted if none. + selected := m.pickerSelected + if len(selected) == 0 { + selected = map[string]bool{filtered[m.pickerCursor].Name: true} + } + // Preserve display order when building comma-separated target list. + all := m.filteredRepos() + var targets []string + seen := make(map[string]bool) + for _, r := range all { + if selected[r.Name] && !seen[r.Name] { + targets = append(targets, r.Name) + seen[r.Name] = true + } + } + if t.Tag != taskfile.TagAgent && t.Tag != taskfile.TagDraft { + t.Tag = taskfile.TagAgent + } + t.Target = strings.Join(targets, ",") + if err := taskfile.UpdateTask(m.filePath, *t); err != nil { + m.err = err + } + m.pickerInput = "" + m.pickerCursor = 0 + m.pickerMulti = false + m.pickerSelected = nil + m.dispatch = dstateNone + return m, nil + } + + // Single-select mode (assignment or routing fallback). + repo := filtered[m.pickerCursor] if t.Tag != taskfile.TagAgent && t.Tag != taskfile.TagDraft { t.Tag = taskfile.TagAgent } @@ -37,7 +70,7 @@ func (m Model) handlePickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if forDispatch { cfgRepo := m.findRepo(repo.Name) if cfgRepo != nil { - m.dispatchTarget = cfgRepo + m.dispatchTargets = []*config.Repo{cfgRepo} m.dispatch = dstateConfirm return m, nil } @@ -45,10 +78,32 @@ func (m Model) handlePickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.dispatch = dstateNone return m, nil + case " ": + // Toggle selection in multi-select mode. + if !m.pickerMulti { + break + } + filtered := m.filteredRepos() + if m.pickerCursor >= len(filtered) { + return m, nil + } + name := filtered[m.pickerCursor].Name + if m.pickerSelected == nil { + m.pickerSelected = make(map[string]bool) + } + if m.pickerSelected[name] { + delete(m.pickerSelected, name) + } else { + m.pickerSelected[name] = true + } + return m, nil + case "esc": m.dispatch = dstateNone m.pickerInput = "" m.pickerCursor = 0 + m.pickerMulti = false + m.pickerSelected = nil return m, nil case "ctrl+c": @@ -110,8 +165,17 @@ func (m Model) pickerView() string { var b strings.Builder contentW := w - 4 + title := "assign target" + if m.pickerMulti { + n := len(m.pickerSelected) + if n > 0 { + title = fmt.Sprintf("assign targets (%d selected)", n) + } else { + title = "assign targets" + } + } header := headerBox.Width(contentW).Render( - lipgloss.NewStyle().Bold(true).Render("assign target"), + lipgloss.NewStyle().Bold(true).Render(title), ) b.WriteString(header) b.WriteString("\n\n") @@ -129,6 +193,16 @@ func (m Model) pickerView() string { cursor = cursorStyle.Render(" ›") + " " } + // In multi-select mode show a checkbox indicator. + checkmark := "" + if m.pickerMulti { + if m.pickerSelected[r.Name] { + checkmark = selectedText.Render("[✓] ") + } else { + checkmark = dimStyle.Render("[ ] ") + } + } + name := r.Name if m.cfg.IsMCP(r.Name) { name = r.Name + " ◈" @@ -150,16 +224,21 @@ func (m Model) pickerView() string { desc = dimStyle.Render(r.Description) } - nameWidth := lipgloss.Width(name) + nameWidth := lipgloss.Width(checkmark) + lipgloss.Width(name) pad := max(24-nameWidth, 2) - b.WriteString(cursor + name + strings.Repeat(" ", pad) + desc + "\n") + b.WriteString(cursor + checkmark + name + strings.Repeat(" ", pad) + desc + "\n") } if len(filtered) == 0 { b.WriteString(dimStyle.Render(" no matching targets") + "\n") } - b.WriteString("\n " + keyHint("Enter", "select") + " " + keyHint("Esc", "cancel") + "\n") + b.WriteString("\n") + if m.pickerMulti { + b.WriteString(" " + keyHint("Space", "toggle") + " " + keyHint("Enter", "confirm") + " " + keyHint("Esc", "cancel") + "\n") + } else { + b.WriteString(" " + keyHint("Enter", "select") + " " + keyHint("Esc", "cancel") + "\n") + } return b.String() } diff --git a/internal/tui/views.go b/internal/tui/views.go index 7d13105..ba05b58 100644 --- a/internal/tui/views.go +++ b/internal/tui/views.go @@ -397,56 +397,77 @@ func (m Model) loadingView() string { } func (m Model) dispatchConfirmView() string { - if m.dispatchTarget == nil || m.dispatchIdx >= len(m.tasks) { + if len(m.dispatchTargets) == 0 || m.dispatchIdx >= len(m.tasks) { return "" } task := m.tasks[m.dispatchIdx] w := m.contentWidth() - generic := m.dispatchTarget.Name == config.GenericRepoName - isMCP := m.cfg.IsMCP(m.dispatchTarget.Name) + contentW := w - 4 var b strings.Builder - contentW := w - 4 - title := "Dispatch task" - switch { - case isMCP: - title = "Dispatch task to MCP agent" - case generic: - title = "Dispatch task to generic workspace" - } - header := headerBox.Width(contentW).Render( - lipgloss.NewStyle().Bold(true).Render(title), - ) - b.WriteString(header) - b.WriteString("\n\n") - targetLabel := "Repo:" - targetBadge := badgeAgent.Render(m.dispatchTarget.Name) - switch { - case isMCP: - targetLabel = "MCP:" - targetBadge = badgeAgentMCP.Render(m.dispatchTarget.Name + " ◈") - case generic: - targetLabel = "Target:" - targetBadge = badgeAgentGen.Render(m.dispatchTarget.Name + " ✦") - } - - b.WriteString(" " + lipgloss.NewStyle().Bold(true).Render("Task:") + " " + task.Text + "\n") - b.WriteString(" " + lipgloss.NewStyle().Bold(true).Render(targetLabel) + " " + targetBadge + "\n") - b.WriteString(" " + lipgloss.NewStyle().Bold(true).Render("Path:") + " " + dimStyle.Render(m.dispatchTarget.Path) + "\n") - if m.dispatchTarget.Description != "" { - b.WriteString(" " + lipgloss.NewStyle().Bold(true).Render("Desc:") + " " + dimStyle.Render(m.dispatchTarget.Description) + "\n") - } - if generic || isMCP { - hint := "The workspace dir will be created if it doesn't exist." - if isMCP { - hint = "Runs in generic_workspace with an MCP-focused prompt. Requires " + m.dispatchTarget.Name + " in claude mcp list." + if len(m.dispatchTargets) == 1 { + // Single-target: original detailed view. + target := m.dispatchTargets[0] + generic := target.Name == config.GenericRepoName + isMCP := m.cfg.IsMCP(target.Name) + + title := "Dispatch task" + switch { + case isMCP: + title = "Dispatch task to MCP agent" + case generic: + title = "Dispatch task to generic workspace" + } + b.WriteString(headerBox.Width(contentW).Render(lipgloss.NewStyle().Bold(true).Render(title))) + b.WriteString("\n\n") + + targetLabel := "Repo:" + targetBadge := badgeAgent.Render(target.Name) + switch { + case isMCP: + targetLabel = "MCP:" + targetBadge = badgeAgentMCP.Render(target.Name + " ◈") + case generic: + targetLabel = "Target:" + targetBadge = badgeAgentGen.Render(target.Name + " ✦") } - b.WriteString("\n " + dimStyle.Render(hint) + "\n") + + b.WriteString(" " + lipgloss.NewStyle().Bold(true).Render("Task:") + " " + task.Text + "\n") + b.WriteString(" " + lipgloss.NewStyle().Bold(true).Render(targetLabel) + " " + targetBadge + "\n") + b.WriteString(" " + lipgloss.NewStyle().Bold(true).Render("Path:") + " " + dimStyle.Render(target.Path) + "\n") + if target.Description != "" { + b.WriteString(" " + lipgloss.NewStyle().Bold(true).Render("Desc:") + " " + dimStyle.Render(target.Description) + "\n") + } + if generic || isMCP { + hint := "The workspace dir will be created if it doesn't exist." + if isMCP { + hint = "Runs in generic_workspace with an MCP-focused prompt. Requires " + target.Name + " in claude mcp list." + } + b.WriteString("\n " + dimStyle.Render(hint) + "\n") + } + } else { + // Multi-target view. + title := fmt.Sprintf("Dispatch task to %d targets", len(m.dispatchTargets)) + b.WriteString(headerBox.Width(contentW).Render(lipgloss.NewStyle().Bold(true).Render(title))) + b.WriteString("\n\n") + + b.WriteString(" " + lipgloss.NewStyle().Bold(true).Render("Task:") + " " + task.Text + "\n\n") + + for _, target := range m.dispatchTargets { + badge := badgeAgent.Render(target.Name) + if m.cfg.IsMCP(target.Name) { + badge = badgeAgentMCP.Render(target.Name + " ◈") + } else if target.Name == config.GenericRepoName { + badge = badgeAgentGen.Render(target.Name + " ✦") + } + b.WriteString(" " + badge + " " + dimStyle.Render(target.Path) + "\n") + } + b.WriteString("\n " + dimStyle.Render(fmt.Sprintf("Launches %d parallel agents.", len(m.dispatchTargets))) + "\n") } - b.WriteString("\n") + b.WriteString("\n") b.WriteString(" " + keyHint("Enter", "confirm") + " " + keyHint("Esc", "cancel") + "\n") return b.String()