From 51005a289ae9e8fce5b3e32e2812afc419dff6ba Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Sun, 18 Jan 2026 20:19:17 -0500 Subject: [PATCH 01/14] Cache git stats and running state in grid for faster rendering Previously, GitStats() and IsRunning() were called synchronously during every render cycle, running shell commands (git, docker ps) multiple times per frame. Now these are loaded asynchronously and cached, making arrow key navigation instant. --- tui/grid.go | 63 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/tui/grid.go b/tui/grid.go index ac2a7b1..b48b293 100644 --- a/tui/grid.go +++ b/tui/grid.go @@ -55,6 +55,8 @@ type GridModel struct { branches []*branch.Branch 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 cursor int width int height int @@ -69,6 +71,7 @@ type GridModel struct { // Grid layout messages type paneContentMsg map[string]string type containerStatsMsg map[string]ContainerStats +type runningStateMsg map[string]bool type gridTickMsg time.Time // NewGridModel creates a new grid view. @@ -77,6 +80,8 @@ func NewGridModel() GridModel { branches: branch.GetManagedBranches(), paneContent: make(map[string]string), containerStats: make(map[string]ContainerStats), + gitStats: make(map[string]*GitStatsInfo), + runningState: make(map[string]bool), } } @@ -85,11 +90,34 @@ func (m GridModel) Init() tea.Cmd { return tea.Batch( m.loadPaneContent, loadContainerStats, + m.loadGridGitStats, + m.loadRunningState, checkProxyStatus, gridTickCmd(), ) } +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) @@ -314,6 +342,14 @@ 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 proxyStatusMsg: m.proxyRunning = bool(msg) return m, nil @@ -325,7 +361,7 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor = len(m.branches) - 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, gridTickCmd()) case createStepMsg: if pending, ok := globalPendingBranches[msg.name]; ok { @@ -448,6 +484,14 @@ func (m GridModel) filteredPendingBranches() []*PendingBranch { return result } +// 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 +} + func (m GridModel) numCols() int { pending := m.filteredPendingBranches() n := len(m.branches) + len(pending) @@ -565,7 +609,7 @@ func (m GridModel) renderStatusBar() string { cpuCores, ramGB := config.GetSystemResources() running := 0 for _, br := range m.branches { - if br.IsRunning() { + if m.isRunning(br.Name) { running++ } } @@ -660,19 +704,20 @@ func (m GridModel) renderCell(idx int, width, height int) string { // 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 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 +743,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 != "" { From 06c360c69ffbb06e0cbf0942d84068f854ea00b1 Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 17:43:43 -0500 Subject: [PATCH 02/14] add task orchestration: ralph loop, phase tracking, AI summaries - task package: phase tracking (planning/ready/executing/done), prompt injection - ralph loop: auto-restart claude until todos complete - tmux: pipe-pane logging, StartRalphLoop function - summary: haiku-powered summaries of claude activity, fallback to log parsing - grid: task status display, 'p' for pre-prompt, 'r' for ralph - git stats: fallback to local main when origin/main missing --- branch/branch.go | 66 +++++--- next-prompt.md | 218 ++++++++++++++++++++++++++ scripts/claude-loop.sh | 152 +++++++++++++++++++ summary/summary.go | 337 +++++++++++++++++++++++++++++++++++++++++ task/prompts.go | 174 +++++++++++++++++++++ task/task.go | 298 ++++++++++++++++++++++++++++++++++++ tmux/tmux.go | 39 +++++ tui/grid.go | 167 +++++++++++++++++++- tui/help.go | 17 +++ 9 files changed, 1447 insertions(+), 21 deletions(-) create mode 100644 next-prompt.md create mode 100755 scripts/claude-loop.sh create mode 100644 summary/summary.go create mode 100644 task/prompts.go create mode 100644 task/task.go 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/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/scripts/claude-loop.sh b/scripts/claude-loop.sh new file mode 100755 index 0000000..4c717af --- /dev/null +++ b/scripts/claude-loop.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# claude-loop.sh - Ralph Wiggum style loop for Claude +# Runs Claude repeatedly until completion or max iterations + +set -e + +TASK_DIR="/home/dark/app/.claude-task" +PHASE_FILE="$TASK_DIR/phase" +STATUS_FILE="$TASK_DIR/status.md" +MAX_ITERATIONS=${MAX_ITERATIONS:-100} +ITERATION=0 + +# 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" +} + +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" + echo "" >> "$STATUS_FILE" + echo "## Recent Activity" >> "$STATUS_FILE" + echo "" >> "$STATUS_FILE" +} + +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 + + # 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" + return 1 + fi + + log "Claude exited with code $exit_code" + + # 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 +} + +main() { + log "Starting Claude loop (max $MAX_ITERATIONS iterations)" + + while [ $ITERATION -lt $MAX_ITERATIONS ]; do + ITERATION=$((ITERATION + 1)) + + 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 + + if ! run_claude; then + # Claude exited, check if we should continue + current_phase=$(cat "$PHASE_FILE" 2>/dev/null || echo "unknown") + + if [ "$current_phase" = "executing" ]; then + log "Claude exited during executing phase, restarting in 2 seconds..." + sleep 2 + else + log "Loop complete (phase: $current_phase)" + break + fi + else + # Phase transition occurred + break + 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" +} + +# 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..e11d1c8 --- /dev/null +++ b/task/prompts.go @@ -0,0 +1,174 @@ +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, then press 'r' in multi to start the Ralph loop.\"\n\n" + + "## What happens next\n\n" + + "After user approves, the Ralph 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\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 user to:\n" + + "1. Review `.claude-task/todos.md`\n" + + "2. Give feedback or approve\n" + + "3. Press 'r' in multi to start the Ralph loop\n\n" + + "You can discuss the plan with the user now.\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 (Ralph Loop)\n\n" + + "You are in a Ralph Wiggum 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" + + "## Commits\n\n" + + "Commit early and often as you make progress:\n" + + "- Short casual commit messages (e.g., \"add user auth\", \"fix login bug\")\n" + + "- No attribution/co-author needed\n" + + "- Commit after completing each logical chunk of work\n" + + "- Don't wait until the end to commit everything\n\n" + + "## When Done\n\n" + + "When ALL todos are complete and tests pass:\n" + + "- Make a final commit if there are uncommitted changes\n" + + "- Write \"done\" to .claude-task/phase\n" + + "- 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..486a2f2 --- /dev/null +++ b/task/task.go @@ -0,0 +1,298 @@ +// Package task provides task orchestration for AI-driven development. +package task + +import ( + "fmt" + "os" + "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 +) + +// 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" + 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 "✅" + 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() +} diff --git a/tmux/tmux.go b/tmux/tmux.go index 0fa6e5c..a42cce9 100644 --- a/tmux/tmux.go +++ b/tmux/tmux.go @@ -101,6 +101,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 +224,43 @@ func detectTerminal() string { return "xterm" } +// StartRalphLoop starts the Ralph loop in the Claude session. +// Kills any existing session and starts fresh. +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 + // The log file will be inside the container at /home/dark/app/.claude-task/output.log + exec.Command("tmux", "pipe-pane", "-t", session, "-o", "cat >> /tmp/claude-output-"+branchName+".log").Run() + + // Start bash in container, then run ralph + dockerBash := fmt.Sprintf("docker exec -it -w /home/dark/app %s bash", 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 openInTerminal(session) +} + +// 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). diff --git a/tui/grid.go b/tui/grid.go index b48b293..e4b00cb 100644 --- a/tui/grid.go +++ b/tui/grid.go @@ -11,6 +11,8 @@ import ( "github.com/darklang/dark-multi/branch" "github.com/darklang/dark-multi/config" + "github.com/darklang/dark-multi/summary" + "github.com/darklang/dark-multi/task" "github.com/darklang/dark-multi/tmux" ) @@ -50,6 +52,13 @@ 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 @@ -57,6 +66,7 @@ type GridModel struct { 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 @@ -72,6 +82,7 @@ type GridModel struct { type paneContentMsg map[string]string type containerStatsMsg map[string]ContainerStats type runningStateMsg map[string]bool +type taskInfoMsg map[string]*TaskInfo type gridTickMsg time.Time // NewGridModel creates a new grid view. @@ -82,6 +93,7 @@ func NewGridModel() GridModel { containerStats: make(map[string]ContainerStats), gitStats: make(map[string]*GitStatsInfo), runningState: make(map[string]bool), + taskInfo: make(map[string]*TaskInfo), } } @@ -92,11 +104,30 @@ func (m GridModel) Init() tea.Cmd { loadContainerStats, m.loadGridGitStats, m.loadRunningState, + m.loadTaskInfo, checkProxyStatus, gridTickCmd(), ) } +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 { @@ -239,6 +270,17 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.branches) > 0 && m.cursor < len(m.branches) { b := m.branches[m.cursor] if 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) @@ -326,6 +368,45 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return logs, logs.Init() } + case "p": + // Edit pre-prompt (task definition) + if len(m.branches) > 0 && m.cursor < len(m.branches) { + b := m.branches[m.cursor] + t := task.New(b.Name, b.Path) + + // Create pre-prompt file with template if it doesn't exist + if !t.Exists() { + template := task.PrePromptTemplate(b.Name) + if err := t.SetPrePrompt(template); err != nil { + m.message = fmt.Sprintf("Error: %v", err) + return m, nil + } + } + + // Use tea.ExecProcess to run editor (hands over terminal properly) + editor := findEditor() + c := exec.Command(editor, t.PrePromptPath()) + return m, tea.ExecProcess(c, func(err error) tea.Msg { + if err != nil { + return operationErrMsg{err} + } + // After editor closes, check content and set phase + content := t.PrePrompt() + if content != "" && !isTemplateOnly(content) { + t.SetPhase(task.PhasePlanning) + return operationDoneMsg{fmt.Sprintf("Task set for %s - press 'c' to start", b.Name)} + } + return operationDoneMsg{"Pre-prompt saved"} + }) + } + + case "r": + // Resume/restart loop + if len(m.branches) > 0 && m.cursor < len(m.branches) { + b := m.branches[m.cursor] + return m, m.resumeTask(b) + } + case "?": return NewHelpModel(), nil } @@ -350,6 +431,10 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.runningState = msg return m, nil + case taskInfoMsg: + m.taskInfo = msg + return m, nil + case proxyStatusMsg: m.proxyRunning = bool(msg) return m, nil @@ -361,7 +446,7 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor = len(m.branches) - 1 } // Note: Don't clean up globalPendingBranches here - let branchStartedMsg handle it - return m, tea.Batch(m.loadPaneContent, loadContainerStats, m.loadGridGitStats, m.loadRunningState, gridTickCmd()) + return m, tea.Batch(m.loadPaneContent, loadContainerStats, m.loadGridGitStats, m.loadRunningState, m.loadTaskInfo, gridTickCmd()) case createStepMsg: if pending, ok := globalPendingBranches[msg.name]; ok { @@ -599,7 +684,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 [p]rompt [r]alph [?] [q]")) } return b.String() @@ -716,6 +801,18 @@ func (m GridModel) renderCell(idx int, width, height int) string { } } + // 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 && m.isRunning(br.Name) { cpuCores, ramGB := config.GetSystemResources() @@ -865,3 +962,69 @@ func (m GridModel) removeBranch(b *branch.Branch) tea.Cmd { return operationDoneMsg{fmt.Sprintf("Removed %s", b.Name)} } } + +// Task-related commands + +func (m GridModel) resumeTask(b *branch.Branch) tea.Cmd { + return func() tea.Msg { + if !b.IsRunning() { + return operationErrMsg{fmt.Errorf("%s is stopped - press 's' to start first", b.Name)} + } + + t := task.New(b.Name, b.Path) + phase := t.Phase() + + if phase == task.PhaseNone { + return operationErrMsg{fmt.Errorf("no task defined - press 'p' to set pre-prompt")} + } + + if phase == task.PhaseDone { + return operationDoneMsg{"Task already complete! Push your changes."} + } + + // Set phase to executing and copy ralph script + t.SetPhase(task.PhaseExecuting) + if err := t.CopyLoopScript(); err != nil { + return operationErrMsg{fmt.Errorf("failed to copy loop script: %w", err)} + } + if err := t.InjectTaskContext(); err != nil { + return operationErrMsg{fmt.Errorf("failed to inject context: %w", err)} + } + + // Get container ID and start the Ralph loop + containerID, err := b.ContainerID() + if err != nil { + return operationErrMsg{fmt.Errorf("failed to get container ID: %w", err)} + } + + if err := tmux.StartRalphLoop(b.Name, containerID); err != nil { + return operationErrMsg{fmt.Errorf("failed to start Ralph loop: %w", err)} + } + + return operationDoneMsg{fmt.Sprintf("Ralph loop started for %s", b.Name)} + } +} + +// 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") +} diff --git a/tui/help.go b/tui/help.go index 15accb4..2da6491 100644 --- a/tui/help.go +++ b/tui/help.go @@ -70,6 +70,23 @@ func (m HelpModel) View() string { b.WriteString(" l View logs\n") b.WriteString("\n") + b.WriteString(sectionStyle.Render("Task Management")) + b.WriteString("\n") + b.WriteString(" p Edit pre-prompt (task definition)\n") + b.WriteString(" r Start Ralph loop (autonomous execution)\n") + b.WriteString("\n") + b.WriteString(" Flow: p → c (plan with Claude) → r (start loop)\n") + b.WriteString("\n") + + b.WriteString(sectionStyle.Render("Task Phases")) + b.WriteString("\n") + b.WriteString(" 📝 no task No pre-prompt defined\n") + b.WriteString(" 🔍 planning Claude creating plan & todos\n") + b.WriteString(" ✋ ready Plan complete, waiting for you\n") + b.WriteString(" ⚡ executing Ralph loop running\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") From fcb00d51f2231caae8c35ce700f516c718db07c1 Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 17:48:26 -0500 Subject: [PATCH 03/14] add task queue system with CLI commands - queue package: task queue with status workflow, priority sorting - queue init: populates queue with 23 tasks (existing branches + next-prompt.md tasks) - CLI commands: multi queue init/ls/status/add - max 10 concurrent containers --- cli/commands.go | 84 +++++++++++++ queue/init.go | 320 ++++++++++++++++++++++++++++++++++++++++++++++++ queue/queue.go | 294 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 698 insertions(+) create mode 100644 queue/init.go create mode 100644 queue/queue.go diff --git a/cli/commands.go b/cli/commands.go index cbec1f9..f72031b 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,85 @@ 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`, + 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)) + + fmt.Printf("Queue Status:\n") + fmt.Printf(" 🔄 Running: %d / %d max\n", running, queue.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) + + default: + fmt.Fprintf(os.Stderr, "Unknown action: %s\nUse: init, ls, status, add\n", action) + os.Exit(1) + } + }, + } + + return cmd +} 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/queue.go b/queue/queue.go new file mode 100644 index 0000000..7ad4337 --- /dev/null +++ b/queue/queue.go @@ -0,0 +1,294 @@ +// 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 is the maximum number of containers to run at once. +const MaxConcurrent = 10 + +// 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 +} + +// GetAll returns all tasks sorted by 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 { + if result[i].Priority != result[j].Priority { + return result[i].Priority < result[j].Priority + } + 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) + } +} From e947d0ef969770dbc7bf206379a9fb8125ce0ccf Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 18:00:20 -0500 Subject: [PATCH 04/14] pass ANTHROPIC_API_KEY to containers for claude auth - dockerExecWithEnv helper passes API key if set - all docker exec calls updated to use this - enables automated claude execution without manual login --- tmux/tmux.go | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tmux/tmux.go b/tmux/tmux.go index a42cce9..099890c 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,23 @@ 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 := os.Getenv("ANTHROPIC_API_KEY") + if apiKey != "" { + 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 { @@ -248,8 +257,8 @@ func StartRalphLoop(branchName, containerID string) error { // The log file will be inside the container at /home/dark/app/.claude-task/output.log exec.Command("tmux", "pipe-pane", "-t", session, "-o", "cat >> /tmp/claude-output-"+branchName+".log").Run() - // Start bash in container, then run ralph - dockerBash := fmt.Sprintf("docker exec -it -w /home/dark/app %s bash", containerID) + // 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() @@ -283,7 +292,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 From dd7f1e73dad2972b85197c2a277fb9fdf4627f66 Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 18:02:13 -0500 Subject: [PATCH 05/14] add queue processor for automatic task execution - processor.go: background worker that starts ready tasks - syncs queue status with task phase files - CLI commands: queue start (daemon), queue process (one-shot) - creates branches, starts containers, runs ralph loop automatically --- cli/commands.go | 22 +++++- queue/processor.go | 192 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 queue/processor.go diff --git a/cli/commands.go b/cli/commands.go index f72031b..7f0783b 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -318,7 +318,9 @@ 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`, + 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] @@ -380,8 +382,24 @@ Actions: 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\n", action) + fmt.Fprintf(os.Stderr, "Unknown action: %s\nUse: init, ls, status, add, start, process\n", action) os.Exit(1) } }, diff --git a/queue/processor.go b/queue/processor.go new file mode 100644 index 0000000..3afdb5e --- /dev/null +++ b/queue/processor.go @@ -0,0 +1,192 @@ +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() + + // First, sync task phases with queue status + syncTaskPhases(q) + + // Check if we have capacity + running := q.CountRunning() + if running >= MaxConcurrent { + return + } + + // Get next ready task + task := q.NextReady() + if task == nil { + return + } + + // Start the task + if err := startTask(task); err != nil { + q.SetError(task.ID, err.Error()) + q.Save() + return + } + + 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) + case task.PhaseNone: + // Task was reset or deleted + if t.Prompt != "" { + q.UpdateStatus(t.ID, StatusReady) + } else { + q.UpdateStatus(t.ID, StatusNeedsPrompt) + } + } + } + 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 +} From 28355d40ad47d0c00c193d7f2d0d137b62ed0f33 Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 18:06:35 -0500 Subject: [PATCH 06/14] integrate queue with TUI: status bar, filtering, help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add queue stats to status bar (running/ready/total tasks) - Add filter cycling with 'f' key (running → running+ready → active → all) - Show current filter in status bar - Load queue tasks on init and periodic refresh - Start queue processor automatically when TUI launches Co-Authored-By: Claude Opus 4.5 --- tui/grid.go | 111 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 6 deletions(-) diff --git a/tui/grid.go b/tui/grid.go index e4b00cb..a7cfb76 100644 --- a/tui/grid.go +++ b/tui/grid.go @@ -11,6 +11,7 @@ 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" @@ -62,6 +63,7 @@ type TaskInfo struct { // 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 @@ -76,6 +78,8 @@ 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 @@ -83,17 +87,27 @@ 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() + + // Default filter: show running tasks + defaultFilter := []queue.Status{queue.StatusRunning} + 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, } } @@ -105,11 +119,16 @@ func (m GridModel) Init() tea.Cmd { 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 { @@ -407,6 +426,12 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.resumeTask(b) } + case "f": + // Cycle through filter options + m.statusFilter = m.nextFilter() + m.cursor = 0 + return m, nil + case "?": return NewHelpModel(), nil } @@ -439,14 +464,19 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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() + if m.cursor >= len(m.filteredTasks()) && len(m.filteredTasks()) > 0 { + m.cursor = len(m.filteredTasks()) - 1 } // Note: Don't clean up globalPendingBranches here - let branchStartedMsg handle it - return m, tea.Batch(m.loadPaneContent, loadContainerStats, m.loadGridGitStats, m.loadRunningState, m.loadTaskInfo, 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 { @@ -551,6 +581,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 @@ -684,7 +773,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 [p]rompt [r]alph [?] [q]")) + b.WriteString(helpStyle.Render("[n]ew [x]del [s]tart [k]ill [c]laude [t]erm [p]rompt [r]alph [f]ilter [?] [q]")) } return b.String() @@ -735,8 +824,18 @@ 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 := queue.Get() + qRunning := q.CountRunning() + qReady := len(q.GetByStatus(queue.StatusReady)) + qTotal := len(m.queueTasks) + queueInfo := fmt.Sprintf("queue: %d run, %d ready, %d total", qRunning, qReady, qTotal) + + // Filter info + filterInfo := fmt.Sprintf("filter: %s", m.filterDescription()) + + return statusBarStyle.Render(fmt.Sprintf("%d cores, %dGB • %d/%d running (%.0f%% CPU, %s/%.0f%% RAM) • %s • %s • proxy %s", + cpuCores, ramGB, running, maxSuggested, hostCpuPct, memStr, hostMemPct, queueInfo, filterInfo, proxyStatus)) } func (m GridModel) renderCell(idx int, width, height int) string { From 968436761e4f72f83f39ad196e487817d5bc4b7b Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 18:09:18 -0500 Subject: [PATCH 07/14] remove manual ralph key, update help and prompts for queue automation - Remove 'r' key from TUI (queue auto-starts tasks) - Update help.go with queue-focused documentation - Update prompts.go to remove manual 'r' press references - Terminology: "automated execution loop" instead of "Ralph loop" The queue processor now handles all task execution automatically. --- task/prompts.go | 17 ++++++++--------- tui/grid.go | 51 +------------------------------------------------ tui/help.go | 19 +++++++++--------- 3 files changed, 19 insertions(+), 68 deletions(-) diff --git a/task/prompts.go b/task/prompts.go index e11d1c8..2f7555b 100644 --- a/task/prompts.go +++ b/task/prompts.go @@ -60,9 +60,9 @@ func (t *Task) planningContext() string { " 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, then press 'r' in multi to start the Ralph loop.\"\n\n" + + "4. Tell the user: \"Planning complete. Review .claude-task/todos.md. Execution will start automatically.\"\n\n" + "## What happens next\n\n" + - "After user approves, the Ralph loop will:\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" + @@ -93,11 +93,10 @@ func (t *Task) readyContext() string { "## Current Todos\n\n" + todosContent + "\n\n" + "## Status\n\n" + - "Waiting for user to:\n" + - "1. Review `.claude-task/todos.md`\n" + - "2. Give feedback or approve\n" + - "3. Press 'r' in multi to start the Ralph loop\n\n" + - "You can discuss the plan with the user now.\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" } @@ -112,8 +111,8 @@ func (t *Task) executingContext() string { } return "\n" + - "# Active Task - Executing Phase (Ralph Loop)\n\n" + - "You are in a Ralph Wiggum loop. Work through the todos systematically.\n\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" + diff --git a/tui/grid.go b/tui/grid.go index a7cfb76..45a1566 100644 --- a/tui/grid.go +++ b/tui/grid.go @@ -419,13 +419,6 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) } - case "r": - // Resume/restart loop - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - return m, m.resumeTask(b) - } - case "f": // Cycle through filter options m.statusFilter = m.nextFilter() @@ -773,7 +766,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 [p]rompt [r]alph [f]ilter [?] [q]")) + b.WriteString(helpStyle.Render("[n]ew [x]del [s]tart [k]ill [c]laude [t]erm [p]rompt [f]ilter [?] [q]")) } return b.String() @@ -1062,48 +1055,6 @@ func (m GridModel) removeBranch(b *branch.Branch) tea.Cmd { } } -// Task-related commands - -func (m GridModel) resumeTask(b *branch.Branch) tea.Cmd { - return func() tea.Msg { - if !b.IsRunning() { - return operationErrMsg{fmt.Errorf("%s is stopped - press 's' to start first", b.Name)} - } - - t := task.New(b.Name, b.Path) - phase := t.Phase() - - if phase == task.PhaseNone { - return operationErrMsg{fmt.Errorf("no task defined - press 'p' to set pre-prompt")} - } - - if phase == task.PhaseDone { - return operationDoneMsg{"Task already complete! Push your changes."} - } - - // Set phase to executing and copy ralph script - t.SetPhase(task.PhaseExecuting) - if err := t.CopyLoopScript(); err != nil { - return operationErrMsg{fmt.Errorf("failed to copy loop script: %w", err)} - } - if err := t.InjectTaskContext(); err != nil { - return operationErrMsg{fmt.Errorf("failed to inject context: %w", err)} - } - - // Get container ID and start the Ralph loop - containerID, err := b.ContainerID() - if err != nil { - return operationErrMsg{fmt.Errorf("failed to get container ID: %w", err)} - } - - if err := tmux.StartRalphLoop(b.Name, containerID); err != nil { - return operationErrMsg{fmt.Errorf("failed to start Ralph loop: %w", err)} - } - - return operationDoneMsg{fmt.Sprintf("Ralph loop started for %s", b.Name)} - } -} - // findEditor returns the user's preferred editor. func findEditor() string { // Try micro first (simple, works well in terminals) diff --git a/tui/help.go b/tui/help.go index 2da6491..ffec028 100644 --- a/tui/help.go +++ b/tui/help.go @@ -70,21 +70,22 @@ func (m HelpModel) View() string { b.WriteString(" l View logs\n") b.WriteString("\n") - b.WriteString(sectionStyle.Render("Task Management")) + b.WriteString(sectionStyle.Render("Task Queue")) b.WriteString("\n") b.WriteString(" p Edit pre-prompt (task definition)\n") - b.WriteString(" r Start Ralph loop (autonomous execution)\n") + b.WriteString(" f Cycle filter (running/ready/all)\n") b.WriteString("\n") - b.WriteString(" Flow: p → c (plan with Claude) → r (start loop)\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("Task Phases")) + b.WriteString(sectionStyle.Render("Queue Status")) b.WriteString("\n") - b.WriteString(" 📝 no task No pre-prompt defined\n") - b.WriteString(" 🔍 planning Claude creating plan & todos\n") - b.WriteString(" ✋ ready Plan complete, waiting for you\n") - b.WriteString(" ⚡ executing Ralph loop running\n") - b.WriteString(" ✅ done Task complete\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")) From e84a4a27ba772bee8172dbe48bfe50b3bb97df67 Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 18:16:16 -0500 Subject: [PATCH 08/14] remove claude config mounts, use ANTHROPIC_API_KEY only - Remove ~/.claude and ~/.claude.json mounts from containers - Remove oauth token injection (no longer needed) - Remove ensureClaudeSettings() function - Auth now handled entirely via ANTHROPIC_API_KEY passed through docker exec New containers will not have any claude config mounted. Existing containers still have the mounts but API key takes precedence. --- branch/ops.go | 42 --------------------------------------- container/devcontainer.go | 29 ++++----------------------- 2 files changed, 4 insertions(+), 67 deletions(-) diff --git a/branch/ops.go b/branch/ops.go index 886b16f..133d0f1 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 diff --git a/container/devcontainer.go b/container/devcontainer.go index 09f3a13..3a32c3d 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,7 +224,7 @@ 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") if !strings.Contains(postCreate, "claude-code") { @@ -244,22 +237,8 @@ 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) - } - } + // Note: Auth is handled via ANTHROPIC_API_KEY passed through docker exec -e at runtime + // (see tmux.dockerExecWithEnv). No oauth token or mounted config needed. // Write merged config output, err := json.MarshalIndent(cfg, "", " ") From 55d1cba4bc20f162a36bbd43b39e44e58fe05df3 Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 18:21:25 -0500 Subject: [PATCH 09/14] pre-seed Claude settings in new containers to skip onboarding Adds setup command to create /home/dark/.claude/settings.json with: - theme: dark - hasCompletedOnboarding: true - apiKeySource: env This skips the theme selection and API key prompts when Claude starts. --- container/devcontainer.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/container/devcontainer.go b/container/devcontainer.go index 3a32c3d..5119aa7 100644 --- a/container/devcontainer.go +++ b/container/devcontainer.go @@ -227,6 +227,11 @@ func GenerateOverrideConfig(b BranchInfo) (string, error) { // 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 to skip onboarding prompts + // This creates settings that tell Claude we've already completed setup + claudeSettingsCmd := `mkdir -p /home/dark/.claude && echo '{"theme":"dark","hasCompletedOnboarding":true,"apiKeySource":"env"}' > /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 != "" { From 5fd048adf9efd7da5f83c2adec737cb17c6a8ddd Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 18:23:14 -0500 Subject: [PATCH 10/14] add syncRunningContainers to detect manually started containers Queue now syncs with actual Docker container state on each tick. If a container is running but queue shows ready/needs-prompt, update to running status (if task has a prompt). --- queue/processor.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/queue/processor.go b/queue/processor.go index 3afdb5e..38a7e05 100644 --- a/queue/processor.go +++ b/queue/processor.go @@ -70,7 +70,10 @@ func runProcessor() { func processQueue() { q := Get() - // First, sync task phases with queue status + // Sync with actual container state (handles manually started containers) + syncRunningContainers(q) + + // Sync task phases with queue status syncTaskPhases(q) // Check if we have capacity @@ -118,6 +121,24 @@ func syncTaskPhases(q *Queue) { 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) From 6a737d744c936677e3cd8943267dd687f3e07335 Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 18:42:55 -0500 Subject: [PATCH 11/14] make StartRalphLoop headless - no auto-popup windows Queue processor now starts tasks without opening terminal windows. Use 'c' in TUI to view a running task's Claude session. --- tmux/tmux.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tmux/tmux.go b/tmux/tmux.go index 099890c..a339e96 100644 --- a/tmux/tmux.go +++ b/tmux/tmux.go @@ -233,8 +233,9 @@ func detectTerminal() string { return "xterm" } -// StartRalphLoop starts the Ralph loop in the Claude session. -// Kills any existing session and starts fresh. +// 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") @@ -254,7 +255,6 @@ func StartRalphLoop(branchName, containerID string) error { exec.Command("tmux", "set-option", "-t", session, "-g", "mouse", "on").Run() // Set up pipe-pane to log all output for summarization - // The log file will be inside the container at /home/dark/app/.claude-task/output.log 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 @@ -262,7 +262,7 @@ func StartRalphLoop(branchName, containerID string) error { 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 openInTerminal(session) + return nil // Headless - no terminal window opened } // GetOutputLogPath returns the path to the Claude output log for a branch. From 57bd8b62c1f9bee84339776c0c974eb2df513a3d Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 19:03:51 -0500 Subject: [PATCH 12/14] configurable max concurrent, first-run setup, batch task starts - Start all ready tasks at once up to limit (no 30s wait between) - Max concurrent now configurable via config file or env var - First-run prompts for max concurrent based on system resources - Fix cursor reset bug (was checking wrong array length) --- cli/commands.go | 3 ++- config/config.go | 35 +++++++++++++++++++++++++++++++ queue/processor.go | 36 +++++++++++++++++--------------- queue/queue.go | 5 +++-- tui/app.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++ tui/grid.go | 5 +++-- 6 files changed, 114 insertions(+), 22 deletions(-) diff --git a/cli/commands.go b/cli/commands.go index 7f0783b..6ca400a 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -361,9 +361,10 @@ Actions: 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, queue.MaxConcurrent) + 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) 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/queue/processor.go b/queue/processor.go index 38a7e05..7f59977 100644 --- a/queue/processor.go +++ b/queue/processor.go @@ -76,27 +76,29 @@ func processQueue() { // Sync task phases with queue status syncTaskPhases(q) - // Check if we have capacity - running := q.CountRunning() - if running >= MaxConcurrent { - return - } + // Start all ready tasks up to capacity (no waiting between starts) + maxConcurrent := config.GetMaxConcurrent() + for { + running := q.CountRunning() + if running >= maxConcurrent { + break + } - // Get next ready task - task := q.NextReady() - if task == nil { - return - } + 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 + } - // Start the task - if err := startTask(task); err != nil { - q.SetError(task.ID, err.Error()) + q.UpdateStatus(task.ID, StatusRunning) q.Save() - return } - - q.UpdateStatus(task.ID, StatusRunning) - q.Save() } // syncTaskPhases updates queue status based on task phase files. diff --git a/queue/queue.go b/queue/queue.go index 7ad4337..5208dcc 100644 --- a/queue/queue.go +++ b/queue/queue.go @@ -24,8 +24,9 @@ const ( StatusPaused Status = "paused" // Manually paused ) -// MaxConcurrent is the maximum number of containers to run at once. -const MaxConcurrent = 10 +// 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 { 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/grid.go b/tui/grid.go index 45a1566..7c6967e 100644 --- a/tui/grid.go +++ b/tui/grid.go @@ -465,8 +465,9 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Refresh branches and content periodically m.branches = branch.GetManagedBranches() m.queueTasks = queue.Get().GetAll() - if m.cursor >= len(m.filteredTasks()) && len(m.filteredTasks()) > 0 { - m.cursor = len(m.filteredTasks()) - 1 + // Keep cursor in bounds (grid shows branches) + if m.cursor >= len(m.branches) && len(m.branches) > 0 { + m.cursor = len(m.branches) - 1 } // Note: Don't clean up globalPendingBranches here - let branchStartedMsg handle it return m, tea.Batch(m.loadPaneContent, loadContainerStats, m.loadGridGitStats, m.loadRunningState, m.loadTaskInfo, loadQueueTasks, gridTickCmd()) From fea97ddc8684862dd0380e5d13f1e204e43b0492 Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 19:08:42 -0500 Subject: [PATCH 13/14] fix API key loading, dynamic grid layout - Read API key from config file, not just env var - Grid layout dynamically adjusts rows/cols based on screen size - Minimum cell size ensures readability - Navigation keys work with dynamic grid --- tmux/tmux.go | 2 +- tui/grid.go | 70 ++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/tmux/tmux.go b/tmux/tmux.go index a339e96..749bbbb 100644 --- a/tmux/tmux.go +++ b/tmux/tmux.go @@ -82,7 +82,7 @@ func OpenTerminal(branchName, containerID string) error { // dockerExecWithEnv returns the docker exec command with ANTHROPIC_API_KEY passed through. func dockerExecWithEnv(containerID string) string { - apiKey := os.Getenv("ANTHROPIC_API_KEY") + apiKey := config.GetAnthropicAPIKey() if apiKey != "" { return fmt.Sprintf("docker exec -it -e ANTHROPIC_API_KEY=%s -w /home/dark/app %s bash", apiKey, containerID) } diff --git a/tui/grid.go b/tui/grid.go index 7c6967e..8d7e19f 100644 --- a/tui/grid.go +++ b/tui/grid.go @@ -237,13 +237,13 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "up": - cols := m.numCols() + _, cols := m.gridDimensions() if m.cursor >= cols { m.cursor -= cols } case "down": - cols := m.numCols() + _, cols := m.gridDimensions() if m.cursor+cols < len(m.branches) { m.cursor += cols } @@ -660,14 +660,55 @@ func (m GridModel) isRunning(name string) bool { return false } -func (m GridModel) numCols() int { +// 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) if n == 0 { - return 1 + return 1, 1 } - // 2 rows, ceil(n/2) columns - return (n + 1) / 2 + + // 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 + } + } + + // Fallback: use max rows + return maxRows, (n + maxRows - 1) / maxRows } // View renders the grid. @@ -712,8 +753,8 @@ func (m GridModel) View() string { return b.String() } - // Calculate cell dimensions - cols := m.numCols() + // Calculate grid dimensions + numRows, numCols := m.gridDimensions() width := m.width if width < 40 { width = 120 @@ -724,16 +765,19 @@ 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++ { + 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) { From 077b36073f63dffba9e2a6ab881a597ea3ef9b66 Mon Sep 17 00:00:00 2001 From: Stachu Korick Date: Thu, 22 Jan 2026 22:30:13 -0500 Subject: [PATCH 14/14] WIP --- branch/discovery.go | 14 +- branch/ops.go | 19 +- container/devcontainer.go | 18 +- queue/healthcheck.go | 197 ++++++++++++ queue/processor.go | 16 + queue/queue.go | 29 +- scripts/claude-loop.sh | 123 +++++++- task/prompts.go | 23 +- task/task.go | 71 ++++- tmux/tmux.go | 3 +- tui/detail.go | 292 +++++++++++++++++ tui/filter.go | 202 ++++++++++++ tui/focus.go | 301 ++++++++++++++++++ tui/grid.go | 640 +++++++++++++++++++++++++++++++------- 14 files changed, 1787 insertions(+), 161 deletions(-) create mode 100644 queue/healthcheck.go create mode 100644 tui/detail.go create mode 100644 tui/filter.go create mode 100644 tui/focus.go 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 133d0f1..55507b9 100644 --- a/branch/ops.go +++ b/branch/ops.go @@ -262,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/container/devcontainer.go b/container/devcontainer.go index 5119aa7..aeb678f 100644 --- a/container/devcontainer.go +++ b/container/devcontainer.go @@ -227,9 +227,9 @@ func GenerateOverrideConfig(b BranchInfo) (string, error) { // 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 to skip onboarding prompts - // This creates settings that tell Claude we've already completed setup - claudeSettingsCmd := `mkdir -p /home/dark/.claude && echo '{"theme":"dark","hasCompletedOnboarding":true,"apiKeySource":"env"}' > /home/dark/.claude/settings.json && chown -R dark:dark /home/dark/.claude` + // 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") { @@ -242,8 +242,16 @@ func GenerateOverrideConfig(b BranchInfo) (string, error) { cfg["postCreateCommand"] = postCreate } - // Note: Auth is handled via ANTHROPIC_API_KEY passed through docker exec -e at runtime - // (see tmux.dockerExecWithEnv). No oauth token or mounted config needed. + // 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 output, err := json.MarshalIndent(cfg, "", " ") 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/processor.go b/queue/processor.go index 7f59977..a51a943 100644 --- a/queue/processor.go +++ b/queue/processor.go @@ -111,6 +111,22 @@ func syncTaskPhases(q *Queue) { 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 != "" { diff --git a/queue/queue.go b/queue/queue.go index 5208dcc..a618f1f 100644 --- a/queue/queue.go +++ b/queue/queue.go @@ -199,7 +199,27 @@ func (q *Queue) GetByStatus(statuses ...Status) []*Task { return result } -// GetAll returns all tasks sorted by priority. +// 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() @@ -210,9 +230,16 @@ func (q *Queue) GetAll() []*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) }) diff --git a/scripts/claude-loop.sh b/scripts/claude-loop.sh index 4c717af..36b8b3c 100755 --- a/scripts/claude-loop.sh +++ b/scripts/claude-loop.sh @@ -1,14 +1,23 @@ #!/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} -ITERATION=0 + +# 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" @@ -23,17 +32,44 @@ log() { 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" @@ -82,6 +118,10 @@ run_claude() { 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 @@ -91,11 +131,20 @@ run_claude() { 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 @@ -107,11 +156,48 @@ run_claude() { return $exit_code } +reset_loop() { + log "Resetting loop state" + ITERATION=0 + save_iteration + rm -f "$ERROR_FILE" + echo "executing" > "$PHASE_FILE" +} + main() { - log "Starting Claude loop (max $MAX_ITERATIONS iterations)" + # 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") @@ -121,20 +207,39 @@ main() { break fi - if ! run_claude; then - # Claude exited, check if we should continue + 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, restarting in 2 seconds..." - sleep 2 + 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 - # Phase transition occurred - break + # 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 @@ -143,7 +248,7 @@ main() { echo "max-iterations-reached" > "$PHASE_FILE" fi - log "Loop finished after $ITERATION iterations" + log "Loop finished after $ITERATION iterations (phase: $(cat $PHASE_FILE 2>/dev/null || echo 'unknown'))" } # Run main if not sourced diff --git a/task/prompts.go b/task/prompts.go index 2f7555b..fc5ceee 100644 --- a/task/prompts.go +++ b/task/prompts.go @@ -71,7 +71,8 @@ func (t *Task) planningContext() string { "- 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\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" } @@ -123,17 +124,21 @@ func (t *Task) executingContext() string { "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" + - "## Commits\n\n" + - "Commit early and often as you make progress:\n" + - "- Short casual commit messages (e.g., \"add user auth\", \"fix login bug\")\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" + - "- Commit after completing each logical chunk of work\n" + - "- Don't wait until the end to commit everything\n\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" + - "- Make a final commit if there are uncommitted changes\n" + - "- Write \"done\" to .claude-task/phase\n" + - "- The loop will exit\n\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" + diff --git a/task/task.go b/task/task.go index 486a2f2..419bb5a 100644 --- a/task/task.go +++ b/task/task.go @@ -4,6 +4,7 @@ package task import ( "fmt" "os" + "os/exec" "path/filepath" "strings" "time" @@ -15,11 +16,16 @@ import ( 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 + 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. @@ -35,6 +41,16 @@ func (p Phase) Display() string { 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) } @@ -53,6 +69,16 @@ func (p Phase) Icon() string { return "⚡" case PhaseDone: return "✅" + case PhaseAuthError: + return "🔑" + case PhaseError: + return "❌" + case PhaseMaxIterations: + return "🔄" + case PhaseAwaitingAnswers: + return "❓" + case PhaseReadyForReview: + return "👀" default: return "?" } @@ -296,3 +322,38 @@ func (t *Task) GetCreatedTime() 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 749bbbb..89b28cb 100644 --- a/tmux/tmux.go +++ b/tmux/tmux.go @@ -84,7 +84,8 @@ func OpenTerminal(branchName, containerID string) error { func dockerExecWithEnv(containerID string) string { apiKey := config.GetAnthropicAPIKey() if apiKey != "" { - return fmt.Sprintf("docker exec -it -e ANTHROPIC_API_KEY=%s -w /home/dark/app %s bash", apiKey, containerID) + // 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) } 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 8d7e19f..80a59b7 100644 --- a/tui/grid.go +++ b/tui/grid.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "os" "os/exec" "strings" "time" @@ -38,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 @@ -95,8 +136,28 @@ func NewGridModel() GridModel { // Start the queue processor queue.StartProcessor() - // Default filter: show running tasks - defaultFilter := []queue.Status{queue.StatusRunning} + // 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(), @@ -108,6 +169,7 @@ func NewGridModel() GridModel { taskInfo: make(map[string]*TaskInfo), statusFilter: defaultFilter, processorOn: true, + message: startupMessage, } } @@ -232,7 +294,8 @@ 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++ } @@ -243,16 +306,19 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "down": + tasks := m.filteredTasks() _, cols := m.gridDimensions() - if m.cursor+cols < len(m.branches) { + 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) @@ -262,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) @@ -280,15 +348,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 "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() { @@ -306,34 +376,45 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) } @@ -346,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) @@ -365,65 +449,112 @@ 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) - if len(m.branches) > 0 && m.cursor < len(m.branches) { - b := m.branches[m.cursor] - t := task.New(b.Name, b.Path) - - // Create pre-prompt file with template if it doesn't exist - if !t.Exists() { - template := task.PrePromptTemplate(b.Name) - if err := t.SetPrePrompt(template); err != nil { - m.message = fmt.Sprintf("Error: %v", err) - return m, nil - } - } + tasks := m.filteredTasks() + if len(tasks) > 0 && m.cursor < len(tasks) { + qt := tasks[m.cursor] + q := queue.Get() - // Use tea.ExecProcess to run editor (hands over terminal properly) + // Edit the queue task's prompt editor := findEditor() - c := exec.Command(editor, t.PrePromptPath()) + // 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} } - // After editor closes, check content and set phase - content := t.PrePrompt() - if content != "" && !isTemplateOnly(content) { - t.SetPhase(task.PhasePlanning) - return operationDoneMsg{fmt.Sprintf("Task set for %s - press 'c' to start", b.Name)} + // 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{"Pre-prompt saved"} + return operationDoneMsg{"Prompt unchanged"} }) } case "f": - // Cycle through filter options - m.statusFilter = m.nextFilter() - m.cursor = 0 - return m, nil + // 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 "?": return NewHelpModel(), nil @@ -465,9 +596,11 @@ func (m GridModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Refresh branches and content periodically m.branches = branch.GetManagedBranches() m.queueTasks = queue.Get().GetAll() - // Keep cursor in bounds (grid shows branches) - if m.cursor >= len(m.branches) && len(m.branches) > 0 { - m.cursor = len(m.branches) - 1 + 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, m.loadGridGitStats, m.loadRunningState, m.loadTaskInfo, loadQueueTasks, gridTickCmd()) @@ -524,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 @@ -555,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 @@ -663,7 +801,8 @@ func (m GridModel) isRunning(name string) bool { // 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, 1 } @@ -716,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) @@ -730,26 +870,27 @@ 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() } @@ -772,7 +913,7 @@ func (m GridModel) View() string { // Build rows dynamically var rows []string - branchIdx := 0 + itemIdx := 0 for row := 0; row < numRows; row++ { var cells []string remainingWidth := width @@ -780,19 +921,19 @@ func (m GridModel) View() string { 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...)) @@ -811,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 [p]rompt [f]ilter [?] [q]")) + 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() @@ -819,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 m.isRunning(br.Name) { - running++ - } - } + + // Count running from queue tasks + q := queue.Get() + running := q.CountRunning() // Calculate total CPU and RAM usage var totalCPU float64 @@ -862,18 +1001,22 @@ func (m GridModel) renderStatusBar() string { memStr = fmt.Sprintf("%.1fGB", totalMemMB/1024) } - // Queue stats - q := queue.Get() - qRunning := q.CountRunning() + // 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", qRunning, qReady, qTotal) + queueInfo := fmt.Sprintf("queue: %d run, %d ready, %d total", running, qReady, qTotal) // Filter info filterInfo := fmt.Sprintf("filter: %s", m.filterDescription()) - return statusBarStyle.Render(fmt.Sprintf("%d cores, %dGB • %d/%d running (%.0f%% CPU, %s/%.0f%% RAM) • %s • %s • proxy %s", - cpuCores, ramGB, running, maxSuggested, hostCpuPct, memStr, hostMemPct, queueInfo, filterInfo, proxyStatus)) + // 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 { @@ -916,11 +1059,18 @@ 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 @@ -1001,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 @@ -1009,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 @@ -1039,8 +1402,16 @@ func (m GridModel) renderPendingCell(pb *PendingBranch, width, height int) strin } content := helpStyle.Render(pb.Status) + cellContent := header + "\n" + content - return cellBorderStyle.Width(innerWidth).Height(innerHeight).Render(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(cellContent) } // Commands @@ -1100,6 +1471,23 @@ func (m GridModel) removeBranch(b *branch.Branch) tea.Cmd { } } +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) @@ -1123,3 +1511,17 @@ 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 +}