diff --git a/branch/branch.go b/branch/branch.go index 68ddfdd..3c975c1 100644 --- a/branch/branch.go +++ b/branch/branch.go @@ -150,32 +150,60 @@ func (b *Branch) GitStatus() (modified int, untracked int) { return modified, untracked } -// GitStats returns commits ahead of origin/main and total lines added/removed (committed + uncommitted). +// GitStats returns commits ahead of main and total lines added/removed (committed + uncommitted). func (b *Branch) GitStats() (commits int, added int, removed int) { if !b.Exists() || b.Name == "main" { return 0, 0, 0 } - // Count commits ahead of origin/main - cmd := exec.Command("git", "-C", b.Path, "rev-list", "--count", "origin/main..HEAD") - out, err := cmd.Output() - if err == nil { - fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &commits) + // Try different refs to compare against + refs := []string{"origin/main", "main"} + var baseRef string + for _, ref := range refs { + cmd := exec.Command("git", "-C", b.Path, "rev-parse", "--verify", ref) + if cmd.Run() == nil { + baseRef = ref + break + } } - // Get total diff stats vs origin/main (includes uncommitted) - // Using "origin/main" without "..." shows diff including working tree - cmd = exec.Command("git", "-C", b.Path, "diff", "--numstat", "origin/main") - out, err = cmd.Output() - if err == nil { - for _, line := range strings.Split(string(out), "\n") { - fields := strings.Fields(line) - if len(fields) >= 2 { - var a, r int - fmt.Sscanf(fields[0], "%d", &a) - fmt.Sscanf(fields[1], "%d", &r) - added += a - removed += r + if baseRef != "" { + // Count commits ahead of base + cmd := exec.Command("git", "-C", b.Path, "rev-list", "--count", baseRef+"..HEAD") + out, err := cmd.Output() + if err == nil { + fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &commits) + } + + // Get total diff stats vs base (includes uncommitted) + cmd = exec.Command("git", "-C", b.Path, "diff", "--numstat", baseRef) + out, err = cmd.Output() + if err == nil { + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 { + var a, r int + fmt.Sscanf(fields[0], "%d", &a) + fmt.Sscanf(fields[1], "%d", &r) + added += a + removed += r + } + } + } + } else { + // No base ref found - just show uncommitted changes + cmd := exec.Command("git", "-C", b.Path, "diff", "--numstat", "HEAD") + out, err := cmd.Output() + if err == nil { + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 { + var a, r int + fmt.Sscanf(fields[0], "%d", &a) + fmt.Sscanf(fields[1], "%d", &r) + added += a + removed += r + } } } } diff --git a/branch/discovery.go b/branch/discovery.go index 054e0c8..4a50b86 100644 --- a/branch/discovery.go +++ b/branch/discovery.go @@ -42,8 +42,9 @@ func FindNextInstanceID() int { } // FindSourceRepo finds a repo to clone from. +// Always clones from 'main' or upstream - never from other branches to avoid inheriting changes. func FindSourceRepo() string { - // Check DARK_SOURCE + // Check DARK_SOURCE (explicit override) if config.DarkSource != config.DarkRoot { gitPath := filepath.Join(config.DarkSource, ".git") if info, err := os.Stat(gitPath); err == nil && info.IsDir() { @@ -52,21 +53,14 @@ func FindSourceRepo() string { } // Check for 'main' branch (must be fully cloned - has devcontainer.json) + // Only use 'main' - never use other branches as source mainPath := filepath.Join(config.DarkRoot, "main") devcontainerPath := filepath.Join(mainPath, ".devcontainer", "devcontainer.json") if _, err := os.Stat(devcontainerPath); err == nil { return mainPath } - // Check any existing managed branch (must be fully cloned) - for _, b := range GetManagedBranches() { - devcontainer := filepath.Join(b.Path, ".devcontainer", "devcontainer.json") - if _, err := os.Stat(devcontainer); err == nil { - return b.Path - } - } - - // Fall back to GitHub + // Fall back to GitHub upstream - will clone fresh from origin/main return "git@github.com:darklang/dark.git" } diff --git a/branch/ops.go b/branch/ops.go index 886b16f..55507b9 100644 --- a/branch/ops.go +++ b/branch/ops.go @@ -2,7 +2,6 @@ package branch import ( "bufio" - "encoding/json" "fmt" "os" "os/exec" @@ -26,43 +25,6 @@ func logToFile(format string, args ...interface{}) { f.WriteString(fmt.Sprintf("[branch] %s\n", msg)) } -// ensureClaudeSettings ensures ~/.claude/settings.json has theme set on the host -// This prevents Claude from showing the theme selection prompt on first run -func ensureClaudeSettings() { - homeDir, err := os.UserHomeDir() - if err != nil { - return - } - - claudeDir := filepath.Join(homeDir, ".claude") - settingsPath := filepath.Join(claudeDir, "settings.json") - - // Create directory if needed - os.MkdirAll(claudeDir, 0755) - - // Read existing settings or start fresh - var settings map[string]interface{} - if data, err := os.ReadFile(settingsPath); err == nil { - json.Unmarshal(data, &settings) - } - if settings == nil { - settings = make(map[string]interface{}) - } - - // Check if theme is already set - if _, ok := settings["theme"]; ok { - return // Theme already configured - } - - // Add theme setting - settings["theme"] = "dark" - - // Write back - if data, err := json.MarshalIndent(settings, "", " "); err == nil { - os.WriteFile(settingsPath, data, 0644) - } -} - // Start starts a branch container and sets up tmux. func Start(b *Branch) error { return StartWithProgress(b, nil) @@ -92,10 +54,6 @@ func StartWithProgress(b *Branch, onProgress func(status string)) error { // Reset progress tracking for fresh start ResetProgressLevel(b.Name) - // Ensure Claude settings exist on host (we mount ~/.claude into containers) - // This prevents the theme selection prompt from appearing - ensureClaudeSettings() - progress("preparing container") // Generate override config @@ -304,12 +262,27 @@ func CreateWithProgress(name string, onProgress func(status string)) (*Branch, e // Ensure remote points to GitHub fork exec.Command("git", "-C", b.Path, "remote", "set-url", "origin", githubFork).Run() + // Also add upstream remote pointing to darklang/dark + exec.Command("git", "-C", b.Path, "remote", "add", "upstream", "git@github.com:darklang/dark.git").Run() + + // Fetch from both remotes exec.Command("git", "-C", b.Path, "fetch", "origin").Run() - checkoutCmd := exec.Command("git", "-C", b.Path, "checkout", "-b", name, "origin/main") + exec.Command("git", "-C", b.Path, "fetch", "upstream").Run() + + // Hard reset to upstream/main to ensure clean state (ignore any changes from source repo) + exec.Command("git", "-C", b.Path, "checkout", "main").Run() + exec.Command("git", "-C", b.Path, "reset", "--hard", "upstream/main").Run() + + // Create new branch from clean main + checkoutCmd := exec.Command("git", "-C", b.Path, "checkout", "-b", name) if err := checkoutCmd.Run(); err != nil { - exec.Command("git", "-C", b.Path, "checkout", "-b", name, "main").Run() + // Branch might already exist, just check it out + exec.Command("git", "-C", b.Path, "checkout", name).Run() } + // Clean any untracked files + exec.Command("git", "-C", b.Path, "clean", "-fd").Run() + b.WriteMetadata(instanceID) return b, nil } diff --git a/cli/commands.go b/cli/commands.go index cbec1f9..6ca400a 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -12,6 +12,7 @@ import ( "github.com/darklang/dark-multi/dns" "github.com/darklang/dark-multi/inotify" "github.com/darklang/dark-multi/proxy" + "github.com/darklang/dark-multi/queue" "github.com/darklang/dark-multi/tui" ) @@ -50,6 +51,7 @@ TUI shortcuts: rootCmd.AddCommand(stopCmd()) rootCmd.AddCommand(rmCmd()) rootCmd.AddCommand(setForkCmd()) + rootCmd.AddCommand(queueCmd()) return rootCmd } @@ -305,3 +307,104 @@ Current setting can be viewed with: }, } } + +func queueCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "queue ", + Short: "Manage the task queue", + Long: `Manage the automated task queue. + +Actions: + init Initialize queue with predefined tasks + ls List all tasks in queue + add Add a task (multi queue add ) + status Show queue status summary + start Start the background queue processor + process Run one processing cycle (start next task if slot available)`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + action := args[0] + q := queue.Get() + + switch action { + case "init": + fmt.Println("Initializing task queue...") + if err := queue.PopulateInitialQueue(); err != nil { + fmt.Fprintf(os.Stderr, "\033[0;31merror:\033[0m %v\n", err) + os.Exit(1) + } + tasks := q.GetAll() + fmt.Printf("\033[0;32m✓\033[0m Queue initialized with %d tasks\n", len(tasks)) + + // Show summary by status + ready := len(q.GetByStatus(queue.StatusReady)) + needsPrompt := len(q.GetByStatus(queue.StatusNeedsPrompt)) + fmt.Printf(" %d ready, %d need prompts\n", ready, needsPrompt) + + case "ls": + tasks := q.GetAll() + if len(tasks) == 0 { + fmt.Println("Queue is empty. Run 'multi queue init' to populate.") + return + } + + fmt.Printf("%-25s %-15s %s\n", "ID", "STATUS", "NAME") + fmt.Println("─────────────────────────────────────────────────────────────") + for _, t := range tasks { + fmt.Printf("%-25s %s %-12s %s\n", t.ID, t.Status.Icon(), t.Status.Display(), t.Name) + } + + case "status": + tasks := q.GetAll() + running := q.CountRunning() + ready := len(q.GetByStatus(queue.StatusReady)) + waiting := len(q.GetByStatus(queue.StatusWaiting)) + done := len(q.GetByStatus(queue.StatusDone)) + needsPrompt := len(q.GetByStatus(queue.StatusNeedsPrompt)) + maxConcurrent := config.GetMaxConcurrent() + + fmt.Printf("Queue Status:\n") + fmt.Printf(" 🔄 Running: %d / %d max\n", running, maxConcurrent) + fmt.Printf(" ⏳ Ready: %d\n", ready) + fmt.Printf(" 📝 Needs Prompt: %d\n", needsPrompt) + fmt.Printf(" ⏸️ Waiting: %d\n", waiting) + fmt.Printf(" ✅ Done: %d\n", done) + fmt.Printf(" ─────────────────\n") + fmt.Printf(" Total: %d\n", len(tasks)) + + case "add": + if len(args) < 3 { + fmt.Fprintln(os.Stderr, "Usage: multi queue add ") + os.Exit(1) + } + id := args[1] + prompt := args[2] + q.Add(id, id, prompt, 50) + q.Save() + fmt.Printf("\033[0;32m✓\033[0m Added task: %s\n", id) + + case "start": + fmt.Println("Starting queue processor...") + queue.StartProcessor() + fmt.Println("\033[0;32m✓\033[0m Queue processor started") + fmt.Println("Press Ctrl+C to stop") + // Block forever (processor runs in background) + select {} + + case "process": + fmt.Println("Processing queue once...") + if err := queue.ProcessOnce(); err != nil { + fmt.Fprintf(os.Stderr, "\033[0;31merror:\033[0m %v\n", err) + os.Exit(1) + } + fmt.Println("\033[0;32m✓\033[0m Done") + + default: + fmt.Fprintf(os.Stderr, "Unknown action: %s\nUse: init, ls, status, add, start, process\n", action) + os.Exit(1) + } + }, + } + + return cmd +} diff --git a/config/config.go b/config/config.go index ad17d40..3dbf4a4 100644 --- a/config/config.go +++ b/config/config.go @@ -124,3 +124,38 @@ func SetGitHubFork(url string) error { forkFile := filepath.Join(ConfigDir, "github-fork") return os.WriteFile(forkFile, []byte(url+"\n"), 0600) } + +// GetMaxConcurrent returns max concurrent containers from config. +func GetMaxConcurrent() int { + // Check environment first + if val := os.Getenv("DARK_MULTI_MAX_CONCURRENT"); val != "" { + if i, err := strconv.Atoi(val); err == nil && i > 0 { + return i + } + } + + // Check config file + maxFile := filepath.Join(ConfigDir, "max-concurrent") + if data, err := os.ReadFile(maxFile); err == nil { + if i, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil && i > 0 { + return i + } + } + + // Default to suggested based on system resources + return SuggestMaxInstances() +} + +// SetMaxConcurrent saves max concurrent containers to config. +func SetMaxConcurrent(n int) error { + os.MkdirAll(ConfigDir, 0755) + maxFile := filepath.Join(ConfigDir, "max-concurrent") + return os.WriteFile(maxFile, []byte(strconv.Itoa(n)+"\n"), 0644) +} + +// IsFirstRun returns true if this is the first time running multi. +func IsFirstRun() bool { + maxFile := filepath.Join(ConfigDir, "max-concurrent") + _, err := os.Stat(maxFile) + return os.IsNotExist(err) +} diff --git a/container/devcontainer.go b/container/devcontainer.go index 09f3a13..aeb678f 100644 --- a/container/devcontainer.go +++ b/container/devcontainer.go @@ -192,18 +192,11 @@ func GenerateOverrideConfig(b BranchInfo) (string, error) { cfg["runArgs"] = newRunArgs // Override mounts with branch-specific volumes - homeDir, _ := os.UserHomeDir() - claudeDir := filepath.Join(homeDir, ".claude") - claudeJson := filepath.Join(homeDir, ".claude.json") - + // Note: We do NOT mount ~/.claude or ~/.claude.json - auth is via ANTHROPIC_API_KEY env var mounts := []interface{}{ fmt.Sprintf("type=volume,src=dark_nuget_%s,dst=/home/dark/.nuget", name), fmt.Sprintf("type=volume,src=dark-vscode-ext-%s,dst=/home/dark/.vscode-server/extensions", name), fmt.Sprintf("type=volume,src=dark-vscode-ext-insiders-%s,dst=/home/dark/.vscode-server-insiders/extensions", name), - // Mount Claude credentials and config (shared across branches) - fmt.Sprintf("type=bind,src=%s,dst=/home/dark/.claude,consistency=cached", claudeDir), - // Mount .claude.json for auth/theme (writable - Claude needs to save settings) - fmt.Sprintf("type=bind,src=%s,dst=/home/dark/.claude.json", claudeJson), } // Note: We intentionally do NOT mount ~/.ssh or ~/.gitconfig to avoid leaking credentials. // Git identity (user.name/user.email) is set via postCreateCommand below. @@ -231,9 +224,14 @@ func GenerateOverrideConfig(b BranchInfo) (string, error) { setupCmds = append(setupCmds, fmt.Sprintf("git config --global user.email %q", gitEmail)) } - // Ensure Claude is installed (auth comes from mounted .claude.json) + // Ensure Claude is installed (auth via ANTHROPIC_API_KEY passed at runtime) setupCmds = append(setupCmds, "sudo npm install -g @anthropic-ai/claude-code 2>/dev/null || true") + // Pre-seed Claude settings and clear any OAuth tokens + // This creates settings that tell Claude to use API key from env and removes any OAuth credentials + claudeSettingsCmd := `mkdir -p /home/dark/.claude && rm -f /home/dark/.claude/.credentials.json /home/dark/.claude/credentials.json /home/dark/.claude/oauth* 2>/dev/null; echo '{"theme":"dark","hasCompletedOnboarding":true,"apiKeySource":"env","hasAcknowledgedCostThreshold":true}' > /home/dark/.claude/settings.json && chown -R dark:dark /home/dark/.claude` + setupCmds = append(setupCmds, claudeSettingsCmd) + if !strings.Contains(postCreate, "claude-code") { setup := strings.Join(setupCmds, " && ") if postCreate != "" { @@ -244,21 +242,15 @@ func GenerateOverrideConfig(b BranchInfo) (string, error) { cfg["postCreateCommand"] = postCreate } - // Inject OAuth token if available (from ~/.config/dark-multi/oauth_token) - // Combined with mounted ~/.claude.json (which has hasCompletedOnboarding: true), - // this enables auto-auth without /login - oauthTokenPath := filepath.Join(config.ConfigDir, "oauth_token") - if tokenBytes, err := os.ReadFile(oauthTokenPath); err == nil { - token := strings.TrimSpace(string(tokenBytes)) - if token != "" { - containerEnv, _ := cfg["containerEnv"].(map[string]interface{}) - if containerEnv == nil { - containerEnv = make(map[string]interface{}) - } - containerEnv["CLAUDE_CODE_OAUTH_TOKEN"] = token - cfg["containerEnv"] = containerEnv - logToFile("Injecting CLAUDE_CODE_OAUTH_TOKEN from %s", oauthTokenPath) + // Set ANTHROPIC_API_KEY in container environment + apiKey := config.GetAnthropicAPIKey() + if apiKey != "" { + containerEnv := make(map[string]interface{}) + if existing, ok := cfg["containerEnv"].(map[string]interface{}); ok { + containerEnv = existing } + containerEnv["ANTHROPIC_API_KEY"] = apiKey + cfg["containerEnv"] = containerEnv } // Write merged config diff --git a/next-prompt.md b/next-prompt.md new file mode 100644 index 0000000..233d2d7 --- /dev/null +++ b/next-prompt.md @@ -0,0 +1,218 @@ +# Preable + +I have some feedback on multi, and a bunch of tasks to work on. +I'd like some major changes to multi, such that we can start automatically queueing up, executing, and finishing tasks in a queue. + +# Changes to Multi + +- We need to be more automatic +- Rather than me creating branches manually, providing the prompt manually, and you doing some hand-holding along the way, I want to create a repository of tasks to complete, and you process that set as a _queue_. as you have availability, queue up the 'ready' tasks, start the containers, give them ralph and the initial prompt, and have the containers go to town until 'done' or stuck. +- No questions until you're stuck or done +- Prepend all you need to the CLAUDE.md to get the whole ralph setup working +- create TODOs folder with original-prompt.md +- todos.md + - create "real" prompt + - commit along the way +- I provide you prompt, and all else done until agent is stuck or done + (needs prompt -> ready -> running -> waiting) + (also: paused-container stopped) +- allow up to 10 branches running at a time - later we might increase this if we think resources can handle such +- list view shows all tasks. some (up to ten) will have a Container currently running, processing/completing. but most will be either Waiting FOr Review or Needs Prompt or Waiting for Container +- the Grid view should be filterable by status. can multi-sepect statuses in cool modal that pops up. by default, probably should only include the Running containers, and (like we have now) display the summaries of what's happening +- early work (for this 'multi' prompt, as well as for in each container) should be focused on making a plan +- ensure multi can revover from crashes at any point +- if we've somehow lost it, bring back single Container page (focus) with whatever's relevant +- separately, some focused Task page might make sense +- we need to switch to a different kind of auth for claude stuff. Let's use the anthropic API key. it's really arduous to manually auth, or copy/mount the claude auth stuff. so stop mounting that, and instead copy the API key that the host has +- turn this document into a TODOs for yourself, somehow, and slowly iterate on them... +- here's an initial wave of tasks to queue up +- (include the already-running branches) +- queue up the _easy_ tasks first. +- I should have a way to manually spin up a container, for the sake of testing, hours/whenever after the container was running (and then stopped) for codewriting. + +# Tasks to queue up + +## Clone Charm TUI stuff +Research tools for TUIs: Charm, FsSpectre, gui.cs, ratatui, etc. +Review some initial TUI building blocks we've started in CLI 'experiments'. + +Build a more thorough set of UI components for building TUIs in Darklang, all following our Ethos in the CLAUDE.md + +Darklang.CLI.UI is a good root namespace to work in. Might be good to migrate whatever we have to that place. + +Write .dark tests in backend/testfiles/execution around how tese components render. Don't get too carried away writing too many tests, though. + + +## Sqlite Spike -- Access, DSL + +We embed Sqlite in our CLI/Runtime. +Internally, we access a data.db to store various data, in our host language, F#, using (library). + +We'd like to avail access to this DB, as well as to user .db files, in our language Darklang, with a minimal set of Builtins. Something like (library). + +Separately, a DSL for querying the DB might be cool. +Hack on that, but keep it separate/'above' the main solution, so I can remove it in case it's ugly. + +Test what you can, ideally in .dark tests. +Update CLI and VS Code editing experience if relevant. + + +## Darklang VS Code _Theme_ Included in Extension +(-classic style) + +Find screenshots of and source code for Darklang Classic. +In this repo, create a VS Code theme in our existing extension, matching the color scheme of Darklang Classic. +Make usage optional. + + +## Implement Wildcard Match Pattern + +- see issue #5460 +- add LPWildcard + add general tests +- close issue via text in committing + + +## Fix/flip Pipes (|>) + +How does/will JS/TS do pipes? +If that's the same as F#, then Darklang is doing it wrong (strangely). Fix it. + + +## AI Spike + +Do a spike on "AI Support" in Darklang +- study/clone langchain +- a few vendor-specific packages (especially Claude Code) +- some generic packages for general use in Darklang.AI +- PT-style primitives: Prompt, Agent, Session +- any specs for us to create packages for? +- tests and dmos where possible +- cool CLI UI for composing prompts? (just a sketch) +- VS Code impact, if any (minimal) +- create demo coding agents +- CLI UI and commands for usage? + (just UI/feel, no deep impl.) +- how do we take advantage of Darklang's strengths to support distributed AI usage, agentes, execution, etc.? +- see wip.darklang.com to determine our strengths, as well as any recent blog posts. + + +## Tidy VS Code extension page + +We have a vs code extension that's wip, very alpha +it requires the CLI has already been downloaded (from GH Releases) and instealled (run 'install' in the exe) +Please update the extension's "landing page" to note such, and include some brief text explaining what Darklang is, and the fact that it's a WIP, with the extension (at this point) meant for internal testing/usage) + +tangentially, somehow demand the vscode lockfile to be in sync with the package.json. we keep running into a dumb issue in CIU. + + +## Find TODOs, CLeanups, and create report + +This codebase has a ton of TODO and CLEANUP marked inline. some of them are involved and should be further punted, but some are likely low-hanging fruit, doable fully without human review/feedback. +Review all of these items. +For the latter group, write a document full of the TODOs/CLEANUPs, referenced by file and text, ## title and brief body with how to resolve (like this wave of TODOs I'm giving you) + + +## HTML Spike + +Somewhere in ./packages, we have started some helper types and functions for creating HTML documents/pages. + +Please review and expand up on this. +There are tests in backend/testfiles +One problem to solve for: weird whitespace issue +(meaningful whitespace in between tags) +run tests with ./scripts/run-backend-tests --filter-test-list html +see source in WIP branch of darklang/website, and rewrite _matter_ page(s) in the packages canvas with this html functionality. + + +## Uncomment/backfill HttpClient tests + +In backend/testfiles/httpclient, many old test files have been marked to be ignored, with a _ prepended to the file name. +I suspect many of these tests can/should be brought back with relatively little pain. +Review and bring those tests back.Where appropriate, delete dupes. +I'd recommend starting with just one test a a time, until you've gained some confidence. +The tests are run by ./scripts/run-backend-tests --filter-test-list HttpClient + + +## Generally backfill tests + +Review all F# tests in backend/tests/Tests +and .dark tests in backend/testfiles +Identify and fill in any, as you see fit +(ignore httpclient tests - another thread is uncommenting those files) + + +## Make test-running faster +See how long ./scripts/run-backend-tests takes. +See if it can be improved. +Are we allowing for as much concurrency as we safely can? +Ignore the package-reload time; another thread is working on that. + + +## Find and report dependencies to upgrade +The benefits, rough steps, etc. +The goal here is to just create one or more .md file I can review, committed to the repo. + + +## Remove Ply +A long time ago, we added Ply to our F# solution. +At the time, there were alleged perf benefits. +In hindsight, it made the codebase noisier than the time save might be, and we'd like to rip it out in favor of async/task, whichever would be appropriate for our needs. + + +## RegEx Support Spike +Initial steps: +- start with LibExe and F# tests +- then F# parser +- more tests (F#) +- .dark tests +- treesitter grammar +- .dark side of consuming that tree sitter tree +- .dark tests +- LSP updates +- any CLI updates? +- any VS COde updates? + +Constraints: +- minimal builtins +- minimal impact to ProgramTypes and RuntimeTypes +- be ready to change syntax + + +## Review Stdlib Issue + +Find and read issue #5329 +Identify things that could be done with relative ease. +Do them +Test them + + +## Upgrade to .NET 10 + + +## More Reflection +Review the minimal reflection we have. +What else can/should we do? +Add few clean builtins, and package fns, and demo/test things. +I thnk right now we just have Builtin.reflect or something +How might this relate to the CLI experience/app? Create a .md report iwth ideas +Really keep F# code impact to be relatively minimal. + + +## Only expose Builtins to one package fn each, where possible. +Darklang's set of available code is _mostly_ written in Darklang, but some core thngs are "builtins" written in F#. +Currently, any Dark fn may reference/use any builtin. +But we'd like to restrict things such that only ONE package fn may reference each builtin. we can do this gradually. + +Commits should be: +- initial infrastructure with + - type usageRestriction = AllowAny | AllowOne of Location +- Do all oe easy restrictions (AllowOne) +- futher commits for the harder restrictions + +run-backend-tests at end to see if things work + +Unfortunately, the runtime doesn't know about the Locations, so this will need to SOMEHOW be a restriction set at parse-time + + + diff --git a/queue/healthcheck.go b/queue/healthcheck.go new file mode 100644 index 0000000..d58aca8 --- /dev/null +++ b/queue/healthcheck.go @@ -0,0 +1,197 @@ +package queue + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/darklang/dark-multi/branch" + "github.com/darklang/dark-multi/config" + "github.com/darklang/dark-multi/task" + "github.com/darklang/dark-multi/tmux" +) + +// HealthIssue represents a detected issue with a task. +type HealthIssue struct { + TaskID string + Severity string // "warning", "error", "info" + Message string + Action string // Suggested action +} + +// RunHealthCheck evaluates the state of all tasks and returns any issues found. +func RunHealthCheck() []HealthIssue { + var issues []HealthIssue + q := Get() + + for _, t := range q.GetAll() { + taskIssues := checkTask(t) + issues = append(issues, taskIssues...) + } + + return issues +} + +func checkTask(t *Task) []HealthIssue { + var issues []HealthIssue + branchPath := filepath.Join(config.DarkRoot, t.ID) + b := branch.New(t.ID) + taskObj := task.New(t.ID, branchPath) + + // Check 1: Container state vs queue state mismatch + containerRunning := b.Exists() && b.IsRunning() + if t.Status == StatusRunning && !containerRunning { + issues = append(issues, HealthIssue{ + TaskID: t.ID, + Severity: "warning", + Message: "Marked as running but container is stopped", + Action: "restart", + }) + } + + // Check 2: Task marked done but has uncommitted work + if t.Status == StatusDone { + uncommitted := countUncommittedFiles(branchPath) + if uncommitted > 0 { + issues = append(issues, HealthIssue{ + TaskID: t.ID, + Severity: "warning", + Message: fmt.Sprintf("Marked done but has %d uncommitted files", uncommitted), + Action: "commit", + }) + } + } + + // Check 3: Check for stuck Claude (running but no output change) + if t.Status == StatusRunning && tmux.ClaudeSessionExists(t.ID) { + paneContent := tmux.CapturePaneContent(t.ID, 50) + if isClaudeStuck(paneContent) { + issues = append(issues, HealthIssue{ + TaskID: t.ID, + Severity: "warning", + Message: "Claude may be stuck (auth issue or waiting for input)", + Action: "check", + }) + } + } + + // Check 4: Phase file says done but queue doesn't reflect it + if taskObj.Exists() { + phase := taskObj.Phase() + if phase == task.PhaseDone && t.Status != StatusDone { + issues = append(issues, HealthIssue{ + TaskID: t.ID, + Severity: "info", + Message: "Phase is done, updating queue status", + Action: "sync", + }) + // Auto-fix: update queue status + q := Get() + q.UpdateStatus(t.ID, StatusDone) + q.Save() + } + + // Check for error phases + if phase == task.PhaseAuthError || phase == task.PhaseError || phase == task.PhaseMaxIterations { + issues = append(issues, HealthIssue{ + TaskID: t.ID, + Severity: "error", + Message: fmt.Sprintf("Task in error state: %s", phase), + Action: "fix", + }) + } + } + + // Check 5: Running for too long without progress + if t.Status == StatusRunning && !t.StartedAt.IsZero() { + runningFor := time.Since(t.StartedAt) + if runningFor > 4*time.Hour { + issues = append(issues, HealthIssue{ + TaskID: t.ID, + Severity: "warning", + Message: fmt.Sprintf("Running for %s without completion", runningFor.Round(time.Minute)), + Action: "check", + }) + } + } + + return issues +} + +func countUncommittedFiles(branchPath string) int { + // Use git status --porcelain to count uncommitted files + cmd := fmt.Sprintf("git -C %s status --porcelain 2>/dev/null | wc -l", branchPath) + out, err := runCommand(cmd) + if err != nil { + return 0 + } + var count int + fmt.Sscanf(strings.TrimSpace(out), "%d", &count) + return count +} + +func isClaudeStuck(paneContent string) bool { + // Check for signs that Claude is stuck + lower := strings.ToLower(paneContent) + + // Auth issues + if strings.Contains(lower, "invalid api key") || + strings.Contains(lower, "authentication failed") || + strings.Contains(lower, "unauthorized") || + strings.Contains(lower, "auth conflict") { + return true + } + + // Waiting for OAuth + if strings.Contains(lower, "platform.claude.com/oauth") || + strings.Contains(lower, "paste code here") { + return true + } + + // Rate limiting + if strings.Contains(lower, "rate limit") || + strings.Contains(lower, "too many requests") { + return true + } + + return false +} + +func runCommand(cmd string) (string, error) { + out, err := exec.Command("bash", "-c", cmd).Output() + if err != nil { + return "", err + } + return string(out), nil +} + +// AutoFix attempts to automatically fix detected issues. +func AutoFix(issues []HealthIssue) []string { + var actions []string + q := Get() + + for _, issue := range issues { + switch issue.Action { + case "sync": + // Already handled in checkTask + actions = append(actions, fmt.Sprintf("%s: synced status", issue.TaskID)) + + case "commit": + // Commit uncommitted work + branchPath := filepath.Join(config.DarkRoot, issue.TaskID) + exec.Command("git", "-C", branchPath, "add", "-A").Run() + exec.Command("git", "-C", branchPath, "commit", "-m", "wip: uncommitted work").Run() + actions = append(actions, fmt.Sprintf("%s: committed uncommitted work", issue.TaskID)) + + case "restart": + // Mark as ready so it can be restarted + q.UpdateStatus(issue.TaskID, StatusReady) + actions = append(actions, fmt.Sprintf("%s: marked ready for restart", issue.TaskID)) + } + } + + q.Save() + return actions +} diff --git a/queue/init.go b/queue/init.go new file mode 100644 index 0000000..358367b --- /dev/null +++ b/queue/init.go @@ -0,0 +1,320 @@ +package queue + +import "time" + +// InitialTasks returns the initial set of tasks to queue. +// Priority: lower = start first. Easy tasks get lower priority numbers. +func InitialTasks() []Task { + return []Task{ + // === Existing branches (priority 0-9, will be reviewed) === + { + ID: "ai-basics", + Name: "AI Basics (existing)", + Prompt: "", // Needs review + Priority: 5, + }, + { + ID: "bring-back-wasm", + Name: "Bring Back WASM (existing)", + Prompt: "", // Needs review + Priority: 5, + }, + { + ID: "extensible-cli", + Name: "Extensible CLI (existing)", + Prompt: "", // Needs review + Priority: 5, + }, + { + ID: "faster-package-reloading", + Name: "Faster Package Reloading (existing)", + Prompt: "", // Needs review + Priority: 5, + }, + + // === Easy tasks (priority 10-19) === + { + ID: "wildcard-match-pattern", + Name: "Implement Wildcard Match Pattern", + Priority: 10, + Prompt: `Implement Wildcard Match Pattern + +- See issue #5460 +- Add LPWildcard +- Add general tests +- Close issue via text in commit message`, + }, + { + ID: "fix-pipes", + Name: "Fix/flip Pipes (|>)", + Priority: 11, + Prompt: `Fix/flip Pipes (|>) + +How does/will JS/TS do pipes? +If that's the same as F#, then Darklang is doing it wrong (strangely). Fix it.`, + }, + { + ID: "vscode-extension-page", + Name: "Tidy VS Code Extension Page", + Priority: 12, + Prompt: `Tidy VS Code extension page + +We have a VS Code extension that's WIP, very alpha. +It requires the CLI has already been downloaded (from GH Releases) and installed (run 'install' in the exe). + +Please update the extension's "landing page" to note such, and include some brief text explaining what Darklang is, and the fact that it's a WIP, with the extension (at this point) meant for internal testing/usage. + +Tangentially, somehow demand the vscode lockfile to be in sync with the package.json. We keep running into a dumb issue in CI.`, + }, + { + ID: "vscode-theme", + Name: "VS Code Theme (Classic Style)", + Priority: 13, + Prompt: `Darklang VS Code Theme (Classic Style) + +Find screenshots of and source code for Darklang Classic. +In this repo, create a VS Code theme in our existing extension, matching the color scheme of Darklang Classic. +Make usage optional.`, + }, + { + ID: "find-todos-report", + Name: "Find TODOs/Cleanups Report", + Priority: 14, + Prompt: `Find TODOs, Cleanups, and create report + +This codebase has a ton of TODO and CLEANUP marked inline. Some of them are involved and should be further punted, but some are likely low-hanging fruit, doable fully without human review/feedback. + +Review all of these items. +For the latter group, write a document full of the TODOs/CLEANUPs, referenced by file and text, ## title and brief body with how to resolve.`, + }, + { + ID: "find-deps-upgrade", + Name: "Find Dependencies to Upgrade", + Priority: 15, + Prompt: `Find and report dependencies to upgrade + +The benefits, rough steps, etc. +The goal here is to just create one or more .md file I can review, committed to the repo.`, + }, + { + ID: "review-stdlib-issue", + Name: "Review Stdlib Issue #5329", + Priority: 16, + Prompt: `Review Stdlib Issue + +Find and read issue #5329 +Identify things that could be done with relative ease. +Do them. +Test them.`, + }, + + // === Medium tasks (priority 20-29) === + { + ID: "httpclient-tests", + Name: "Uncomment HttpClient Tests", + Priority: 20, + Prompt: `Uncomment/backfill HttpClient tests + +In backend/testfiles/httpclient, many old test files have been marked to be ignored, with a _ prepended to the file name. +I suspect many of these tests can/should be brought back with relatively little pain. + +Review and bring those tests back. Where appropriate, delete dupes. +I'd recommend starting with just one test at a time, until you've gained some confidence. +The tests are run by ./scripts/run-backend-tests --filter-test-list HttpClient`, + }, + { + ID: "backfill-tests", + Name: "Generally Backfill Tests", + Priority: 21, + Prompt: `Generally backfill tests + +Review all F# tests in backend/tests/Tests +and .dark tests in backend/testfiles +Identify and fill in any, as you see fit +(ignore httpclient tests - another thread is uncommenting those files)`, + }, + { + ID: "faster-tests", + Name: "Make Test-Running Faster", + Priority: 22, + Prompt: `Make test-running faster + +See how long ./scripts/run-backend-tests takes. +See if it can be improved. +Are we allowing for as much concurrency as we safely can? +Ignore the package-reload time; another thread is working on that.`, + }, + { + ID: "remove-ply", + Name: "Remove Ply", + Priority: 23, + Prompt: `Remove Ply + +A long time ago, we added Ply to our F# solution. +At the time, there were alleged perf benefits. +In hindsight, it made the codebase noisier than the time save might be, and we'd like to rip it out in favor of async/task, whichever would be appropriate for our needs.`, + }, + { + ID: "dotnet-10", + Name: "Upgrade to .NET 10", + Priority: 24, + Prompt: `Upgrade to .NET 10`, + }, + + // === Larger tasks (priority 30-39) === + { + ID: "charm-tui", + Name: "Clone Charm TUI Stuff", + Priority: 30, + Prompt: `Clone Charm TUI stuff + +Research tools for TUIs: Charm, FsSpectre, gui.cs, ratatui, etc. +Review some initial TUI building blocks we've started in CLI 'experiments'. + +Build a more thorough set of UI components for building TUIs in Darklang, all following our Ethos in the CLAUDE.md. + +Darklang.CLI.UI is a good root namespace to work in. Might be good to migrate whatever we have to that place. + +Write .dark tests in backend/testfiles/execution around how these components render. Don't get too carried away writing too many tests, though.`, + }, + { + ID: "sqlite-spike", + Name: "Sqlite Spike - Access, DSL", + Priority: 31, + Prompt: `Sqlite Spike -- Access, DSL + +We embed Sqlite in our CLI/Runtime. +Internally, we access a data.db to store various data, in our host language, F#, using (library). + +We'd like to avail access to this DB, as well as to user .db files, in our language Darklang, with a minimal set of Builtins. + +Separately, a DSL for querying the DB might be cool. +Hack on that, but keep it separate/'above' the main solution, so I can remove it in case it's ugly. + +Test what you can, ideally in .dark tests. +Update CLI and VS Code editing experience if relevant.`, + }, + { + ID: "html-spike", + Name: "HTML Spike", + Priority: 32, + Prompt: `HTML Spike + +Somewhere in ./packages, we have started some helper types and functions for creating HTML documents/pages. + +Please review and expand upon this. +There are tests in backend/testfiles. +One problem to solve for: weird whitespace issue (meaningful whitespace in between tags). + +Run tests with ./scripts/run-backend-tests --filter-test-list html +See source in WIP branch of darklang/website, and rewrite _matter_ page(s) in the packages canvas with this html functionality.`, + }, + { + ID: "regex-spike", + Name: "RegEx Support Spike", + Priority: 33, + Prompt: `RegEx Support Spike + +Initial steps: +- start with LibExe and F# tests +- then F# parser +- more tests (F#) +- .dark tests +- treesitter grammar +- .dark side of consuming that tree sitter tree +- .dark tests +- LSP updates +- any CLI updates? +- any VS Code updates? + +Constraints: +- minimal builtins +- minimal impact to ProgramTypes and RuntimeTypes +- be ready to change syntax`, + }, + { + ID: "reflection", + Name: "More Reflection", + Priority: 34, + Prompt: `More Reflection + +Review the minimal reflection we have. +What else can/should we do? +Add few clean builtins, and package fns, and demo/test things. +I think right now we just have Builtin.reflect or something. + +How might this relate to the CLI experience/app? Create a .md report with ideas. +Really keep F# code impact to be relatively minimal.`, + }, + { + ID: "builtins-restrict", + Name: "Restrict Builtins to One Package Fn", + Priority: 35, + Prompt: `Only expose Builtins to one package fn each, where possible. + +Darklang's set of available code is _mostly_ written in Darklang, but some core things are "builtins" written in F#. +Currently, any Dark fn may reference/use any builtin. +But we'd like to restrict things such that only ONE package fn may reference each builtin. We can do this gradually. + +Commits should be: +- initial infrastructure with + - type usageRestriction = AllowAny | AllowOne of Location +- Do all the easy restrictions (AllowOne) +- further commits for the harder restrictions + +run-backend-tests at end to see if things work + +Unfortunately, the runtime doesn't know about the Locations, so this will need to SOMEHOW be a restriction set at parse-time`, + }, + + // === AI Spike (priority 40, larger exploratory task) === + { + ID: "ai-spike", + Name: "AI Spike", + Priority: 40, + Prompt: `AI Spike + +Do a spike on "AI Support" in Darklang: +- study/clone langchain +- a few vendor-specific packages (especially Claude Code) +- some generic packages for general use in Darklang.AI +- PT-style primitives: Prompt, Agent, Session +- any specs for us to create packages for? +- tests and demos where possible +- cool CLI UI for composing prompts? (just a sketch) +- VS Code impact, if any (minimal) +- create demo coding agents +- CLI UI and commands for usage? (just UI/feel, no deep impl.) +- how do we take advantage of Darklang's strengths to support distributed AI usage, agents, execution, etc.? +- see wip.darklang.com to determine our strengths, as well as any recent blog posts.`, + }, + } +} + +// PopulateInitialQueue adds initial tasks to the queue. +func PopulateInitialQueue() error { + q := Get() + + for _, task := range InitialTasks() { + // Don't overwrite existing tasks + if q.Get(task.ID) == nil { + status := StatusReady + if task.Prompt == "" { + status = StatusNeedsPrompt + } + q.Tasks[task.ID] = &Task{ + ID: task.ID, + Name: task.Name, + Prompt: task.Prompt, + Status: status, + Priority: task.Priority, + CreatedAt: task.CreatedAt, + } + if q.Tasks[task.ID].CreatedAt.IsZero() { + q.Tasks[task.ID].CreatedAt = time.Now() + } + } + } + + return q.Save() +} diff --git a/queue/processor.go b/queue/processor.go new file mode 100644 index 0000000..a51a943 --- /dev/null +++ b/queue/processor.go @@ -0,0 +1,231 @@ +package queue + +import ( + "fmt" + "path/filepath" + "sync" + "time" + + "github.com/darklang/dark-multi/branch" + "github.com/darklang/dark-multi/config" + "github.com/darklang/dark-multi/task" + "github.com/darklang/dark-multi/tmux" +) + +var ( + processorRunning bool + processorMu sync.Mutex +) + +// StartProcessor starts the background queue processor. +func StartProcessor() { + processorMu.Lock() + if processorRunning { + processorMu.Unlock() + return + } + processorRunning = true + processorMu.Unlock() + + go runProcessor() +} + +// StopProcessor stops the background queue processor. +func StopProcessor() { + processorMu.Lock() + processorRunning = false + processorMu.Unlock() +} + +// IsProcessorRunning returns true if the processor is running. +func IsProcessorRunning() bool { + processorMu.Lock() + defer processorMu.Unlock() + return processorRunning +} + +func runProcessor() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + // Process immediately on start + processQueue() + + for { + processorMu.Lock() + if !processorRunning { + processorMu.Unlock() + return + } + processorMu.Unlock() + + select { + case <-ticker.C: + processQueue() + } + } +} + +// processQueue checks for tasks to start and monitors running tasks. +func processQueue() { + q := Get() + + // Sync with actual container state (handles manually started containers) + syncRunningContainers(q) + + // Sync task phases with queue status + syncTaskPhases(q) + + // Start all ready tasks up to capacity (no waiting between starts) + maxConcurrent := config.GetMaxConcurrent() + for { + running := q.CountRunning() + if running >= maxConcurrent { + break + } + + task := q.NextReady() + if task == nil { + break + } + + // Start the task + if err := startTask(task); err != nil { + q.SetError(task.ID, err.Error()) + q.Save() + continue + } + + q.UpdateStatus(task.ID, StatusRunning) + q.Save() + } +} + +// syncTaskPhases updates queue status based on task phase files. +func syncTaskPhases(q *Queue) { + for _, t := range q.GetByStatus(StatusRunning) { + branchPath := filepath.Join(config.DarkRoot, t.ID) + taskObj := task.New(t.ID, branchPath) + + phase := taskObj.Phase() + switch phase { + case task.PhaseDone: + q.UpdateStatus(t.ID, StatusDone) + // Clean up task files for a clean PR + go func(taskObj *task.Task, branchPath string) { + taskObj.Cleanup() // Removes .claude-task/ and cleans CLAUDE.md + }(taskObj, branchPath) + // Stop container when task is done to free resources + b := branch.New(t.ID) + if b.IsRunning() { + go branch.Stop(b) // Stop in background to not block sync + } + case task.PhaseAuthError, task.PhaseError, task.PhaseMaxIterations: + // Error states - mark as waiting for human intervention + q.UpdateStatus(t.ID, StatusWaiting) + q.SetError(t.ID, string(phase)) + case task.PhaseAwaitingAnswers, task.PhaseReadyForReview: + // Needs human input + q.UpdateStatus(t.ID, StatusWaiting) + case task.PhaseNone: + // Task was reset or deleted + if t.Prompt != "" { + q.UpdateStatus(t.ID, StatusReady) + } else { + q.UpdateStatus(t.ID, StatusNeedsPrompt) + } + } + } + q.Save() +} + +// syncRunningContainers updates queue status based on actual running containers. +// This handles the case where containers were started manually before the queue existed. +func syncRunningContainers(q *Queue) { + // Check tasks that are ready or needs-prompt - if their container is running, mark as running + for _, t := range q.GetAll() { + if t.Status == StatusRunning || t.Status == StatusDone { + continue // Already in terminal state + } + + b := branch.New(t.ID) + if b.Exists() && b.IsRunning() { + // Container is running, update queue status + q.UpdateStatus(t.ID, StatusRunning) + } + } + q.Save() +} + +// startTask creates the branch if needed, sets up the task, and starts the ralph loop. +func startTask(t *Task) error { + branchPath := filepath.Join(config.DarkRoot, t.ID) + + // Create branch if it doesn't exist + b := branch.New(t.ID) + if !b.Exists() { + var err error + b, err = branch.Create(t.ID) + if err != nil { + return fmt.Errorf("failed to create branch: %w", err) + } + } + + // Start container if not running + if !b.IsRunning() { + if err := branch.Start(b); err != nil { + return fmt.Errorf("failed to start container: %w", err) + } + // Wait for container to be ready + time.Sleep(5 * time.Second) + } + + // Set up task + taskObj := task.New(t.ID, branchPath) + + // Set the prompt + if err := taskObj.SetPrePrompt(t.Prompt); err != nil { + return fmt.Errorf("failed to set pre-prompt: %w", err) + } + + // Set up task infrastructure + if err := taskObj.EnsureClaudeTaskDir(); err != nil { + return fmt.Errorf("failed to create task dir: %w", err) + } + + if err := taskObj.WriteInitialSetup(); err != nil { + return fmt.Errorf("failed to write initial setup: %w", err) + } + + // Set phase to executing + taskObj.SetPhase(task.PhaseExecuting) + + // Copy ralph script + if err := taskObj.CopyLoopScript(); err != nil { + return fmt.Errorf("failed to copy loop script: %w", err) + } + + // Inject task context into CLAUDE.md + if err := taskObj.InjectTaskContext(); err != nil { + return fmt.Errorf("failed to inject context: %w", err) + } + + // Get container ID + containerID, err := b.ContainerID() + if err != nil { + return fmt.Errorf("failed to get container ID: %w", err) + } + + // Start ralph loop + if err := tmux.StartRalphLoop(t.ID, containerID); err != nil { + return fmt.Errorf("failed to start ralph loop: %w", err) + } + + return nil +} + +// ProcessOnce runs a single processing cycle (for CLI usage). +func ProcessOnce() error { + processQueue() + return nil +} diff --git a/queue/queue.go b/queue/queue.go new file mode 100644 index 0000000..a618f1f --- /dev/null +++ b/queue/queue.go @@ -0,0 +1,322 @@ +// Package queue manages the task queue for automated processing. +package queue + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/darklang/dark-multi/config" +) + +// Status represents task status in the queue. +type Status string + +const ( + StatusNeedsPrompt Status = "needs-prompt" // Waiting for prompt to be written + StatusReady Status = "ready" // Has prompt, waiting for container slot + StatusRunning Status = "running" // Container running, claude working + StatusWaiting Status = "waiting" // Stuck or needs human input + StatusDone Status = "done" // Completed + StatusPaused Status = "paused" // Manually paused +) + +// MaxConcurrent returns the configured max concurrent containers. +// Use config.GetMaxConcurrent() for the actual value. +var MaxConcurrent = 10 // Deprecated: use config.GetMaxConcurrent() + +// Task represents a queued task. +type Task struct { + ID string `json:"id"` // Unique ID (usually branch name) + Name string `json:"name"` // Display name + Prompt string `json:"prompt"` // Task description/prompt + Status Status `json:"status"` // Current status + Priority int `json:"priority"` // Lower = higher priority + CreatedAt time.Time `json:"created_at"` // When task was created + StartedAt time.Time `json:"started_at"` // When container started + CompletedAt time.Time `json:"completed_at"` // When task completed + Error string `json:"error"` // Error message if stuck +} + +// Queue manages the task queue. +type Queue struct { + Tasks map[string]*Task `json:"tasks"` + mu sync.RWMutex +} + +var ( + instance *Queue + once sync.Once +) + +// Get returns the singleton queue instance. +func Get() *Queue { + once.Do(func() { + instance = &Queue{ + Tasks: make(map[string]*Task), + } + instance.Load() + }) + return instance +} + +// queuePath returns the path to the queue file. +func queuePath() string { + return filepath.Join(config.ConfigDir, "queue.json") +} + +// Load loads the queue from disk. +func (q *Queue) Load() error { + q.mu.Lock() + defer q.mu.Unlock() + + data, err := os.ReadFile(queuePath()) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + return json.Unmarshal(data, &q.Tasks) +} + +// Save persists the queue to disk. +func (q *Queue) Save() error { + q.mu.RLock() + defer q.mu.RUnlock() + + if err := os.MkdirAll(config.ConfigDir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(q.Tasks, "", " ") + if err != nil { + return err + } + + return os.WriteFile(queuePath(), data, 0644) +} + +// Add adds a new task to the queue. +func (q *Queue) Add(id, name, prompt string, priority int) *Task { + q.mu.Lock() + defer q.mu.Unlock() + + status := StatusReady + if prompt == "" { + status = StatusNeedsPrompt + } + + task := &Task{ + ID: id, + Name: name, + Prompt: prompt, + Status: status, + Priority: priority, + CreatedAt: time.Now(), + } + + q.Tasks[id] = task + return task +} + +// Get returns a task by ID. +func (q *Queue) Get(id string) *Task { + q.mu.RLock() + defer q.mu.RUnlock() + return q.Tasks[id] +} + +// UpdateStatus updates a task's status. +func (q *Queue) UpdateStatus(id string, status Status) { + q.mu.Lock() + defer q.mu.Unlock() + + if task, ok := q.Tasks[id]; ok { + task.Status = status + if status == StatusRunning && task.StartedAt.IsZero() { + task.StartedAt = time.Now() + } + if status == StatusDone && task.CompletedAt.IsZero() { + task.CompletedAt = time.Now() + } + } +} + +// SetPrompt sets the prompt for a task. +func (q *Queue) SetPrompt(id, prompt string) { + q.mu.Lock() + defer q.mu.Unlock() + + if task, ok := q.Tasks[id]; ok { + task.Prompt = prompt + if task.Status == StatusNeedsPrompt { + task.Status = StatusReady + } + } +} + +// SetError sets an error message and marks task as waiting. +func (q *Queue) SetError(id, err string) { + q.mu.Lock() + defer q.mu.Unlock() + + if task, ok := q.Tasks[id]; ok { + task.Error = err + task.Status = StatusWaiting + } +} + +// GetByStatus returns all tasks with a given status. +func (q *Queue) GetByStatus(statuses ...Status) []*Task { + q.mu.RLock() + defer q.mu.RUnlock() + + statusSet := make(map[Status]bool) + for _, s := range statuses { + statusSet[s] = true + } + + var result []*Task + for _, task := range q.Tasks { + if statusSet[task.Status] { + result = append(result, task) + } + } + + // Sort by priority, then by created time + sort.Slice(result, func(i, j int) bool { + if result[i].Priority != result[j].Priority { + return result[i].Priority < result[j].Priority + } + return result[i].CreatedAt.Before(result[j].CreatedAt) + }) + + return result +} + +// statusOrder returns the sort order for a status (lower = first). +func statusOrder(s Status) int { + switch s { + case StatusDone: + return 0 + case StatusRunning: + return 1 + case StatusWaiting: + return 2 + case StatusReady: + return 3 + case StatusNeedsPrompt: + return 4 + case StatusPaused: + return 5 + default: + return 9 + } +} + +// GetAll returns all tasks sorted by status (done first), then priority. +func (q *Queue) GetAll() []*Task { + q.mu.RLock() + defer q.mu.RUnlock() + + var result []*Task + for _, task := range q.Tasks { + result = append(result, task) + } + + sort.Slice(result, func(i, j int) bool { + // First sort by status + orderI, orderJ := statusOrder(result[i].Status), statusOrder(result[j].Status) + if orderI != orderJ { + return orderI < orderJ + } + // Then by priority + if result[i].Priority != result[j].Priority { + return result[i].Priority < result[j].Priority + } + // Then by creation time + return result[i].CreatedAt.Before(result[j].CreatedAt) + }) + + return result +} + +// CountRunning returns the number of running tasks. +func (q *Queue) CountRunning() int { + q.mu.RLock() + defer q.mu.RUnlock() + + count := 0 + for _, task := range q.Tasks { + if task.Status == StatusRunning { + count++ + } + } + return count +} + +// NextReady returns the next ready task, or nil if none or at capacity. +func (q *Queue) NextReady() *Task { + if q.CountRunning() >= MaxConcurrent { + return nil + } + + tasks := q.GetByStatus(StatusReady) + if len(tasks) == 0 { + return nil + } + + return tasks[0] +} + +// Remove removes a task from the queue. +func (q *Queue) Remove(id string) { + q.mu.Lock() + defer q.mu.Unlock() + delete(q.Tasks, id) +} + +// StatusIcon returns an icon for a status. +func (s Status) Icon() string { + switch s { + case StatusNeedsPrompt: + return "📝" + case StatusReady: + return "⏳" + case StatusRunning: + return "🔄" + case StatusWaiting: + return "⏸️" + case StatusDone: + return "✅" + case StatusPaused: + return "⏹️" + default: + return "?" + } +} + +// StatusDisplay returns a display string for a status. +func (s Status) Display() string { + switch s { + case StatusNeedsPrompt: + return "needs prompt" + case StatusReady: + return "ready" + case StatusRunning: + return "running" + case StatusWaiting: + return "waiting" + case StatusDone: + return "done" + case StatusPaused: + return "paused" + default: + return string(s) + } +} diff --git a/scripts/claude-loop.sh b/scripts/claude-loop.sh new file mode 100755 index 0000000..36b8b3c --- /dev/null +++ b/scripts/claude-loop.sh @@ -0,0 +1,257 @@ +#!/bin/bash +# claude-loop.sh - Ralph Wiggum style loop for Claude +# Runs Claude repeatedly until completion or max iterations +# Supports recovery from auth failures and iteration limits + +set -e + +TASK_DIR="/home/dark/app/.claude-task" +PHASE_FILE="$TASK_DIR/phase" +STATUS_FILE="$TASK_DIR/status.md" +ERROR_FILE="$TASK_DIR/error" +ITERATION_FILE="$TASK_DIR/iteration" +MAX_ITERATIONS=${MAX_ITERATIONS:-100} + +# Load iteration count (allows recovery after restart) +if [ -f "$ITERATION_FILE" ]; then + ITERATION=$(cat "$ITERATION_FILE") +else + ITERATION=0 +fi + +# Ensure task directory exists +mkdir -p "$TASK_DIR" + +# Initialize phase file if not exists +if [ ! -f "$PHASE_FILE" ]; then + echo "executing" > "$PHASE_FILE" +fi + +log() { + echo "[claude-loop] $1" + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$TASK_DIR/loop.log" +} + +# Clear any OAuth tokens to avoid auth conflicts +# We use ANTHROPIC_API_KEY from environment, not OAuth +clear_oauth_tokens() { + rm -f ~/.claude/.credentials.json ~/.claude/credentials.json ~/.claude/oauth* 2>/dev/null || true + # Ensure settings use API key from env + if [ -d ~/.claude ]; then + echo '{"theme":"dark","hasCompletedOnboarding":true,"apiKeySource":"env","hasAcknowledgedCostThreshold":true}' > ~/.claude/settings.json + fi +} + +update_status() { + echo "# Loop Status" > "$STATUS_FILE" + echo "" >> "$STATUS_FILE" + echo "**Iteration:** $ITERATION / $MAX_ITERATIONS" >> "$STATUS_FILE" + echo "**Phase:** $(cat $PHASE_FILE 2>/dev/null || echo 'unknown')" >> "$STATUS_FILE" + echo "**Last update:** $(date '+%Y-%m-%d %H:%M:%S')" >> "$STATUS_FILE" + if [ -f "$ERROR_FILE" ]; then + echo "**Error:** $(cat $ERROR_FILE)" >> "$STATUS_FILE" + fi + echo "" >> "$STATUS_FILE" + echo "## Recent Activity" >> "$STATUS_FILE" + echo "" >> "$STATUS_FILE" +} + +save_iteration() { + echo "$ITERATION" > "$ITERATION_FILE" +} + +check_auth_error() { + local output_file="$1" + + # Check for various auth error patterns + if grep -qi "invalid.*api.*key\|authentication.*failed\|unauthorized\|401\|api.*key.*invalid\|ANTHROPIC_API_KEY" "$output_file" 2>/dev/null; then + return 0 # Auth error detected + fi + return 1 +} + +check_phase_transition() { + # Check if Claude output contains phase transition signals + local output_file="$1" + + if grep -q "AWAITING_ANSWERS" "$output_file" 2>/dev/null; then + echo "awaiting-answers" > "$PHASE_FILE" + log "Phase transition: awaiting-answers" + return 0 # Stop loop + fi + + if grep -q "READY_TO_EXECUTE" "$output_file" 2>/dev/null; then + echo "executing" > "$PHASE_FILE" + log "Phase transition: executing" + return 1 # Continue loop + fi + + if grep -q "READY_FOR_REVIEW" "$output_file" 2>/dev/null; then + echo "ready-for-review" > "$PHASE_FILE" + log "Phase transition: ready-for-review" + return 0 # Stop loop + fi + + if grep -q "NEEDS_HELP" "$output_file" 2>/dev/null; then + echo "awaiting-answers" > "$PHASE_FILE" + log "Phase transition: needs-help -> awaiting-answers" + return 0 # Stop loop + fi + + if grep -q "CLEANUP" "$output_file" 2>/dev/null; then + echo "cleanup" > "$PHASE_FILE" + log "Phase transition: cleanup" + return 1 # Continue with cleanup + fi + + if grep -q "DONE" "$output_file" 2>/dev/null; then + echo "done" > "$PHASE_FILE" + log "Phase transition: done" + return 0 # Stop loop + fi + + return 1 # No transition, continue if in executing phase +} + +run_claude() { + local output_file="$TASK_DIR/claude-output-$ITERATION.log" + + log "Starting Claude iteration $ITERATION" + update_status + save_iteration + + # Clear any previous error + rm -f "$ERROR_FILE" + + # Run Claude, capturing output + # Note: --dangerously-skip-permissions allows autonomous operation + if command -v claude &> /dev/null; then + # Tee output to both terminal and file for phase detection + claude --dangerously-skip-permissions 2>&1 | tee "$output_file" + local exit_code=${PIPESTATUS[0]} + else + log "ERROR: claude command not found" + echo "claude command not found" > "$ERROR_FILE" + return 1 + fi + + log "Claude exited with code $exit_code" + + # Check for auth errors first + if check_auth_error "$output_file"; then + log "ERROR: Authentication failure detected" + echo "auth-error" > "$PHASE_FILE" + echo "Authentication failed - check ANTHROPIC_API_KEY" > "$ERROR_FILE" + return 2 # Special exit code for auth error + fi + + # Check for phase transitions in output + if check_phase_transition "$output_file"; then + return 0 # Phase transition requires stopping + fi + + # Clean up old output files (keep last 5) + ls -t "$TASK_DIR"/claude-output-*.log 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true + + return $exit_code +} + +reset_loop() { + log "Resetting loop state" + ITERATION=0 + save_iteration + rm -f "$ERROR_FILE" + echo "executing" > "$PHASE_FILE" +} + +main() { + # Clear any OAuth tokens to avoid auth conflicts + clear_oauth_tokens + + # Check for reset flag + if [ "$1" = "--reset" ] || [ "$1" = "-r" ]; then + reset_loop + fi + + # Check current phase - if it's an error state, wait for manual intervention + current_phase=$(cat "$PHASE_FILE" 2>/dev/null || echo "executing") + if [ "$current_phase" = "auth-error" ]; then + log "Auth error state - waiting for fix. Run with --reset after fixing API key." + echo "Waiting for auth fix. Set ANTHROPIC_API_KEY and run: .claude-task/ralph.sh --reset" + sleep 30 + exit 1 + fi + + # Check if we hit max iterations previously + if [ "$current_phase" = "max-iterations-reached" ]; then + log "Max iterations was reached. Run with --reset to continue." + echo "Max iterations reached. Run: .claude-task/ralph.sh --reset" + sleep 30 + exit 1 + fi + + log "Starting Claude loop (iteration $ITERATION, max $MAX_ITERATIONS)" + + local consecutive_failures=0 + local max_consecutive_failures=5 + + while [ $ITERATION -lt $MAX_ITERATIONS ]; do + ITERATION=$((ITERATION + 1)) + save_iteration + + current_phase=$(cat "$PHASE_FILE" 2>/dev/null || echo "executing") + + # Only loop in executing or cleanup phases + if [ "$current_phase" != "executing" ] && [ "$current_phase" != "cleanup" ] && [ "$current_phase" != "planning" ]; then + log "Phase is $current_phase, stopping loop" + break + fi + + run_claude + local result=$? + + if [ $result -eq 2 ]; then + # Auth error - stop and wait + log "Stopping due to auth error" + break + elif [ $result -ne 0 ]; then + # Claude exited with error + consecutive_failures=$((consecutive_failures + 1)) + current_phase=$(cat "$PHASE_FILE" 2>/dev/null || echo "unknown") + + if [ $consecutive_failures -ge $max_consecutive_failures ]; then + log "ERROR: $consecutive_failures consecutive failures, stopping" + echo "error" > "$PHASE_FILE" + echo "Too many consecutive failures ($consecutive_failures)" > "$ERROR_FILE" + break + fi + + if [ "$current_phase" = "executing" ]; then + log "Claude exited during executing phase (failure $consecutive_failures/$max_consecutive_failures), restarting in 5 seconds..." + sleep 5 + else + log "Loop complete (phase: $current_phase)" + break + fi + else + # Success or phase transition + consecutive_failures=0 + current_phase=$(cat "$PHASE_FILE" 2>/dev/null || echo "unknown") + if [ "$current_phase" != "executing" ] && [ "$current_phase" != "cleanup" ]; then + break + fi + fi + done + + if [ $ITERATION -ge $MAX_ITERATIONS ]; then + log "WARNING: Max iterations ($MAX_ITERATIONS) reached" + echo "max-iterations-reached" > "$PHASE_FILE" + fi + + log "Loop finished after $ITERATION iterations (phase: $(cat $PHASE_FILE 2>/dev/null || echo 'unknown'))" +} + +# Run main if not sourced +if [ "${BASH_SOURCE[0]}" == "${0}" ]; then + main "$@" +fi diff --git a/summary/summary.go b/summary/summary.go new file mode 100644 index 0000000..a4a9735 --- /dev/null +++ b/summary/summary.go @@ -0,0 +1,337 @@ +// Package summary provides AI-powered summarization of Claude sessions. +package summary + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/darklang/dark-multi/tmux" +) + +// Cache stores summaries for branches +var ( + cache = make(map[string]*CachedSummary) + cacheMu sync.RWMutex + summarizing = make(map[string]bool) + sumMu sync.Mutex +) + +// CachedSummary holds a cached summary with timestamp +type CachedSummary struct { + Summary string + Iteration int + UpdatedAt time.Time +} + +// GetSummary returns the cached summary for a branch, or triggers generation. +func GetSummary(branchName string) string { + cacheMu.RLock() + cached, ok := cache[branchName] + cacheMu.RUnlock() + + if ok && time.Since(cached.UpdatedAt) < 60*time.Second { + return formatSummary(cached) + } + + // Trigger async summarization if not already running + go triggerSummarization(branchName) + + if ok { + return formatSummary(cached) // Return stale while updating + } + + // Return fallback immediately while waiting for first summary + iter := getIteration(branchName) + summary := getFallbackSummary(branchName) + if iter > 0 || summary != "" { + return formatResult(iter, summary) + } + return "" +} + +func formatSummary(c *CachedSummary) string { + return formatResult(c.Iteration, c.Summary) +} + +func formatResult(iter int, summary string) string { + if iter > 0 && summary != "" { + return fmt.Sprintf("#%d • %s", iter, summary) + } + if iter > 0 { + return fmt.Sprintf("#%d", iter) + } + if summary != "" { + return summary + } + return "" +} + +func triggerSummarization(branchName string) { + sumMu.Lock() + if summarizing[branchName] { + sumMu.Unlock() + return + } + summarizing[branchName] = true + sumMu.Unlock() + + defer func() { + sumMu.Lock() + delete(summarizing, branchName) + sumMu.Unlock() + }() + + iter := getIteration(branchName) + sum := generateSummary(branchName) + if sum != "" || iter > 0 { + cacheMu.Lock() + cache[branchName] = &CachedSummary{ + Summary: sum, + Iteration: iter, + UpdatedAt: time.Now(), + } + cacheMu.Unlock() + } +} + +// getIteration extracts the current iteration number from the ralph log +func getIteration(branchName string) int { + logPath := tmux.GetOutputLogPath(branchName) + content, err := readTail(logPath, 2048) + if err != nil { + return 0 + } + + // Look for "[ralph] Iteration N" pattern + re := regexp.MustCompile(`\[ralph\] Iteration (\d+)`) + matches := re.FindAllStringSubmatch(content, -1) + if len(matches) > 0 { + // Get the last match (most recent iteration) + lastMatch := matches[len(matches)-1] + var iter int + fmt.Sscanf(lastMatch[1], "%d", &iter) + return iter + } + return 0 +} + +func generateSummary(branchName string) string { + logPath := tmux.GetOutputLogPath(branchName) + + // Read last ~4KB of log + content, err := readTail(logPath, 4096) + if err != nil || content == "" { + return getFallbackSummary(branchName) + } + + // Clean up ANSI escape codes and control characters + content = cleanTerminalOutput(content) + if len(content) < 50 { + return getFallbackSummary(branchName) + } + + // Check for API key - if not set, use fallback + if os.Getenv("ANTHROPIC_API_KEY") == "" { + return getFallbackSummary(branchName) + } + + // Call Haiku for summarization + return callHaiku(content) +} + +// getFallbackSummary extracts useful info from the log without AI +func getFallbackSummary(branchName string) string { + logPath := tmux.GetOutputLogPath(branchName) + content, err := readTail(logPath, 2048) + if err != nil || content == "" { + return "" + } + + content = cleanTerminalOutput(content) + lines := strings.Split(content, "\n") + + // Look for interesting patterns in reverse order + for i := len(lines) - 1; i >= 0 && i >= len(lines)-20; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + + // Skip common noise + lower := strings.ToLower(line) + if strings.HasPrefix(lower, "[ralph]") || + strings.Contains(lower, "iteration") || + strings.Contains(lower, "───") || + strings.Contains(lower, "╭") || + strings.Contains(lower, "╰") || + len(line) < 5 { + continue + } + + // Look for file operations + if strings.Contains(line, "Reading") || strings.Contains(line, "Writing") || + strings.Contains(line, "Editing") || strings.Contains(line, "Created") { + return truncate(line, 80) + } + + // Look for tool usage + if strings.Contains(line, "Read(") || strings.Contains(line, "Edit(") || + strings.Contains(line, "Write(") || strings.Contains(line, "Bash(") { + return truncate(line, 80) + } + + // Return first non-noise line + return truncate(line, 80) + } + + return "" +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} + +func readTail(path string, maxBytes int64) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return "", err + } + + start := stat.Size() - maxBytes + if start < 0 { + start = 0 + } + + f.Seek(start, 0) + data, err := io.ReadAll(f) + if err != nil { + return "", err + } + + return string(data), nil +} + +func cleanTerminalOutput(s string) string { + // Remove ANSI escape sequences + var result strings.Builder + inEscape := false + for _, r := range s { + if r == '\x1b' { + inEscape = true + continue + } + if inEscape { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + inEscape = false + } + continue + } + // Skip other control characters except newline/tab + if r < 32 && r != '\n' && r != '\t' { + continue + } + result.WriteRune(r) + } + return result.String() +} + +type claudeRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Messages []message `json:"messages"` +} + +type message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type claudeResponse struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` +} + +func callHaiku(content string) string { + apiKey := os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + return "" + } + + prompt := `What is Claude doing RIGHT NOW? One short fragment, max 80 chars. No bullet, no period. + +Good: editing auth.go to fix login timeout +Good: running pytest, 3 failures so far +Good: reading codebase to understand user model +Bad: Claude is currently working on implementing the authentication system for users + +Output ONLY the fragment, nothing else. + +Terminal output: +` + content + + reqBody := claudeRequest{ + Model: "claude-3-5-haiku-20241022", + MaxTokens: 100, + Messages: []message{ + {Role: "user", Content: prompt}, + }, + } + + jsonBody, _ := json.Marshal(reqBody) + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "" + } + + var result claudeResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "" + } + + if len(result.Content) > 0 { + text := strings.TrimSpace(result.Content[0].Text) + // Remove any bullet or period Haiku might add + text = strings.TrimPrefix(text, "•") + text = strings.TrimPrefix(text, "-") + text = strings.TrimSpace(text) + text = strings.TrimSuffix(text, ".") + return truncate(text, 80) + } + return "" +} + +// ClearCache removes the cached summary for a branch. +func ClearCache(branchName string) { + cacheMu.Lock() + delete(cache, branchName) + cacheMu.Unlock() +} diff --git a/task/prompts.go b/task/prompts.go new file mode 100644 index 0000000..fc5ceee --- /dev/null +++ b/task/prompts.go @@ -0,0 +1,178 @@ +package task + +import ( + "os" + "path/filepath" + "strings" +) + +// InjectTaskContext writes the task context to CLAUDE.md in the branch. +func (t *Task) InjectTaskContext() error { + claudeMdPath := filepath.Join(t.BranchPath, "CLAUDE.md") + + // Read existing CLAUDE.md + existing := "" + if data, err := os.ReadFile(claudeMdPath); err == nil { + existing = string(data) + } + + // Remove old task context if present + if strings.Contains(existing, "") { + existing = removeTaskContext(existing) + } + + // Build task context based on phase + var taskContext string + phase := t.Phase() + + switch phase { + case PhasePlanning: + taskContext = t.planningContext() + case PhaseReady: + taskContext = t.readyContext() + case PhaseExecuting: + taskContext = t.executingContext() + default: + return nil // No context to inject + } + + // Prepend to CLAUDE.md + newContent := taskContext + existing + return os.WriteFile(claudeMdPath, []byte(newContent), 0644) +} + +func (t *Task) planningContext() string { + prePrompt := t.PrePrompt() + + return "\n" + + "# Active Task - Planning Phase\n\n" + + "You have been given a task to complete. Your job is to:\n\n" + + "1. **Research** the codebase to understand what needs to be done\n" + + "2. **Create a plan** with specific, actionable todos\n" + + "3. **Signal** when planning is complete\n\n" + + "## The Task\n\n" + + prePrompt + "\n\n" + + "## Your Instructions\n\n" + + "1. Read and understand the relevant code\n" + + "2. Create `.claude-task/todos.md` with a detailed checklist of specific tasks\n" + + "3. **When planning is complete**, write \"ready\" to `.claude-task/phase`:\n" + + " ```bash\n" + + " echo \"ready\" > .claude-task/phase\n" + + " ```\n" + + " This signals the TUI that you're done planning.\n\n" + + "4. Tell the user: \"Planning complete. Review .claude-task/todos.md. Execution will start automatically.\"\n\n" + + "## What happens next\n\n" + + "The automated loop will:\n" + + "- Run you repeatedly until all todos are done\n" + + "- You read CLAUDE.md and .claude-task/todos.md each iteration\n" + + "- Mark todos as [x] when complete\n" + + "- Write \"done\" to .claude-task/phase when ALL todos complete\n\n" + + "## Important\n\n" + + "- Be thorough in research before creating the plan\n" + + "- Keep todos specific and actionable\n" + + "- Include testing in the plan\n" + + "- You can interact with the user now during planning\n" + + "- **COMMIT your plan** before signaling ready (git add . && git commit -m \"plan: \")\n\n" + + "\n\n" +} + +func (t *Task) readyContext() string { + prePrompt := t.PrePrompt() + + // Read current todos + todosPath := filepath.Join(t.ClaudeTaskDir(), "todos.md") + todosContent := "" + if data, err := os.ReadFile(todosPath); err == nil { + todosContent = string(data) + } + + return "\n" + + "# Active Task - Ready for Review\n\n" + + "Planning is complete. The user is reviewing the plan.\n\n" + + "## The Task\n\n" + + prePrompt + "\n\n" + + "## Current Todos\n\n" + + todosContent + "\n\n" + + "## Status\n\n" + + "Waiting for:\n" + + "1. User review of `.claude-task/todos.md`\n" + + "2. Automated execution to start (queue processor handles this)\n\n" + + "You can discuss the plan with the user while waiting.\n\n" + + "\n\n" +} + +func (t *Task) executingContext() string { + prePrompt := t.PrePrompt() + + // Read current todos + todosPath := filepath.Join(t.ClaudeTaskDir(), "todos.md") + todosContent := "" + if data, err := os.ReadFile(todosPath); err == nil { + todosContent = string(data) + } + + return "\n" + + "# Active Task - Executing Phase\n\n" + + "You are in an automated execution loop. Work through the todos systematically.\n\n" + + "## The Task\n\n" + + prePrompt + "\n\n" + + "## Current Todos\n\n" + + todosContent + "\n\n" + + "## Instructions\n\n" + + "1. Find the next uncompleted todo (marked with [ ])\n" + + "2. Complete it\n" + + "3. Mark it done in .claude-task/todos.md (change [ ] to [x])\n" + + "4. Run tests to verify\n" + + "5. Continue to next todo\n\n" + + "## IMPORTANT: Commit Your Work\n\n" + + "**You MUST commit after each completed todo.** This is critical.\n\n" + + "```bash\n" + + "git add -A && git commit -m \"\"\n" + + "```\n\n" + + "- Commit after EVERY todo completion, not just at the end\n" + + "- Short casual messages (e.g., \"add user auth\", \"fix login bug\")\n" + + "- No attribution/co-author needed\n" + + "- If you exit without committing, your work is lost!\n\n" + + "## When Done\n\n" + + "When ALL todos are complete and tests pass:\n" + + "1. Make a final commit if there are any uncommitted changes\n" + + "2. Verify with `git status` that working tree is clean\n" + + "3. Write \"done\" to .claude-task/phase\n" + + "4. The loop will exit\n\n" + + "## If Stuck\n\n" + + "If stuck, just exit - the loop will restart you.\n" + + "Leave notes in .claude-task/todos.md about what's blocking.\n\n" + + "\n\n" +} + +// RemoveTaskContext removes the injected task context from CLAUDE.md. +func (t *Task) RemoveTaskContext() error { + claudeMdPath := filepath.Join(t.BranchPath, "CLAUDE.md") + + data, err := os.ReadFile(claudeMdPath) + if err != nil { + return nil + } + + content := removeTaskContext(string(data)) + return os.WriteFile(claudeMdPath, []byte(content), 0644) +} + +func removeTaskContext(content string) string { + startMarker := "" + endMarker := "" + + startIdx := strings.Index(content, startMarker) + endIdx := strings.Index(content, endMarker) + + if startIdx == -1 || endIdx == -1 { + return content + } + + endIdx += len(endMarker) + for endIdx < len(content) && (content[endIdx] == '\n' || content[endIdx] == '\r') { + endIdx++ + } + + return content[:startIdx] + content[endIdx:] +} diff --git a/task/task.go b/task/task.go new file mode 100644 index 0000000..419bb5a --- /dev/null +++ b/task/task.go @@ -0,0 +1,359 @@ +// Package task provides task orchestration for AI-driven development. +package task + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/darklang/dark-multi/config" +) + +// Phase represents the current phase of a task. +type Phase string + +const ( + PhaseNone Phase = "" // No task assigned + PhasePlanning Phase = "planning" // AI creating plan, todos + PhaseReady Phase = "ready" // Planning done, waiting for user to start Ralph + PhaseExecuting Phase = "executing" // Ralph loop running + PhaseDone Phase = "done" // Complete + PhaseAuthError Phase = "auth-error" // Authentication failed + PhaseError Phase = "error" // General error + PhaseMaxIterations Phase = "max-iterations-reached" // Hit iteration limit + PhaseAwaitingAnswers Phase = "awaiting-answers" // Needs human input + PhaseReadyForReview Phase = "ready-for-review" // Completed, needs review +) + +// PhaseDisplay returns a human-readable display string for a phase. +func (p Phase) Display() string { + switch p { + case PhaseNone: + return "no task" + case PhasePlanning: + return "planning" + case PhaseReady: + return "ready" + case PhaseExecuting: + return "executing" + case PhaseDone: + return "done" + case PhaseAuthError: + return "auth error" + case PhaseError: + return "error" + case PhaseMaxIterations: + return "max iterations" + case PhaseAwaitingAnswers: + return "needs input" + case PhaseReadyForReview: + return "review" + default: + return string(p) + } +} + +// PhaseIcon returns an icon for the phase. +func (p Phase) Icon() string { + switch p { + case PhaseNone: + return "📝" + case PhasePlanning: + return "🔍" + case PhaseReady: + return "✋" + case PhaseExecuting: + return "⚡" + case PhaseDone: + return "✅" + case PhaseAuthError: + return "🔑" + case PhaseError: + return "❌" + case PhaseMaxIterations: + return "🔄" + case PhaseAwaitingAnswers: + return "❓" + case PhaseReadyForReview: + return "👀" + default: + return "?" + } +} + +// Task represents an AI-driven development task for a branch. +type Task struct { + BranchName string + TaskDir string // ~/.config/dark-multi/tasks// + BranchPath string // ~/code/dark// +} + +// New creates a new Task for a branch. +func New(branchName, branchPath string) *Task { + taskDir := filepath.Join(config.ConfigDir, "tasks", branchName) + return &Task{ + BranchName: branchName, + TaskDir: taskDir, + BranchPath: branchPath, + } +} + +// Exists returns true if a task exists for this branch. +func (t *Task) Exists() bool { + _, err := os.Stat(t.prePromptPath()) + return err == nil +} + +// Phase returns the current phase of the task. +func (t *Task) Phase() Phase { + if !t.Exists() { + return PhaseNone + } + + // Check branch's .claude-task/phase (Claude/loop writes here) + branchPhaseFile := filepath.Join(t.BranchPath, ".claude-task", "phase") + if data, err := os.ReadFile(branchPhaseFile); err == nil { + phase := Phase(strings.TrimSpace(string(data))) + if phase != "" { + return phase + } + } + + // Fall back to task dir phase file + data, err := os.ReadFile(t.phasePath()) + if err != nil { + return PhasePlanning // Has pre-prompt but no phase = planning + } + + phase := Phase(strings.TrimSpace(string(data))) + if phase == "" { + return PhasePlanning + } + return phase +} + +// SetPhase updates the task phase. +func (t *Task) SetPhase(phase Phase) error { + if err := os.MkdirAll(t.TaskDir, 0755); err != nil { + return err + } + return os.WriteFile(t.phasePath(), []byte(string(phase)), 0644) +} + +// PrePrompt returns the pre-prompt content. +func (t *Task) PrePrompt() string { + data, err := os.ReadFile(t.prePromptPath()) + if err != nil { + return "" + } + return string(data) +} + +// SetPrePrompt saves the pre-prompt and initializes the task. +func (t *Task) SetPrePrompt(content string) error { + if err := os.MkdirAll(t.TaskDir, 0755); err != nil { + return err + } + return os.WriteFile(t.prePromptPath(), []byte(content), 0644) +} + +// TodoProgress returns (completed, total) todo counts from .claude-task/todos.md. +func (t *Task) TodoProgress() (int, int) { + todosPath := filepath.Join(t.BranchPath, ".claude-task", "todos.md") + data, err := os.ReadFile(todosPath) + if err != nil { + return 0, 0 + } + + content := string(data) + total := strings.Count(content, "- [ ]") + strings.Count(content, "- [x]") + strings.Count(content, "- [X]") + completed := strings.Count(content, "- [x]") + strings.Count(content, "- [X]") + return completed, total +} + +// StatusLine returns a short status for TUI display. +func (t *Task) StatusLine() string { + phase := t.Phase() + // Show todo progress for planning, ready, and executing phases + if phase == PhasePlanning || phase == PhaseReady || phase == PhaseExecuting { + done, total := t.TodoProgress() + if total > 0 { + return fmt.Sprintf("%d/%d", done, total) + } + } + return "" +} + +// PrePromptTemplate returns a template for a new pre-prompt. +func PrePromptTemplate(branchName string) string { + return fmt.Sprintf(`# Task: %s + +## Goal +[What should this accomplish?] + +## Context +[Relevant background, files to look at, constraints] + +## Success Criteria +[How do we know it's done? What tests should pass?] +`, branchName) +} + +// File paths +func (t *Task) prePromptPath() string { return filepath.Join(t.TaskDir, "pre-prompt.md") } +func (t *Task) phasePath() string { return filepath.Join(t.TaskDir, "phase") } + +// PrePromptPath returns the path to pre-prompt.md for external editing. +func (t *Task) PrePromptPath() string { return t.prePromptPath() } + +// ClaudeTaskDir returns the path to .claude-task/ in the branch. +func (t *Task) ClaudeTaskDir() string { + return filepath.Join(t.BranchPath, ".claude-task") +} + +// EnsureClaudeTaskDir creates the .claude-task directory in the branch. +func (t *Task) EnsureClaudeTaskDir() error { + return os.MkdirAll(t.ClaudeTaskDir(), 0755) +} + +// CopyLoopScript copies the Ralph loop script into the branch's .claude-task/. +func (t *Task) CopyLoopScript() error { + if err := t.EnsureClaudeTaskDir(); err != nil { + return err + } + + // The loop script content + script := `#!/bin/bash +# Ralph Wiggum loop - runs Claude until task complete +set -e + +TASK_DIR=".claude-task" +PHASE_FILE="$TASK_DIR/phase" +MAX_ITERATIONS=${MAX_ITERATIONS:-100} +ITERATION=0 + +mkdir -p "$TASK_DIR" +echo "executing" > "$PHASE_FILE" + +log() { + echo "[ralph] $1" + echo "$(date '+%H:%M:%S') $1" >> "$TASK_DIR/loop.log" +} + +log "Starting Ralph loop (max $MAX_ITERATIONS iterations)" + +while [ $ITERATION -lt $MAX_ITERATIONS ]; do + ITERATION=$((ITERATION + 1)) + + phase=$(cat "$PHASE_FILE" 2>/dev/null || echo "executing") + if [ "$phase" = "done" ]; then + log "Task complete!" + break + fi + + log "Iteration $ITERATION - running Claude" + + # Run Claude with a prompt - it reads CLAUDE.md which has the task context + claude --dangerously-skip-permissions -p "Continue working on the task. Read CLAUDE.md for context and .claude-task/todos.md for the checklist. Complete the next unchecked todo." || true + + # Check phase after Claude exits + phase=$(cat "$PHASE_FILE" 2>/dev/null || echo "executing") + if [ "$phase" = "done" ]; then + log "Task complete!" + break + fi + + log "Claude exited, restarting in 2s..." + sleep 2 +done + +if [ $ITERATION -ge $MAX_ITERATIONS ]; then + log "Max iterations reached" +fi + +log "Loop finished" +` + + scriptPath := filepath.Join(t.ClaudeTaskDir(), "ralph.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + return err + } + + return nil +} + +// WriteInitialSetup creates the initial .claude-task/todos.md and phase file. +func (t *Task) WriteInitialSetup() error { + if err := t.EnsureClaudeTaskDir(); err != nil { + return err + } + + // Create initial todos.md + todos := `# Task Todos + + + +- [ ] (Planning) Research and understand the codebase +- [ ] (Planning) Create detailed implementation plan +- [ ] (Planning) Update this todo list with specific tasks +` + todosPath := filepath.Join(t.ClaudeTaskDir(), "todos.md") + if err := os.WriteFile(todosPath, []byte(todos), 0644); err != nil { + return err + } + + // Set phase to planning + phasePath := filepath.Join(t.ClaudeTaskDir(), "phase") + if err := os.WriteFile(phasePath, []byte("planning"), 0644); err != nil { + return err + } + + return nil +} + +// GetCreatedTime returns when the task was created. +func (t *Task) GetCreatedTime() time.Time { + info, err := os.Stat(t.prePromptPath()) + if err != nil { + return time.Time{} + } + return info.ModTime() +} + +// Cleanup removes task-related files for a clean PR. +// This commits any uncommitted work first, then removes .claude-task/ and cleans CLAUDE.md. +func (t *Task) Cleanup() error { + gitDir := t.BranchPath + + // First, commit any uncommitted work (excluding task files) + // Check if there's uncommitted work + out, _ := exec.Command("git", "-C", gitDir, "status", "--porcelain").Output() + hasUncommitted := len(strings.TrimSpace(string(out))) > 0 + + if hasUncommitted { + // Stage and commit the actual work first + exec.Command("git", "-C", gitDir, "add", "-A").Run() + exec.Command("git", "-C", gitDir, "commit", "-m", fmt.Sprintf("wip: %s", t.BranchName)).Run() + } + + // Now remove task files + claudeTaskDir := t.ClaudeTaskDir() + if _, err := os.Stat(claudeTaskDir); err == nil { + os.RemoveAll(claudeTaskDir) + } + + // Remove injected task context from CLAUDE.md + t.RemoveTaskContext() + + // Commit the cleanup separately (if there are changes) + out, _ = exec.Command("git", "-C", gitDir, "status", "--porcelain").Output() + if len(strings.TrimSpace(string(out))) > 0 { + exec.Command("git", "-C", gitDir, "add", "-A").Run() + exec.Command("git", "-C", gitDir, "commit", "-m", "cleanup: remove task management files").Run() + } + + return nil +} diff --git a/tmux/tmux.go b/tmux/tmux.go index 0fa6e5c..89b28cb 100644 --- a/tmux/tmux.go +++ b/tmux/tmux.go @@ -48,8 +48,8 @@ func OpenClaude(branchName, containerID string) error { } exec.Command("tmux", "set-option", "-t", session, "-g", "mouse", "on").Run() - // Start bash in container, then run claude - dockerBash := fmt.Sprintf("docker exec -it -w /home/dark/app %s bash", containerID) + // Start bash in container with API key, then run claude + dockerBash := dockerExecWithEnv(containerID) exec.Command("tmux", "send-keys", "-t", session, dockerBash, "Enter").Run() exec.Command("tmux", "send-keys", "-t", session, "sleep 1 && claude --dangerously-skip-permissions", "Enter").Run() } @@ -72,14 +72,24 @@ func OpenTerminal(branchName, containerID string) error { } exec.Command("tmux", "set-option", "-t", session, "-g", "mouse", "on").Run() - // Start bash in container - dockerBash := fmt.Sprintf("docker exec -it -w /home/dark/app %s bash", containerID) + // Start bash in container with API key + dockerBash := dockerExecWithEnv(containerID) exec.Command("tmux", "send-keys", "-t", session, dockerBash, "Enter").Run() } return openInTerminal(session) } +// dockerExecWithEnv returns the docker exec command with ANTHROPIC_API_KEY passed through. +func dockerExecWithEnv(containerID string) string { + apiKey := config.GetAnthropicAPIKey() + if apiKey != "" { + // Use single quotes to prevent shell interpretation of special characters + return fmt.Sprintf("docker exec -it -e ANTHROPIC_API_KEY='%s' -w /home/dark/app %s bash", apiKey, containerID) + } + return fmt.Sprintf("docker exec -it -w /home/dark/app %s bash", containerID) +} + // openInTerminal opens a tmux session in a terminal window. // If already attached, focuses the existing window. func openInTerminal(session string) error { @@ -101,6 +111,8 @@ func CapturePaneContent(branchName string, lines int) string { if !sessionExists(session) { return "" } + + // Capture last N lines from scrollback cmd := exec.Command("tmux", "capture-pane", "-t", session, "-p", "-S", fmt.Sprintf("-%d", lines)) out, err := cmd.Output() if err != nil { @@ -222,6 +234,43 @@ func detectTerminal() string { return "xterm" } +// StartRalphLoop starts the Ralph loop in the Claude session (headless). +// Kills any existing session and starts fresh. Does NOT open a terminal window. +// Use OpenClaude() to view the session. +func StartRalphLoop(branchName, containerID string) error { + if !IsAvailable() { + return fmt.Errorf("tmux not available") + } + + session := sessionName(branchName, SessionClaude) + + // Kill existing session - cleaner than trying to interrupt + if sessionExists(session) { + exec.Command("tmux", "kill-session", "-t", session).Run() + } + + // Create fresh session + if err := exec.Command("tmux", "new-session", "-d", "-s", session).Run(); err != nil { + return fmt.Errorf("failed to create session: %w", err) + } + exec.Command("tmux", "set-option", "-t", session, "-g", "mouse", "on").Run() + + // Set up pipe-pane to log all output for summarization + exec.Command("tmux", "pipe-pane", "-t", session, "-o", "cat >> /tmp/claude-output-"+branchName+".log").Run() + + // Start bash in container with API key, then run ralph + dockerBash := dockerExecWithEnv(containerID) + exec.Command("tmux", "send-keys", "-t", session, dockerBash, "Enter").Run() + exec.Command("tmux", "send-keys", "-t", session, "sleep 1 && .claude-task/ralph.sh", "Enter").Run() + + return nil // Headless - no terminal window opened +} + +// GetOutputLogPath returns the path to the Claude output log for a branch. +func GetOutputLogPath(branchName string) string { + return "/tmp/claude-output-" + branchName + ".log" +} + // Legacy compatibility // BranchSessionExists returns true if the Claude session exists (for grid status). @@ -244,7 +293,7 @@ func CreateBranchSession(branchName string, containerID string, branchPath strin return err } exec.Command("tmux", "set-option", "-t", session, "-g", "mouse", "on").Run() - dockerBash := fmt.Sprintf("docker exec -it -w /home/dark/app %s bash", containerID) + dockerBash := dockerExecWithEnv(containerID) exec.Command("tmux", "send-keys", "-t", session, dockerBash, "Enter").Run() exec.Command("tmux", "send-keys", "-t", session, "sleep 1 && claude --dangerously-skip-permissions", "Enter").Run() return nil diff --git a/tui/app.go b/tui/app.go index fa5ff78..3622579 100644 --- a/tui/app.go +++ b/tui/app.go @@ -1,11 +1,26 @@ package tui import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + tea "github.com/charmbracelet/bubbletea" + + "github.com/darklang/dark-multi/config" ) // Run starts the TUI application. func Run() error { + // First-run setup + if config.IsFirstRun() { + if err := firstRunSetup(); err != nil { + return err + } + } + p := tea.NewProgram( NewGridModel(), tea.WithAltScreen(), @@ -14,3 +29,40 @@ func Run() error { _, err := p.Run() return err } + +// firstRunSetup prompts for initial configuration. +func firstRunSetup() error { + cpuCores, ramGB := config.GetSystemResources() + suggested := config.SuggestMaxInstances() + + fmt.Println("Welcome to Dark Multi!") + fmt.Println() + fmt.Printf("System: %d CPU cores, %dGB RAM\n", cpuCores, ramGB) + fmt.Printf("Suggested max concurrent containers: %d\n", suggested) + fmt.Println() + fmt.Printf("How many containers to run at once? [%d]: ", suggested) + + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return err + } + + input = strings.TrimSpace(input) + maxConcurrent := suggested + if input != "" { + if n, err := strconv.Atoi(input); err == nil && n > 0 { + maxConcurrent = n + } + } + + if err := config.SetMaxConcurrent(maxConcurrent); err != nil { + return err + } + + fmt.Printf("\nSet to %d concurrent containers. You can change this later with:\n", maxConcurrent) + fmt.Println(" export DARK_MULTI_MAX_CONCURRENT=N") + fmt.Println() + + return nil +} diff --git a/tui/detail.go b/tui/detail.go new file mode 100644 index 0000000..e0c2efb --- /dev/null +++ b/tui/detail.go @@ -0,0 +1,292 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/darklang/dark-multi/branch" + "github.com/darklang/dark-multi/config" + "github.com/darklang/dark-multi/queue" + "github.com/darklang/dark-multi/tmux" +) + +var ( + detailTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")). + MarginBottom(1) + + detailLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("99")). + Width(15) + + detailValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + detailSectionStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("214")). + MarginTop(1). + MarginBottom(1) + + detailURLStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("117")). + Underline(true) +) + +// DetailModel shows detailed information about a task. +type DetailModel struct { + task *queue.Task + branch *branch.Branch + parent GridModel + width int + height int + urlCursor int + urls []string +} + +// NewDetailModel creates a new detail view for a task. +func NewDetailModel(task *queue.Task, parent GridModel) DetailModel { + b := branch.New(task.ID) + + // Build list of URLs + var urls []string + if b.Exists() && b.IsRunning() { + // Add canvas URLs + urls = append(urls, + fmt.Sprintf("http://dark-packages.%s.dlio.localhost:%d/ping", task.ID, config.ProxyPort), + fmt.Sprintf("http://builtwithdark.%s.dlio.localhost:%d", task.ID, config.ProxyPort), + ) + } + + return DetailModel{ + task: task, + branch: b, + parent: parent, + width: parent.width, + height: parent.height, + urls: urls, + } +} + +// Init initializes the detail model. +func (m DetailModel) Init() tea.Cmd { + return nil +} + +// Update handles messages. +func (m DetailModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc": + // Return to grid + return m.parent, m.parent.Init() + + case "up", "k": + if m.urlCursor > 0 { + m.urlCursor-- + } + + case "down", "j": + if m.urlCursor < len(m.urls)-1 { + m.urlCursor++ + } + + case "enter", "o": + // Open selected URL + if len(m.urls) > 0 && m.urlCursor < len(m.urls) { + openInBrowser(m.urls[m.urlCursor]) + } + + case "s": + // Start task + if m.branch != nil && !m.branch.IsRunning() { + globalPendingBranches[m.task.ID] = &PendingBranch{Name: m.task.ID, Status: "starting"} + return m.parent, m.parent.startBranch(m.branch) + } else if m.branch == nil || !m.branch.Exists() { + globalPendingBranches[m.task.ID] = &PendingBranch{Name: m.task.ID, Status: "creating"} + return m.parent, m.parent.createAndStartBranch(m.task.ID) + } + + case "K": + // Kill task + if m.branch != nil && m.branch.IsRunning() { + return m.parent, m.parent.stopBranch(m.branch) + } + + case "c": + // Open Claude + if m.branch != nil && m.branch.IsRunning() { + containerID, err := m.branch.ContainerID() + if err == nil { + tmux.OpenClaude(m.task.ID, containerID) + } + } + + case "t": + // Open terminal + if m.branch != nil && m.branch.IsRunning() { + containerID, err := m.branch.ContainerID() + if err == nil { + tmux.OpenTerminal(m.task.ID, containerID) + } + } + + case "v": + // Focus view + focus := NewFocusModel(m.task, m.parent) + return focus, focus.Init() + + case "e": + // Open VS Code + if m.branch != nil && m.branch.IsRunning() { + return m.parent, m.parent.openCode(m.branch) + } + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.parent.width = msg.Width + m.parent.height = msg.Height + } + + return m, nil +} + +// View renders the detail view. +func (m DetailModel) View() string { + var b strings.Builder + + // Title + b.WriteString(detailTitleStyle.Render(fmt.Sprintf("%s %s", m.task.Status.Icon(), m.task.ID))) + b.WriteString("\n\n") + + // Task info section + b.WriteString(detailSectionStyle.Render("Task Info")) + b.WriteString("\n") + b.WriteString(m.renderRow("Name", m.task.Name)) + b.WriteString(m.renderRow("Status", m.task.Status.Display())) + b.WriteString(m.renderRow("Priority", fmt.Sprintf("%d", m.task.Priority))) + if !m.task.CreatedAt.IsZero() { + b.WriteString(m.renderRow("Created", m.task.CreatedAt.Format(time.RFC822))) + } + if !m.task.StartedAt.IsZero() { + b.WriteString(m.renderRow("Started", m.task.StartedAt.Format(time.RFC822))) + } + if !m.task.CompletedAt.IsZero() { + b.WriteString(m.renderRow("Completed", m.task.CompletedAt.Format(time.RFC822))) + } + if m.task.Error != "" { + b.WriteString(m.renderRow("Error", errorStyle.Render(m.task.Error))) + } + + // Prompt section + b.WriteString("\n") + b.WriteString(detailSectionStyle.Render("Prompt")) + b.WriteString("\n") + if m.task.Prompt != "" { + // Wrap and truncate prompt + prompt := m.task.Prompt + maxLen := m.width * 5 // Allow 5 lines + if len(prompt) > maxLen { + prompt = prompt[:maxLen] + "..." + } + // Simple word wrapping + lines := wrapTextWords(prompt, m.width-5) + for _, line := range lines { + b.WriteString(" " + detailValueStyle.Render(line) + "\n") + } + } else { + b.WriteString(" " + stoppedStyle.Render("[No prompt - task needs configuration]") + "\n") + } + + // Branch/Container info + if m.branch != nil && m.branch.Exists() { + b.WriteString("\n") + b.WriteString(detailSectionStyle.Render("Container")) + b.WriteString("\n") + if m.branch.IsRunning() { + b.WriteString(m.renderRow("Status", runningStyle.Render("● Running"))) + if containerID, err := m.branch.ContainerID(); err == nil { + b.WriteString(m.renderRow("Container", containerID[:12])) + } + } else { + b.WriteString(m.renderRow("Status", stoppedStyle.Render("○ Stopped"))) + } + + // Git stats + commits, added, removed := m.branch.GitStats() + if commits > 0 || added > 0 || removed > 0 { + b.WriteString(m.renderRow("Git", fmt.Sprintf("%d commits, +%d/-%d lines", commits, added, removed))) + } + } + + // URLs section + if len(m.urls) > 0 { + b.WriteString("\n") + b.WriteString(detailSectionStyle.Render("URLs")) + b.WriteString("\n") + for i, url := range m.urls { + prefix := " " + style := detailURLStyle + if i == m.urlCursor { + prefix = "▸ " + style = style.Bold(true) + } + b.WriteString(prefix + style.Render(url) + "\n") + } + } + + // Footer + b.WriteString("\n") + var actions []string + if m.branch != nil && m.branch.IsRunning() { + actions = append(actions, "[c]laude", "[t]erm", "[v]iew", "[e]dit", "[K]ill") + } else { + actions = append(actions, "[s]tart") + } + if len(m.urls) > 0 { + actions = append(actions, "[o]pen URL") + } + actions = append(actions, "[esc] back") + b.WriteString(helpStyle.Render(strings.Join(actions, " "))) + + return b.String() +} + +func (m DetailModel) renderRow(label, value string) string { + return detailLabelStyle.Render(label+":") + " " + detailValueStyle.Render(value) + "\n" +} + +// wrapTextWords wraps text at word boundaries. +func wrapTextWords(text string, width int) []string { + if width <= 0 { + return []string{text} + } + + var lines []string + words := strings.Fields(text) + var currentLine string + + for _, word := range words { + if currentLine == "" { + currentLine = word + } else if len(currentLine)+1+len(word) <= width { + currentLine += " " + word + } else { + lines = append(lines, currentLine) + currentLine = word + } + } + if currentLine != "" { + lines = append(lines, currentLine) + } + + return lines +} diff --git a/tui/filter.go b/tui/filter.go new file mode 100644 index 0000000..41e0515 --- /dev/null +++ b/tui/filter.go @@ -0,0 +1,202 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/darklang/dark-multi/queue" +) + +var ( + filterTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")). + MarginBottom(1) + + filterItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + filterSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("212")). + Bold(true) + + filterCheckStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")) + + filterUncheckStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) +) + +// allStatuses returns all possible queue statuses. +func allStatuses() []queue.Status { + return []queue.Status{ + queue.StatusRunning, + queue.StatusReady, + queue.StatusWaiting, + queue.StatusNeedsPrompt, + queue.StatusDone, + queue.StatusPaused, + } +} + +// FilterModel is a modal for selecting status filters. +type FilterModel struct { + statuses []queue.Status // all available statuses + selected map[queue.Status]bool // which statuses are selected + cursor int // current cursor position + parent GridModel // parent grid to return to +} + +// NewFilterModel creates a new filter modal. +func NewFilterModel(parent GridModel) FilterModel { + statuses := allStatuses() + selected := make(map[queue.Status]bool) + + // Initialize with parent's current filter + for _, s := range parent.statusFilter { + selected[s] = true + } + + // If no filter, default to all selected + if len(parent.statusFilter) == 0 { + for _, s := range statuses { + selected[s] = true + } + } + + return FilterModel{ + statuses: statuses, + selected: selected, + cursor: 0, + parent: parent, + } +} + +// Init initializes the filter model. +func (m FilterModel) Init() tea.Cmd { + return nil +} + +// Update handles messages. +func (m FilterModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc": + // Cancel - return to parent without changes + return m.parent, m.parent.Init() + + case "enter": + // Apply filter and return to parent + var filter []queue.Status + for _, s := range m.statuses { + if m.selected[s] { + filter = append(filter, s) + } + } + // If all selected, use empty filter (show all) + if len(filter) == len(m.statuses) { + filter = nil + } + m.parent.statusFilter = filter + m.parent.cursor = 0 // Reset cursor since items may change + return m.parent, m.parent.Init() + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + case "down", "j": + if m.cursor < len(m.statuses)-1 { + m.cursor++ + } + + case " ", "x": + // Toggle current selection + s := m.statuses[m.cursor] + m.selected[s] = !m.selected[s] + + case "a": + // Select all + for _, s := range m.statuses { + m.selected[s] = true + } + + case "n": + // Select none + for _, s := range m.statuses { + m.selected[s] = false + } + + case "r": + // Quick preset: running only + for _, s := range m.statuses { + m.selected[s] = (s == queue.StatusRunning) + } + + case "w": + // Quick preset: waiting (needs attention) + for _, s := range m.statuses { + m.selected[s] = (s == queue.StatusWaiting || s == queue.StatusNeedsPrompt) + } + } + + case tea.WindowSizeMsg: + m.parent.width = msg.Width + m.parent.height = msg.Height + } + + return m, nil +} + +// View renders the filter modal. +func (m FilterModel) View() string { + var b strings.Builder + + b.WriteString(filterTitleStyle.Render("STATUS FILTER")) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("Select which statuses to show:")) + b.WriteString("\n\n") + + for i, s := range m.statuses { + // Checkbox + check := filterUncheckStyle.Render("[ ]") + if m.selected[s] { + check = filterCheckStyle.Render("[✓]") + } + + // Status icon and name + label := fmt.Sprintf("%s %s", s.Icon(), s.Display()) + + // Count tasks with this status + count := 0 + for _, t := range m.parent.queueTasks { + if t.Status == s { + count++ + } + } + countStr := helpStyle.Render(fmt.Sprintf("(%d)", count)) + + // Highlight if cursor is here + style := filterItemStyle + if i == m.cursor { + style = filterSelectedStyle + label = "▸ " + label + } else { + label = " " + label + } + + b.WriteString(fmt.Sprintf("%s %s %s\n", check, style.Render(label), countStr)) + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("[space] toggle [a]ll [n]one [r]unning [w]aiting")) + b.WriteString("\n") + b.WriteString(helpStyle.Render("[enter] apply [esc] cancel")) + + return b.String() +} diff --git a/tui/focus.go b/tui/focus.go new file mode 100644 index 0000000..8db495a --- /dev/null +++ b/tui/focus.go @@ -0,0 +1,301 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/darklang/dark-multi/branch" + "github.com/darklang/dark-multi/queue" + "github.com/darklang/dark-multi/tmux" +) + +var ( + focusTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")). + Background(lipgloss.Color("236")). + Padding(0, 1) + + focusContentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + focusStatusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Background(lipgloss.Color("236")). + Padding(0, 1) +) + +// FocusModel shows a single container's output in full screen. +type FocusModel struct { + task *queue.Task + branch *branch.Branch + content string + scrollPos int + width int + height int + parent GridModel + inputMode bool + inputText string +} + +type focusTickMsg time.Time +type focusContentMsg string + +// NewFocusModel creates a new focus view for a task. +func NewFocusModel(task *queue.Task, parent GridModel) FocusModel { + b := branch.New(task.ID) + return FocusModel{ + task: task, + branch: b, + parent: parent, + width: parent.width, + height: parent.height, + } +} + +// Init initializes the focus model. +func (m FocusModel) Init() tea.Cmd { + return tea.Batch( + m.loadContent, + focusTickCmd(), + ) +} + +func focusTickCmd() tea.Cmd { + return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { + return focusTickMsg(t) + }) +} + +func (m FocusModel) loadContent() tea.Msg { + if m.branch == nil || !tmux.BranchSessionExists(m.task.ID) { + return focusContentMsg("") + } + // Capture more lines for full-screen view + lines := m.height - 4 // Leave room for header and footer + if lines < 20 { + lines = 50 + } + content := tmux.CapturePaneContent(m.task.ID, lines*2) // Get extra for scrolling + return focusContentMsg(content) +} + +// Update handles messages. +func (m FocusModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if m.inputMode { + return m.handleInput(msg) + } + + switch msg.String() { + case "q", "esc": + // Return to grid + return m.parent, m.parent.Init() + + case "up", "k": + if m.scrollPos > 0 { + m.scrollPos-- + } + + case "down", "j": + lines := strings.Split(m.content, "\n") + maxScroll := len(lines) - (m.height - 4) + if maxScroll < 0 { + maxScroll = 0 + } + if m.scrollPos < maxScroll { + m.scrollPos++ + } + + case "g": + // Go to top + m.scrollPos = 0 + + case "G": + // Go to bottom + lines := strings.Split(m.content, "\n") + maxScroll := len(lines) - (m.height - 4) + if maxScroll < 0 { + maxScroll = 0 + } + m.scrollPos = maxScroll + + case "pgup": + m.scrollPos -= 10 + if m.scrollPos < 0 { + m.scrollPos = 0 + } + + case "pgdown": + lines := strings.Split(m.content, "\n") + maxScroll := len(lines) - (m.height - 4) + if maxScroll < 0 { + maxScroll = 0 + } + m.scrollPos += 10 + if m.scrollPos > maxScroll { + m.scrollPos = maxScroll + } + + case "i": + // Enter input mode to send text to Claude + m.inputMode = true + m.inputText = "" + + case "o": + // Open in external terminal + if m.branch != nil && m.branch.IsRunning() { + containerID, err := m.branch.ContainerID() + if err == nil { + tmux.OpenClaude(m.task.ID, containerID) + } + } + + case "r": + // Refresh content + return m, m.loadContent + } + + case focusContentMsg: + m.content = string(msg) + return m, nil + + case focusTickMsg: + return m, tea.Batch(m.loadContent, focusTickCmd()) + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.parent.width = msg.Width + m.parent.height = msg.Height + } + + return m, nil +} + +func (m FocusModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + if m.inputText != "" { + // Send text to Claude session + tmux.SendToClaude(m.task.ID, m.inputText) + } + m.inputMode = false + m.inputText = "" + return m, m.loadContent + + case "esc": + m.inputMode = false + m.inputText = "" + return m, nil + + case "backspace": + if len(m.inputText) > 0 { + m.inputText = m.inputText[:len(m.inputText)-1] + } + return m, nil + + default: + key := msg.String() + if len(key) == 1 || key == "space" { + if key == "space" { + key = " " + } + m.inputText += key + } + return m, nil + } +} + +// View renders the focus view. +func (m FocusModel) View() string { + var b strings.Builder + + // Header + statusIcon := m.task.Status.Icon() + title := fmt.Sprintf("%s %s", statusIcon, m.task.ID) + + branchRunning := m.branch != nil && m.branch.Exists() && m.branch.IsRunning() + runStatus := "stopped" + if branchRunning { + runStatus = "running" + } + + headerLeft := focusTitleStyle.Render(title) + headerRight := focusStatusStyle.Render(runStatus) + headerPadding := m.width - lipgloss.Width(headerLeft) - lipgloss.Width(headerRight) + if headerPadding < 0 { + headerPadding = 0 + } + b.WriteString(headerLeft + strings.Repeat(" ", headerPadding) + headerRight) + b.WriteString("\n") + + // Content area + contentHeight := m.height - 4 // header + footer + padding + if contentHeight < 5 { + contentHeight = 20 + } + + if m.content == "" { + if !branchRunning { + b.WriteString(stoppedStyle.Render("\n[Container not running - press 's' in grid to start]\n")) + } else if !tmux.BranchSessionExists(m.task.ID) { + b.WriteString(stoppedStyle.Render("\n[No Claude session - press 'o' to open one]\n")) + } else { + b.WriteString(stoppedStyle.Render("\n[Loading...]\n")) + } + } else { + lines := strings.Split(m.content, "\n") + + // Apply scroll + start := m.scrollPos + if start >= len(lines) { + start = 0 + } + end := start + contentHeight + if end > len(lines) { + end = len(lines) + } + + visibleLines := lines[start:end] + + // Truncate long lines + for i, line := range visibleLines { + if len(line) > m.width { + visibleLines[i] = line[:m.width-1] + "…" + } + } + + b.WriteString(focusContentStyle.Render(strings.Join(visibleLines, "\n"))) + b.WriteString("\n") + } + + // Pad to bottom + currentLines := strings.Count(b.String(), "\n") + for i := currentLines; i < m.height-2; i++ { + b.WriteString("\n") + } + + // Footer / Input + if m.inputMode { + b.WriteString(focusStatusStyle.Render("Send to Claude: ")) + b.WriteString(m.inputText) + b.WriteString("█") + b.WriteString("\n") + b.WriteString(helpStyle.Render("[enter] send [esc] cancel")) + } else { + scrollInfo := "" + lines := strings.Split(m.content, "\n") + if len(lines) > contentHeight { + scrollInfo = fmt.Sprintf(" [line %d/%d]", m.scrollPos+1, len(lines)) + } + b.WriteString(helpStyle.Render(fmt.Sprintf("[i]nput [o]pen terminal [↑↓] scroll [g/G] top/bottom [r]efresh [esc] back%s", scrollInfo))) + } + + return b.String() +} diff --git a/tui/grid.go b/tui/grid.go index ac2a7b1..80a59b7 100644 --- a/tui/grid.go +++ b/tui/grid.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "os" "os/exec" "strings" "time" @@ -11,6 +12,9 @@ import ( "github.com/darklang/dark-multi/branch" "github.com/darklang/dark-multi/config" + "github.com/darklang/dark-multi/queue" + "github.com/darklang/dark-multi/summary" + "github.com/darklang/dark-multi/task" "github.com/darklang/dark-multi/tmux" ) @@ -35,6 +39,46 @@ var ( globalPendingBranches = make(map[string]*PendingBranch) ) +// cellStyleForStatus returns a subtle border color based on queue status. +func cellStyleForStatus(status queue.Status, selected bool) lipgloss.Style { + // Subtle border colors based on status + var borderColor string + switch status { + case queue.StatusDone: + borderColor = "34" // subtle green + case queue.StatusRunning: + borderColor = "33" // subtle blue + case queue.StatusWaiting: + borderColor = "130" // subtle orange/yellow + case queue.StatusReady: + borderColor = "241" // default gray + case queue.StatusNeedsPrompt: + borderColor = "241" // default gray + case queue.StatusPaused: + borderColor = "241" // default gray + default: + borderColor = "241" + } + + if selected { + // Brighter version when selected + switch status { + case queue.StatusDone: + borderColor = "42" // brighter green + case queue.StatusRunning: + borderColor = "39" // brighter blue + case queue.StatusWaiting: + borderColor = "214" // brighter orange + default: + borderColor = "212" // pink (default selected) + } + } + + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(borderColor)) +} + // GridInputMode represents input modes. type GridInputMode int @@ -50,11 +94,22 @@ type ContainerStats struct { Memory string // e.g., "1.2GB" } +// TaskInfo holds cached task information for display. +type TaskInfo struct { + Phase task.Phase + StatusLine string // e.g., "3/7 todos" + Summary string // AI-generated summary of current activity +} + // GridModel displays all Claude sessions in a grid layout. type GridModel struct { branches []*branch.Branch + queueTasks []*queue.Task // all tasks from queue paneContent map[string]string // branch name -> captured content containerStats map[string]ContainerStats // branch name -> stats + gitStats map[string]*GitStatsInfo // cached git stats + runningState map[string]bool // cached IsRunning state + taskInfo map[string]*TaskInfo // cached task info cursor int width int height int @@ -64,19 +119,57 @@ type GridModel struct { inputText string proxyRunning bool loading bool + statusFilter []queue.Status // filter by these statuses (empty = show all) + processorOn bool // queue processor running } // Grid layout messages type paneContentMsg map[string]string type containerStatsMsg map[string]ContainerStats +type runningStateMsg map[string]bool +type taskInfoMsg map[string]*TaskInfo +type queueTasksMsg []*queue.Task type gridTickMsg time.Time // NewGridModel creates a new grid view. func NewGridModel() GridModel { + // Start the queue processor + queue.StartProcessor() + + // Run health check on startup + issues := queue.RunHealthCheck() + var startupMessage string + if len(issues) > 0 { + // Summarize issues + var warnings, errors int + for _, issue := range issues { + if issue.Severity == "error" { + errors++ + } else if issue.Severity == "warning" { + warnings++ + } + } + if errors > 0 || warnings > 0 { + startupMessage = fmt.Sprintf("Health check: %d errors, %d warnings", errors, warnings) + } + // Auto-fix what we can + queue.AutoFix(issues) + } + + // Default filter: show running + ready (most actionable) + defaultFilter := []queue.Status{queue.StatusRunning, queue.StatusReady} + return GridModel{ branches: branch.GetManagedBranches(), + queueTasks: queue.Get().GetAll(), paneContent: make(map[string]string), containerStats: make(map[string]ContainerStats), + gitStats: make(map[string]*GitStatsInfo), + runningState: make(map[string]bool), + taskInfo: make(map[string]*TaskInfo), + statusFilter: defaultFilter, + processorOn: true, + message: startupMessage, } } @@ -85,11 +178,58 @@ func (m GridModel) Init() tea.Cmd { return tea.Batch( m.loadPaneContent, loadContainerStats, + m.loadGridGitStats, + m.loadRunningState, + m.loadTaskInfo, + loadQueueTasks, checkProxyStatus, gridTickCmd(), ) } +func loadQueueTasks() tea.Msg { + return queueTasksMsg(queue.Get().GetAll()) +} + +func (m GridModel) loadTaskInfo() tea.Msg { + info := make(map[string]*TaskInfo) + for _, b := range m.branches { + t := task.New(b.Name, b.Path) + phase := t.Phase() + ti := &TaskInfo{ + Phase: phase, + StatusLine: t.StatusLine(), + } + // Get AI summary for executing branches + if phase == task.PhaseExecuting { + ti.Summary = summary.GetSummary(b.Name) + } + info[b.Name] = ti + } + return taskInfoMsg(info) +} + +func (m GridModel) loadRunningState() tea.Msg { + state := make(map[string]bool) + for _, b := range m.branches { + state[b.Name] = b.IsRunning() + } + return runningStateMsg(state) +} + +func (m GridModel) loadGridGitStats() tea.Msg { + stats := make(map[string]*GitStatsInfo) + for _, b := range m.branches { + commits, added, removed := b.GitStats() + stats[b.Name] = &GitStatsInfo{ + Commits: commits, + Added: added, + Removed: removed, + } + } + return gitStatsMsg(stats) +} + func gridTickCmd() tea.Cmd { return tea.Tick(1*time.Second, func(t time.Time) tea.Msg { return gridTickMsg(t) @@ -154,27 +294,31 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "right": - if m.cursor < len(m.branches)-1 { + tasks := m.filteredTasks() + if m.cursor < len(tasks)-1 { m.cursor++ } case "up": - cols := m.numCols() + _, cols := m.gridDimensions() if m.cursor >= cols { m.cursor -= cols } case "down": - cols := m.numCols() - if m.cursor+cols < len(m.branches) { + tasks := m.filteredTasks() + _, cols := m.gridDimensions() + if m.cursor+cols < len(tasks) { m.cursor += cols } case "enter": - // Open Claude for selected branch (same as 'c') - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - if b.IsRunning() { + // Open Claude for selected task (same as 'c') + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + b := branch.New(t.ID) + if b.Exists() && b.IsRunning() { containerID, err := b.ContainerID() if err != nil { m.message = fmt.Sprintf("Error: %v", err) @@ -184,15 +328,17 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.message = fmt.Sprintf("Error: %v", err) } } else { - m.message = fmt.Sprintf("%s is stopped - press 's' to start", b.Name) + m.message = fmt.Sprintf("%s is not running - press 's' to start", t.ID) } } case "t": - // Open terminal for selected branch - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - if b.IsRunning() { + // Open terminal for selected task + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + b := branch.New(t.ID) + if b.Exists() && b.IsRunning() { containerID, err := b.ContainerID() if err != nil { m.message = fmt.Sprintf("Error: %v", err) @@ -202,49 +348,73 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.message = fmt.Sprintf("Error: %v", err) } } else { - m.message = fmt.Sprintf("%s is stopped - press 's' to start", b.Name) + m.message = fmt.Sprintf("%s is not running - press 's' to start", t.ID) } } case "c": - // Open Claude for selected branch - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - if b.IsRunning() { + // Open Claude for selected task + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + qt := tasks[m.cursor] + b := branch.New(qt.ID) + if b.Exists() && b.IsRunning() { + // Inject task context if there's an active task + t := task.New(b.Name, b.Path) + if t.Exists() { + phase := t.Phase() + if phase == task.PhasePlanning || phase == task.PhaseReady || phase == task.PhaseExecuting { + t.InjectTaskContext() + // Also ensure .claude-task dir exists + t.EnsureClaudeTaskDir() + } + } + containerID, err := b.ContainerID() if err != nil { m.message = fmt.Sprintf("Error: %v", err) return m, nil } if err := tmux.OpenClaude(b.Name, containerID); err != nil { - m.message = fmt.Sprintf("Error: %v", err) + m.message = fmt.Sprintf("Error opening Claude: %v", err) + } else { + m.message = fmt.Sprintf("Opened Claude terminal for %s", b.Name) } } else { - m.message = fmt.Sprintf("%s is stopped - press 's' to start", b.Name) + m.message = fmt.Sprintf("%s is not running - press 's' to start", qt.ID) } } case "s": - // Start selected branch - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - if b.IsRunning() { - m.message = fmt.Sprintf("%s is already running", b.Name) - } else { + // Start selected task + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + b := branch.New(t.ID) + if b.Exists() && b.IsRunning() { + m.message = fmt.Sprintf("%s is already running", t.ID) + } else if b.Exists() { globalPendingBranches[b.Name] = &PendingBranch{Name: b.Name, Status: "starting container"} m.loading = true return m, m.startBranch(b) + } else { + // Branch doesn't exist yet - create and start + globalPendingBranches[t.ID] = &PendingBranch{Name: t.ID, Status: "creating branch"} + m.loading = true + return m, m.createAndStartBranch(t.ID) } } case "k": - // Kill (stop) selected branch - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - if !b.IsRunning() { - m.message = fmt.Sprintf("%s is already stopped", b.Name) + // Kill (stop) selected task + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + b := branch.New(t.ID) + if !b.Exists() || !b.IsRunning() { + m.message = fmt.Sprintf("%s is not running", t.ID) } else { - m.message = fmt.Sprintf("Killing %s...", b.Name) + m.message = fmt.Sprintf("Killing %s...", t.ID) m.loading = true return m, m.stopBranch(b) } @@ -257,18 +427,21 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "x": - // Delete branch - if len(m.branches) > 0 { + // Delete task/branch + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { m.inputMode = GridInputConfirmDelete return m, nil } case "e": // Open VS Code (editor) - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - if !b.IsRunning() { - m.message = fmt.Sprintf("%s is stopped - press 's' to start", b.Name) + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + b := branch.New(t.ID) + if !b.Exists() || !b.IsRunning() { + m.message = fmt.Sprintf("%s is not running - press 's' to start", t.ID) return m, nil } return m, m.openCode(b) @@ -276,26 +449,111 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "m": // Open Matter - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - url := fmt.Sprintf("dark-packages.%s.dlio.localhost:%d/ping", b.Name, config.ProxyPort) + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + url := fmt.Sprintf("dark-packages.%s.dlio.localhost:%d/ping", t.ID, config.ProxyPort) openInBrowser(url) m.message = "Opened Matter" } case "d": // Open diff (gitk) - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - return m, m.openDiff(b) + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + b := branch.New(t.ID) + if b.Exists() { + return m, m.openDiff(b) + } + m.message = fmt.Sprintf("%s branch not created yet", t.ID) } case "l": // View logs - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - logs := NewLogViewerModel(b) - return logs, logs.Init() + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + b := branch.New(t.ID) + if b.Exists() { + logs := NewLogViewerModel(b) + return logs, logs.Init() + } + m.message = fmt.Sprintf("%s branch not created yet", t.ID) + } + + case "p": + // Edit pre-prompt (task definition) + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + qt := tasks[m.cursor] + q := queue.Get() + + // Edit the queue task's prompt + editor := findEditor() + // Write prompt to temp file for editing + tmpFile := fmt.Sprintf("/tmp/dark-multi-prompt-%s.md", qt.ID) + content := qt.Prompt + if content == "" { + content = fmt.Sprintf("# Task: %s\n\n[Write your prompt here]\n", qt.Name) + } + if err := writeFile(tmpFile, content); err != nil { + m.message = fmt.Sprintf("Error: %v", err) + return m, nil + } + + c := exec.Command(editor, tmpFile) + return m, tea.ExecProcess(c, func(err error) tea.Msg { + if err != nil { + return operationErrMsg{err} + } + // Read back the edited prompt + newContent, err := readFile(tmpFile) + if err != nil { + return operationErrMsg{err} + } + if newContent != "" && newContent != content { + q.SetPrompt(qt.ID, newContent) + q.Save() + return operationDoneMsg{fmt.Sprintf("Prompt updated for %s", qt.ID)} + } + return operationDoneMsg{"Prompt unchanged"} + }) + } + + case "f": + // Open filter modal + filter := NewFilterModel(m) + return filter, filter.Init() + + case "Q": + // Toggle queue processor (Shift+Q) + if queue.IsProcessorRunning() { + queue.StopProcessor() + m.processorOn = false + m.message = "Queue processor stopped (manual mode)" + } else { + queue.StartProcessor() + m.processorOn = true + m.message = "Queue processor started (auto mode)" + } + + case "v": + // Open focus view for selected task + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + focus := NewFocusModel(t, m) + return focus, focus.Init() + } + + case "i": + // Open detail/info view for selected task + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + detail := NewDetailModel(t, m) + return detail, detail.Init() } case "?": @@ -314,18 +572,38 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case gitStatsMsg: + m.gitStats = msg + return m, nil + + case runningStateMsg: + m.runningState = msg + return m, nil + + case taskInfoMsg: + m.taskInfo = msg + return m, nil + case proxyStatusMsg: m.proxyRunning = bool(msg) return m, nil + case queueTasksMsg: + m.queueTasks = msg + return m, nil + case gridTickMsg: // Refresh branches and content periodically m.branches = branch.GetManagedBranches() - if m.cursor >= len(m.branches) && len(m.branches) > 0 { - m.cursor = len(m.branches) - 1 + m.queueTasks = queue.Get().GetAll() + m.processorOn = queue.IsProcessorRunning() + // Keep cursor in bounds (grid shows filtered tasks) + tasks := m.filteredTasks() + if m.cursor >= len(tasks) && len(tasks) > 0 { + m.cursor = len(tasks) - 1 } // Note: Don't clean up globalPendingBranches here - let branchStartedMsg handle it - return m, tea.Batch(m.loadPaneContent, loadContainerStats, gridTickCmd()) + return m, tea.Batch(m.loadPaneContent, loadContainerStats, m.loadGridGitStats, m.loadRunningState, m.loadTaskInfo, loadQueueTasks, gridTickCmd()) case createStepMsg: if pending, ok := globalPendingBranches[msg.name]; ok { @@ -379,14 +657,18 @@ func (m GridModel) handleInputMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { name := m.inputText m.inputMode = GridInputNone m.inputText = "" - m.loading = true - b := branch.New(name) - if b.Exists() { - globalPendingBranches[name] = &PendingBranch{Name: name, Status: "starting container"} - } else { - globalPendingBranches[name] = &PendingBranch{Name: name, Status: "cloning from GitHub"} + + // Add to queue (doesn't start it yet) + q := queue.Get() + if existing := q.Get(name); existing != nil { + m.message = fmt.Sprintf("Task '%s' already exists", name) + return m, nil } - return m, m.createAndStartBranch(name) + q.Add(name, name, "", 50) // Empty prompt, needs-prompt status + q.Save() + m.queueTasks = q.GetAll() + m.message = fmt.Sprintf("Added task '%s' - press 'p' to set prompt, 's' to start", name) + return m, nil case "esc": m.inputMode = GridInputNone @@ -410,12 +692,13 @@ func (m GridModel) handleInputMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case GridInputConfirmDelete: switch msg.String() { case "y", "Y": - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] m.inputMode = GridInputNone m.loading = true - m.message = fmt.Sprintf("Removing %s...", b.Name) - return m, m.removeBranch(b) + m.message = fmt.Sprintf("Removing %s...", t.ID) + return m, m.removeTask(t) } m.inputMode = GridInputNone return m, nil @@ -430,6 +713,65 @@ func (m GridModel) handleInputMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// filteredTasks returns queue tasks filtered by status. +func (m GridModel) filteredTasks() []*queue.Task { + if len(m.statusFilter) == 0 { + return m.queueTasks + } + + filterSet := make(map[queue.Status]bool) + for _, s := range m.statusFilter { + filterSet[s] = true + } + + var result []*queue.Task + for _, t := range m.queueTasks { + if filterSet[t.Status] { + result = append(result, t) + } + } + return result +} + +// nextFilter cycles through filter presets. +func (m GridModel) nextFilter() []queue.Status { + // Filter presets to cycle through + presets := [][]queue.Status{ + {queue.StatusRunning}, // Running only + {queue.StatusRunning, queue.StatusReady}, // Running + Ready + {queue.StatusRunning, queue.StatusReady, queue.StatusWaiting}, // Active + {}, // All + } + + // Find current preset + currentKey := filterKey(m.statusFilter) + for i, preset := range presets { + if filterKey(preset) == currentKey { + return presets[(i+1)%len(presets)] + } + } + return presets[0] +} + +func filterKey(statuses []queue.Status) string { + var parts []string + for _, s := range statuses { + parts = append(parts, string(s)) + } + return strings.Join(parts, ",") +} + +// filterDescription returns a human-readable description of current filter. +func (m GridModel) filterDescription() string { + if len(m.statusFilter) == 0 { + return "all" + } + if len(m.statusFilter) == 1 { + return string(m.statusFilter[0]) + } + return fmt.Sprintf("%d statuses", len(m.statusFilter)) +} + // filteredPendingBranches returns pending branches that don't overlap with existing branches func (m GridModel) filteredPendingBranches() []*PendingBranch { var result []*PendingBranch @@ -448,14 +790,64 @@ func (m GridModel) filteredPendingBranches() []*PendingBranch { return result } -func (m GridModel) numCols() int { +// isRunning returns cached running state for a branch +func (m GridModel) isRunning(name string) bool { + if running, ok := m.runningState[name]; ok { + return running + } + return false +} + +// gridDimensions calculates optimal rows and cols for the grid. +func (m GridModel) gridDimensions() (rows, cols int) { pending := m.filteredPendingBranches() - n := len(m.branches) + len(pending) + tasks := m.filteredTasks() + n := len(tasks) + len(pending) if n == 0 { - return 1 + return 1, 1 + } + + // Get available space (reserve 5 lines for status/help) + availHeight := m.height - 5 + if availHeight < 10 { + availHeight = 35 + } + availWidth := m.width + if availWidth < 40 { + availWidth = 120 + } + + // Minimum cell dimensions for readability + minCellWidth := 40 + minCellHeight := 8 + + // Calculate max possible rows and cols + maxRows := availHeight / minCellHeight + maxCols := availWidth / minCellWidth + + if maxRows < 1 { + maxRows = 1 + } + if maxCols < 1 { + maxCols = 1 + } + + // Find optimal grid that fits all items with balanced aspect ratio + // Try to fill screen while keeping cells readable + for rows = 1; rows <= maxRows; rows++ { + cols = (n + rows - 1) / rows // ceiling division + if cols <= maxCols { + // Check if cells would be too wide (prefer more rows for balance) + cellWidth := availWidth / cols + if cellWidth > 80 && rows < maxRows && rows*2 >= n { + continue // Try more rows for better balance + } + return rows, cols + } } - // 2 rows, ceil(n/2) columns - return (n + 1) / 2 + + // Fallback: use max rows + return maxRows, (n + maxRows - 1) / maxRows } // View renders the grid. @@ -463,11 +855,12 @@ func (m GridModel) View() string { var b strings.Builder pendingBranches := m.filteredPendingBranches() - totalBranches := len(m.branches) + len(pendingBranches) + tasks := m.filteredTasks() + totalItems := len(tasks) + len(pendingBranches) // Handle input modes if m.inputMode == GridInputNewBranch { - b.WriteString(titleStyle.Render("NEW BRANCH")) + b.WriteString(titleStyle.Render("NEW TASK")) b.WriteString("\n\n") b.WriteString(selectedStyle.Render("Name: ")) b.WriteString(m.inputText) @@ -477,31 +870,32 @@ func (m GridModel) View() string { } if m.inputMode == GridInputConfirmDelete { - b.WriteString(titleStyle.Render("DELETE BRANCH")) + b.WriteString(titleStyle.Render("DELETE TASK")) b.WriteString("\n\n") - if len(m.branches) > 0 && m.cursor < len(m.branches) { - br := m.branches[m.cursor] - if br.HasChanges() { - b.WriteString(errorStyle.Render(fmt.Sprintf("⚠ '%s' has uncommitted changes!\n", br.Name))) + if len(tasks) > 0 && m.cursor < len(tasks) { + t := tasks[m.cursor] + br := branch.New(t.ID) + if br.Exists() && br.HasChanges() { + b.WriteString(errorStyle.Render(fmt.Sprintf("⚠ '%s' has uncommitted changes!\n", t.ID))) } - b.WriteString(fmt.Sprintf("Delete '%s'? [y/n]", br.Name)) + b.WriteString(fmt.Sprintf("Delete '%s'? [y/n]", t.ID)) } return b.String() } - if totalBranches == 0 { + if totalItems == 0 { b.WriteString(titleStyle.Render("DARK MULTI")) b.WriteString("\n\n") - b.WriteString(stoppedStyle.Render("No branches. Press 'n' to create one.")) + b.WriteString(stoppedStyle.Render("No tasks. Press 'n' to create one or run 'multi queue init'.")) b.WriteString("\n\n") b.WriteString(m.renderStatusBar()) b.WriteString("\n") - b.WriteString(helpStyle.Render("[n]ew [?]help [q]uit")) + b.WriteString(helpStyle.Render("[n]ew [f]ilter [?]help [q]uit")) return b.String() } - // Calculate cell dimensions - cols := m.numCols() + // Calculate grid dimensions + numRows, numCols := m.gridDimensions() width := m.width if width < 40 { width = 120 @@ -512,31 +906,34 @@ func (m GridModel) View() string { } // Reserve 5 lines for newline, status bar, newline, and help/message - cellHeight := (height - 5) / 2 + cellHeight := (height - 5) / numRows + if cellHeight < 6 { + cellHeight = 6 + } - // Build rows (2 rows) + // Build rows dynamically var rows []string - branchIdx := 0 - for row := 0; row < 2; row++ { + itemIdx := 0 + for row := 0; row < numRows; row++ { var cells []string remainingWidth := width - for col := 0; col < cols; col++ { - cellWidth := remainingWidth / (cols - col) + for col := 0; col < numCols; col++ { + cellWidth := remainingWidth / (numCols - col) remainingWidth -= cellWidth - if branchIdx < len(m.branches) { - cells = append(cells, m.renderCell(branchIdx, cellWidth, cellHeight)) - branchIdx++ + if itemIdx < len(tasks) { + cells = append(cells, m.renderTaskCell(tasks[itemIdx], itemIdx, cellWidth, cellHeight)) + itemIdx++ } else { // Render pending branches (filtered to avoid duplicates) - pendingIdx := branchIdx - len(m.branches) + pendingIdx := itemIdx - len(tasks) if pendingIdx < len(pendingBranches) { cells = append(cells, m.renderPendingCell(pendingBranches[pendingIdx], cellWidth, cellHeight)) } else { // Empty cell cells = append(cells, cellBorderStyle.Width(cellWidth-2).Height(cellHeight-2).Render("")) } - branchIdx++ + itemIdx++ } } rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cells...)) @@ -555,7 +952,7 @@ func (m GridModel) View() string { } else if m.message != "" { b.WriteString(m.message) } else { - b.WriteString(helpStyle.Render("[n]ew [x]del [s]tart [k]ill [c]laude [t]erm [e]ditor [l]ogs [d]iff [m]atter [?]help [q]uit")) + b.WriteString(helpStyle.Render("[n]ew [x]del [s]tart [k]ill [c]laude [t]erm [v]iew [i]nfo [p]rompt [f]ilter [?] [q]")) } return b.String() @@ -563,12 +960,10 @@ func (m GridModel) View() string { func (m GridModel) renderStatusBar() string { cpuCores, ramGB := config.GetSystemResources() - running := 0 - for _, br := range m.branches { - if br.IsRunning() { - running++ - } - } + + // Count running from queue tasks + q := queue.Get() + running := q.CountRunning() // Calculate total CPU and RAM usage var totalCPU float64 @@ -606,8 +1001,22 @@ func (m GridModel) renderStatusBar() string { memStr = fmt.Sprintf("%.1fGB", totalMemMB/1024) } - return statusBarStyle.Render(fmt.Sprintf("%d cores, %dGB • %d/%d running (%.0f%% CPU, %s/%.0f%% RAM) • proxy %s", - cpuCores, ramGB, running, maxSuggested, hostCpuPct, memStr, hostMemPct, proxyStatus)) + // Queue stats (q already declared above) + qReady := len(q.GetByStatus(queue.StatusReady)) + qTotal := len(m.queueTasks) + queueInfo := fmt.Sprintf("queue: %d run, %d ready, %d total", running, qReady, qTotal) + + // Filter info + filterInfo := fmt.Sprintf("filter: %s", m.filterDescription()) + + // Processor status + procStatus := "auto" + if !m.processorOn { + procStatus = "manual" + } + + return statusBarStyle.Render(fmt.Sprintf("%d cores, %dGB • %d/%d running (%.0f%% CPU, %s/%.0f%% RAM) • %s • %s • mode: %s • proxy %s", + cpuCores, ramGB, running, maxSuggested, hostCpuPct, memStr, hostMemPct, queueInfo, filterInfo, procStatus, proxyStatus)) } func (m GridModel) renderCell(idx int, width, height int) string { @@ -650,29 +1059,49 @@ func (m GridModel) renderCell(idx int, width, height int) string { } content := helpStyle.Render(pending.Status) + cellContent := header + "\n" + content + // Enforce strict height limit + cellLines := strings.Split(cellContent, "\n") + if len(cellLines) > innerHeight { + cellLines = cellLines[:innerHeight] + cellContent = strings.Join(cellLines, "\n") + } style := cellBorderStyle if selected { style = cellSelectedStyle } - return style.Width(innerWidth).Height(innerHeight).Render(header + "\n" + content) + return style.Width(innerWidth).Height(innerHeight).Render(cellContent) } // Header with status icon and branch name var header string statusIcon := stoppedStyle.Render("○") - if br.IsRunning() { + if m.isRunning(br.Name) { statusIcon = runningStyle.Render("●") } header = statusIcon + " " + cellHeaderStyle.Render(br.Name) - // Add git stats (commits ahead, lines changed) - commits, added, removed := br.GitStats() - if commits > 0 || added > 0 || removed > 0 { - header += helpStyle.Render(fmt.Sprintf(", git: %dc +%d/-%d", commits, added, removed)) + // Add git stats (commits ahead, lines changed) - use cached values + if gs, ok := m.gitStats[br.Name]; ok && gs != nil { + if gs.Commits > 0 || gs.Added > 0 || gs.Removed > 0 { + header += helpStyle.Render(fmt.Sprintf(", git: %dc +%d/-%d", gs.Commits, gs.Added, gs.Removed)) + } + } + + // Add task status if task exists + if ti, ok := m.taskInfo[br.Name]; ok && ti != nil && ti.Phase != task.PhaseNone { + taskStatus := ti.Phase.Icon() + " " + ti.Phase.Display() + if ti.StatusLine != "" { + taskStatus += " " + ti.StatusLine + } + if ti.Summary != "" { + taskStatus += ": " + ti.Summary + } + header += "\n" + helpStyle.Render(taskStatus) } // Add CPU/RAM stats if running - if stats, ok := m.containerStats[br.Name]; ok && br.IsRunning() { + if stats, ok := m.containerStats[br.Name]; ok && m.isRunning(br.Name) { cpuCores, ramGB := config.GetSystemResources() // Convert CPU percentage to % of total host CPU var cpuPct float64 @@ -698,7 +1127,7 @@ func (m GridModel) renderCell(idx int, width, height int) string { // Content var content string - if br.IsRunning() { + if m.isRunning(br.Name) { if !tmux.BranchSessionExists(br.Name) { content = stoppedStyle.Render("[ready - press 'c' for Claude]") } else if pane, ok := m.paneContent[br.Name]; ok && pane != "" { @@ -722,6 +1151,13 @@ func (m GridModel) renderCell(idx int, width, height int) string { cellContent := header + "\n" + content + // Enforce strict height limit - truncate to innerHeight lines + cellLines := strings.Split(cellContent, "\n") + if len(cellLines) > innerHeight { + cellLines = cellLines[:innerHeight] + cellContent = strings.Join(cellLines, "\n") + } + style := cellBorderStyle if selected { style = cellSelectedStyle @@ -730,6 +1166,212 @@ func (m GridModel) renderCell(idx int, width, height int) string { return style.Width(innerWidth).Height(innerHeight).Render(cellContent) } +// renderTaskCell renders a queue task cell. +func (m GridModel) renderTaskCell(t *queue.Task, idx int, width, height int) string { + innerWidth := width - 2 + innerHeight := height - 2 + + selected := idx == m.cursor + + // Check if this task has a pending operation + if pending, ok := globalPendingBranches[t.ID]; ok { + header := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("◐") + " " + cellHeaderStyle.Render(t.ID) + + // Show CPU/RAM stats if container is already running + if stats, ok := m.containerStats[t.ID]; ok { + cpuCores, ramGB := config.GetSystemResources() + var cpuPct float64 + fmt.Sscanf(strings.TrimSuffix(stats.CPU, "%"), "%f", &cpuPct) + hostCpuPct := cpuPct / float64(cpuCores) + mem := stats.Memory + var memMB float64 + if strings.HasSuffix(mem, "GiB") { + var v float64 + fmt.Sscanf(strings.TrimSuffix(mem, "GiB"), "%f", &v) + memMB = v * 1024 + } else if strings.HasSuffix(mem, "MiB") { + fmt.Sscanf(strings.TrimSuffix(mem, "MiB"), "%f", &memMB) + } + memPct := memMB / (float64(ramGB) * 1024) * 100 + memStr := fmt.Sprintf("%.0fMB", memMB) + if memMB >= 1024 { + memStr = fmt.Sprintf("%.1fGB", memMB/1024) + } + header += helpStyle.Render(fmt.Sprintf(", CPU: %.0f%%, RAM: %s/%.0f%%", hostCpuPct, memStr, memPct)) + } + + content := helpStyle.Render(pending.Status) + cellContent := header + "\n" + content + // Enforce strict height limit + cellLines := strings.Split(cellContent, "\n") + if len(cellLines) > innerHeight { + cellLines = cellLines[:innerHeight] + cellContent = strings.Join(cellLines, "\n") + } + style := cellStyleForStatus(t.Status, selected) + return style.Width(innerWidth).Height(innerHeight).Render(cellContent) + } + + // Header with status icon and task name + statusIcon := t.Status.Icon() + header := statusIcon + " " + cellHeaderStyle.Render(t.ID) + + // Check if branch exists and is running + b := branch.New(t.ID) + branchRunning := b.Exists() && m.isRunning(t.ID) + + // Add git stats if branch exists + if gs, ok := m.gitStats[t.ID]; ok && gs != nil { + if gs.Commits > 0 || gs.Added > 0 || gs.Removed > 0 { + header += helpStyle.Render(fmt.Sprintf(", git: %dc +%d/-%d", gs.Commits, gs.Added, gs.Removed)) + } + } + + // Add task phase info if available + if ti, ok := m.taskInfo[t.ID]; ok && ti != nil && ti.Phase != task.PhaseNone { + taskStatus := ti.Phase.Icon() + " " + ti.Phase.Display() + if ti.StatusLine != "" { + taskStatus += " " + ti.StatusLine + } + if ti.Summary != "" { + taskStatus += ": " + ti.Summary + } + header += "\n" + helpStyle.Render(taskStatus) + } else { + // Show queue status + header += "\n" + helpStyle.Render(t.Status.Display()) + } + + // Add CPU/RAM stats if running + if stats, ok := m.containerStats[t.ID]; ok && branchRunning { + cpuCores, ramGB := config.GetSystemResources() + var cpuPct float64 + fmt.Sscanf(strings.TrimSuffix(stats.CPU, "%"), "%f", &cpuPct) + hostCpuPct := cpuPct / float64(cpuCores) + mem := stats.Memory + var memMB float64 + if strings.HasSuffix(mem, "GiB") { + var v float64 + fmt.Sscanf(strings.TrimSuffix(mem, "GiB"), "%f", &v) + memMB = v * 1024 + } else if strings.HasSuffix(mem, "MiB") { + fmt.Sscanf(strings.TrimSuffix(mem, "MiB"), "%f", &memMB) + } + memPct := memMB / (float64(ramGB) * 1024) * 100 + memStr := fmt.Sprintf("%.0fMB", memMB) + if memMB >= 1024 { + memStr = fmt.Sprintf("%.1fGB", memMB/1024) + } + header += helpStyle.Render(fmt.Sprintf(", CPU: %.0f%%, RAM: %s/%.0f%%", hostCpuPct, memStr, memPct)) + } + + // Content + var content string + if branchRunning { + if !tmux.BranchSessionExists(t.ID) { + content = stoppedStyle.Render("[ready - press 'c' for Claude]") + } else if pane, ok := m.paneContent[t.ID]; ok && pane != "" { + // Clean up Claude branding and OAuth noise + cleanedPane := cleanPaneContent(pane) + if cleanedPane == "" { + content = stoppedStyle.Render("[Claude starting...]") + } else { + lines := strings.Split(cleanedPane, "\n") + maxLines := innerHeight - 2 + if len(lines) > maxLines { + lines = lines[len(lines)-maxLines:] + } + for i, line := range lines { + if len(line) > innerWidth { + lines[i] = line[:innerWidth-1] + "…" + } + } + content = strings.Join(lines, "\n") + } + } else { + content = stoppedStyle.Render("[Claude session active]") + } + } else { + // Show task prompt preview for non-running tasks + if t.Prompt != "" { + preview := t.Prompt + if len(preview) > innerWidth*2 { + preview = preview[:innerWidth*2] + "..." + } + // Wrap to multiple lines + lines := wrapText(preview, innerWidth) + maxLines := innerHeight - 2 + if len(lines) > maxLines { + lines = lines[:maxLines] + } + content = cellStoppedStyle.Render(strings.Join(lines, "\n")) + } else { + content = cellStoppedStyle.Render("[no prompt - press 'p' to add]") + } + } + + cellContent := header + "\n" + content + + // Enforce strict height limit - truncate to innerHeight lines + cellLines := strings.Split(cellContent, "\n") + if len(cellLines) > innerHeight { + cellLines = cellLines[:innerHeight] + cellContent = strings.Join(cellLines, "\n") + } + + style := cellStyleForStatus(t.Status, selected) + + return style.Width(innerWidth).Height(innerHeight).Render(cellContent) +} + +// cleanPaneContent filters out Claude branding, OAuth URLs, and other noise from tmux output. +func cleanPaneContent(content string) string { + lines := strings.Split(content, "\n") + var cleaned []string + + for _, line := range lines { + // Skip Claude ASCII art and branding + if strings.Contains(line, "╭") || strings.Contains(line, "╰") || + strings.Contains(line, "│") && (strings.Contains(line, "░") || strings.Contains(line, "▓")) { + continue + } + // Skip OAuth URLs + if strings.Contains(line, "platform.claude.com/oauth") || + strings.Contains(line, "https://") && strings.Contains(line, "auth") { + continue + } + // Skip login prompts + if strings.Contains(line, "Browser didn't open") || + strings.Contains(line, "Paste code here") || + strings.Contains(line, "Please run /login") { + continue + } + // Skip empty lines at start + if len(cleaned) == 0 && strings.TrimSpace(line) == "" { + continue + } + cleaned = append(cleaned, line) + } + + return strings.Join(cleaned, "\n") +} + +// wrapText wraps text to fit within a given width. +func wrapText(text string, width int) []string { + if width <= 0 { + return []string{text} + } + var lines []string + for len(text) > width { + lines = append(lines, text[:width]) + text = text[width:] + } + if len(text) > 0 { + lines = append(lines, text) + } + return lines +} + func (m GridModel) renderPendingCell(pb *PendingBranch, width, height int) string { innerWidth := width - 2 innerHeight := height - 2 @@ -760,8 +1402,16 @@ func (m GridModel) renderPendingCell(pb *PendingBranch, width, height int) strin } content := helpStyle.Render(pb.Status) + cellContent := header + "\n" + content + + // Enforce strict height limit + cellLines := strings.Split(cellContent, "\n") + if len(cellLines) > innerHeight { + cellLines = cellLines[:innerHeight] + cellContent = strings.Join(cellLines, "\n") + } - return cellBorderStyle.Width(innerWidth).Height(innerHeight).Render(header + "\n" + content) + return cellBorderStyle.Width(innerWidth).Height(innerHeight).Render(cellContent) } // Commands @@ -820,3 +1470,58 @@ func (m GridModel) removeBranch(b *branch.Branch) tea.Cmd { return operationDoneMsg{fmt.Sprintf("Removed %s", b.Name)} } } + +func (m GridModel) removeTask(t *queue.Task) tea.Cmd { + return func() tea.Msg { + // Remove branch if it exists + b := branch.New(t.ID) + if b.Exists() { + if err := removeBranchFull(b); err != nil { + return operationErrMsg{err} + } + } + // Remove from queue + q := queue.Get() + q.Remove(t.ID) + q.Save() + return operationDoneMsg{fmt.Sprintf("Removed %s", t.ID)} + } +} + +// findEditor returns the user's preferred editor. +func findEditor() string { + // Try micro first (simple, works well in terminals) + if _, err := exec.LookPath("micro"); err == nil { + return "micro" + } + // Try nano + if _, err := exec.LookPath("nano"); err == nil { + return "nano" + } + // Try vim + if _, err := exec.LookPath("vim"); err == nil { + return "vim" + } + // Fall back to vi + return "vi" +} + +// isTemplateOnly checks if content is still just the template. +func isTemplateOnly(content string) bool { + return strings.Contains(content, "[What should this accomplish?]") && + strings.Contains(content, "[Relevant background") +} + +// writeFile writes content to a file. +func writeFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0644) +} + +// readFile reads content from a file. +func readFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/tui/help.go b/tui/help.go index 15accb4..ffec028 100644 --- a/tui/help.go +++ b/tui/help.go @@ -70,6 +70,24 @@ func (m HelpModel) View() string { b.WriteString(" l View logs\n") b.WriteString("\n") + b.WriteString(sectionStyle.Render("Task Queue")) + b.WriteString("\n") + b.WriteString(" p Edit pre-prompt (task definition)\n") + b.WriteString(" f Cycle filter (running/ready/all)\n") + b.WriteString("\n") + b.WriteString(" Tasks auto-start from queue when slots available.\n") + b.WriteString(" Queue managed via: multi queue init/ls/status\n") + b.WriteString("\n") + + b.WriteString(sectionStyle.Render("Queue Status")) + b.WriteString("\n") + b.WriteString(" 📝 needs-prompt Waiting for prompt to be written\n") + b.WriteString(" ⏳ ready Has prompt, waiting for slot\n") + b.WriteString(" 🔄 running Container active, Claude working\n") + b.WriteString(" ⏸️ waiting Stuck or needs human input\n") + b.WriteString(" ✅ done Task complete\n") + b.WriteString("\n") + b.WriteString(sectionStyle.Render("Grid View")) b.WriteString("\n") b.WriteString(" arrows Navigate branches\n")