diff --git a/cmd/root/board.go b/cmd/root/board.go new file mode 100644 index 000000000..c2298461f --- /dev/null +++ b/cmd/root/board.go @@ -0,0 +1,36 @@ +package root + +import ( + "github.com/spf13/cobra" + + boardtui "github.com/docker/docker-agent/pkg/board/tui" + "github.com/docker/docker-agent/pkg/telemetry" +) + +// newBoardCmd creates the `docker agent board` command: a Kanban TUI that +// orchestrates one agent per card, each running in a tmux session on an +// isolated git worktree. Projects and column prompts are configured in the +// user's global config file, or from the TUI itself. +func newBoardCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "board", + Short: "Orchestrate agents on a Kanban board", + Long: `Board is a Kanban TUI for orchestrating agents. Each card launches an agent +in a tmux session on an isolated git worktree, and moving a card forward +through the pipeline (Dev → Review → Push → Done) sends the +destination column's prompt to its agent. + +Projects and column prompts are stored in the global config file +(~/.config/cagent/config.yaml) and can be managed from the TUI.`, + Example: ` docker-agent board`, + Args: cobra.NoArgs, + GroupID: "core", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + telemetry.TrackCommand(cmd.Context(), "board", nil) + applyTheme("") + return boardtui.Run(cmd.Context()) + }, + } + return cmd +} diff --git a/cmd/root/root.go b/cmd/root/root.go index 5a9f4b2da..734fd855d 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -168,6 +168,7 @@ We collect anonymous usage data to help improve docker agent. To disable: newDebugCmd(), newAliasCmd(), newSandboxCmd(), + newBoardCmd(), newServeCmd(), newAskpassCmd(), ) diff --git a/docs/features/board/index.md b/docs/features/board/index.md new file mode 100644 index 000000000..dbda07e67 --- /dev/null +++ b/docs/features/board/index.md @@ -0,0 +1,83 @@ +--- +title: "Kanban Board" +description: "Orchestrate multiple agents from a Kanban TUI: each card runs an agent in a tmux session on an isolated git worktree." +keywords: docker agent, ai agents, features, board, kanban, orchestration +linkTitle: "Kanban Board" +weight: 15 +canonical: https://docs.docker.com/ai/docker-agent/features/board/ +--- + +_Board is a Kanban TUI for orchestrating agents. Each card launches an agent +in a tmux session on an isolated git worktree, and moving a card forward +through the pipeline sends the destination column's prompt to its agent._ + +## Launching the board + +```bash +$ docker agent board +``` + +Requirements: `tmux` and `git` must be installed. + +## How it works + +- **Cards run agents.** Creating a card (`n`) launches `docker agent run` in + a dedicated tmux session, working in a fresh git worktree branched from the + project's upstream default branch. The card's title, running/idle status, + and failures are mirrored live from the agent's control plane. +- **Columns are a pipeline.** The default pipeline is + Dev → Review → Push → Done. Moving a card forward (`]`) + sends the destination column's prompt to the card's agent; moving it back + (`[`) sends nothing. +- **Attach anytime.** Press `enter` (or double-click a card) to attach your + terminal to the agent's session and interact with it directly; `ctrl+q` + detaches and returns to the board. +- **Everything is recoverable.** Quitting the board leaves agents running in + tmux; restarting it reattaches to them. If an agent process dies, the board + relaunches it and resumes the same conversation and worktree. + +## Key bindings + +| Key | Action | +| ------------- | --------------------------------------------------- | +| `n` | Create a card (project + prompt) | +| `enter` | Attach to the card's agent (`ctrl+q` detaches) | +| `d` | View the card's worktree diff | +| `o` | Open the card's worktree in `$BOARD_EDITOR` (`code`) | +| `[` / `]` | Move the card back / forward | +| `x` | Delete the card, its session, worktree, and branch | +| `p` | Manage projects | +| `e` | Edit the selected column's prompt | +| `←↓↑→` `hjkl` | Navigate | +| mouse | Click selects, double-click attaches, wheel scrolls | +| `?` | Help | +| `q` | Quit (agents keep running) | + +## Configuration + +Everything is configured in the global config file +(`~/.config/cagent/config.yaml`) or through the TUI itself (`p` for projects, +`e` for column prompts): + +```yaml +board: + projects: + - name: my-app + path: /Users/me/src/my-app + agent: coder # any agent ref; defaults to the built-in agent + columns: + - id: dev + name: Dev + emoji: 🔨 + - id: review + name: Review + emoji: 🔍 + prompt: Review the local changes and fix any issues you find. + - id: done + name: Done + emoji: ✅ +``` + +Omitting `columns` keeps the default pipeline. When a card enters a column +with a `prompt`, that prompt is delivered to the card's agent as its next +message. diff --git a/pkg/board/app.go b/pkg/board/app.go new file mode 100644 index 000000000..b24c8498e --- /dev/null +++ b/pkg/board/app.go @@ -0,0 +1,384 @@ +package board + +import ( + "cmp" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + + "github.com/docker/docker-agent/pkg/paths" + "github.com/docker/docker-agent/pkg/userconfig" +) + +// DefaultAgent is the agent ref used for projects that do not set one. +const DefaultAgent = "default" + +// Project is a repository cards can be created against, configured in the +// user's global config file (or through the TUI, which persists there). +type Project struct { + Name string + Path string + Agent string +} + +// App is the board engine: it owns the cards, the per-card agent sessions, +// and the configuration (projects and columns) stored in the user's global +// config file. +type App struct { + // ctx is the board-lifetime context used by engine operations that + // outlive a single UI interaction (git commands, tmux attach). + ctx context.Context //nolint:containedctx // board-lifetime context + store *Store + sessions sessionManager + controller *controller + + // mu guards config (the mutable projects/columns section of the user's + // global config file). + mu sync.Mutex + config *userconfig.Config + columns []Column + + onChanged func() +} + +// NewApp loads the board state and reattaches to any sessions still running +// in tmux. onChanged is called (from arbitrary goroutines) whenever a card +// changes, so the UI can refresh. +func NewApp(ctx context.Context, onChanged func()) (*App, error) { + if _, err := exec.LookPath("tmux"); err != nil { + return nil, errors.New("the board runs each agent in a tmux session: please install tmux first") + } + + // One board per state file: a second instance would run its own + // watchers and race this one relaunching agents. + if err := acquireLock(StatePath() + ".lock"); err != nil { + return nil, err + } + + cfg, err := userconfig.Load() + if err != nil { + return nil, fmt.Errorf("load user config: %w", err) + } + + store, err := OpenStore(StatePath()) + if err != nil { + return nil, err + } + + var columns []userconfig.BoardColumn + if cfg.Board != nil { + columns = cfg.Board.Columns + } + + app := &App{ + ctx: ctx, + store: store, + sessions: tmuxSessions{ctx: ctx}, + config: cfg, + columns: ColumnsFromConfig(columns), + onChanged: onChanged, + } + app.controller = newController(ctx, store, app.sessions, onChanged) + app.controller.ReconcileAll() + return app, nil +} + +// Columns returns the board's pipeline. +func (a *App) Columns() []Column { + a.mu.Lock() + defer a.mu.Unlock() + return slices.Clone(a.columns) +} + +// ColumnIndex returns the position of a column in the pipeline, or -1. +func (a *App) ColumnIndex(colID string) int { + a.mu.Lock() + defer a.mu.Unlock() + return slices.IndexFunc(a.columns, func(c Column) bool { return c.ID == colID }) +} + +// SetColumnPrompt updates a column's prompt and persists it to the user's +// global config file. +func (a *App) SetColumnPrompt(colID, prompt string) error { + a.mu.Lock() + defer a.mu.Unlock() + i := slices.IndexFunc(a.columns, func(c Column) bool { return c.ID == colID }) + if i < 0 { + return fmt.Errorf("unknown column %q", colID) + } + a.columns[i].Prompt = prompt + return a.saveConfigLocked() +} + +// saveConfigLocked persists the projects and columns to the global config +// file. Callers must hold a.mu. +func (a *App) saveConfigLocked() error { + board := &userconfig.Board{} + if a.config.Board != nil { + *board = *a.config.Board + } + if slices.Equal(a.columns, DefaultColumns) { + // Keep the config free of the built-in pipeline so future changes to + // the defaults reach users who never customized their columns. + board.Columns = nil + } else { + board.Columns = make([]userconfig.BoardColumn, 0, len(a.columns)) + for _, c := range a.columns { + board.Columns = append(board.Columns, userconfig.BoardColumn{ID: c.ID, Name: c.Name, Emoji: c.Emoji, Prompt: c.Prompt}) + } + } + a.config.Board = board + return a.config.Save() +} + +// Projects returns the configured projects. +func (a *App) Projects() []Project { + a.mu.Lock() + defer a.mu.Unlock() + if a.config.Board == nil { + return nil + } + projects := make([]Project, 0, len(a.config.Board.Projects)) + for _, p := range a.config.Board.Projects { + projects = append(projects, Project{Name: p.Name, Path: p.Path, Agent: p.Agent}) + } + return projects +} + +// AddProject validates and appends a project, persisting it to the user's +// global config file. The path is normalized to an absolute path (expanding +// a leading ~) so cards never depend on the board's working directory. +func (a *App) AddProject(p Project) error { + if strings.TrimSpace(p.Name) == "" { + return errors.New("project name is required") + } + path, err := normalizeProjectPath(p.Path) + if err != nil { + return err + } + p.Path = path + if !isGitRepo(a.ctx, p.Path) { + return fmt.Errorf("%s is not a git repository", p.Path) + } + + a.mu.Lock() + defer a.mu.Unlock() + if a.config.Board == nil { + a.config.Board = &userconfig.Board{} + } + for _, existing := range a.config.Board.Projects { + if existing.Name == p.Name { + return fmt.Errorf("project %q already exists", p.Name) + } + } + a.config.Board.Projects = append(a.config.Board.Projects, userconfig.BoardProject{ + Name: p.Name, + Path: p.Path, + Agent: p.Agent, + }) + return a.saveConfigLocked() +} + +// normalizeProjectPath expands a leading ~ and makes the path absolute. An +// empty path is rejected: it would silently validate against the board's +// working directory. +func normalizeProjectPath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", errors.New("project path is required") + } + if path == "~" || strings.HasPrefix(path, "~/") { + path = filepath.Join(paths.GetHomeDir(), strings.TrimPrefix(path[1:], "/")) + } + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve project path: %w", err) + } + return abs, nil +} + +// RemoveProject deletes a project by name and persists the change. Existing +// cards keep the repo path and agent they were created with. +func (a *App) RemoveProject(name string) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.config.Board == nil { + return nil + } + a.config.Board.Projects = slices.DeleteFunc(a.config.Board.Projects, func(p userconfig.BoardProject) bool { + return p.Name == name + }) + return a.saveConfigLocked() +} + +// Cards returns all cards in board order. +func (a *App) Cards() []*Card { + return a.store.ListCards() +} + +// CreateCard creates a card in the first column and launches its agent +// session. docker agent creates the isolated git worktree (named after the +// card) on first launch and exposes its control plane on a per-card unix +// socket; the board records where the worktree lives and starts watching +// the session. The title is a placeholder derived from the prompt, replaced +// when the agent emits its session_title event, so card creation is instant. +func (a *App) CreateCard(project Project, prompt string) (card *Card, err error) { + if strings.TrimSpace(prompt) == "" { + return nil, errors.New("prompt is required") + } + agent := project.Agent + if agent == "" { + agent = DefaultAgent + } + + worktreeName := newWorktreeName() + branch := worktreeBranch(worktreeName) + wtPath := worktreeDir(worktreeName) + sessionName := newSessionName() + agentSession := newID() + + defer func() { + if err != nil { + _ = a.sessions.KillSession(sessionName) + removeWorktree(a.ctx, project.Path, wtPath, branch) + } + }() + + // Launch from the repository: --worktree branches the new worktree from + // the repo's upstream base (detected, not assumed). + base := upstreamBase(a.ctx, project.Path) + if err := a.sessions.NewSession(sessionName, project.Path, agent, agentSession, socketPath(agentSession), worktreeName, base, prompt); err != nil { + return nil, fmt.Errorf("tmux session: %w", err) + } + + firstColumn := a.Columns()[0].ID + card = &Card{ + ID: newID(), + Title: placeholderTitle(prompt), + Column: firstColumn, + Status: StatusStarting, + Project: project.Name, + Agent: agent, + RepoPath: project.Path, + Branch: branch, + Worktree: wtPath, + Session: sessionName, + AgentSession: agentSession, + } + + if err := a.store.InsertCard(card); err != nil { + return nil, fmt.Errorf("insert card: %w", err) + } + + a.controller.Start(card) + a.onChanged() + return card, nil +} + +// MoveCard moves a card to the given column. A move never changes the +// card's status: the status tracks the agent's activity, not the move. A +// busy card cannot move forward; the check is enforced atomically by the +// store so a watcher flipping the status concurrently cannot slip past it. +// Moving forward sends the destination column's prompt to the card's agent; +// the move stays observable even when the prompt cannot be delivered. +func (a *App) MoveCard(cardID, colID string) error { + card, err := a.store.GetCard(cardID) + if err != nil { + return err + } + + dstIdx := a.ColumnIndex(colID) + if dstIdx < 0 { + return fmt.Errorf("unknown column %q", colID) + } + movedForward := dstIdx > a.ColumnIndex(card.Column) + + moved, err := a.store.MoveCard(cardID, colID, movedForward) + if err != nil { + return err + } + a.controller.Start(moved) // no-op if already watching + a.onChanged() + + if movedForward { + return a.controller.SendPrompt(moved, a.Columns()[dstIdx].Prompt) + } + return nil +} + +// DeleteCard removes a card, kills its session, and cleans up its worktree. +func (a *App) DeleteCard(cardID string) error { + card, err := a.store.GetCard(cardID) + if err != nil { + return err + } + // Remove from the store first: combined with the controller's relaunch + // lock (Teardown), this guarantees no in-flight relaunch resurrects the + // session after it is killed here. + if err := a.store.DeleteCard(cardID); err != nil { + return err + } + a.controller.Stop(cardID) + a.controller.Teardown(card) + removeWorktree(a.ctx, card.RepoPath, card.Worktree, card.Branch) + // The agent is gone for good: drop its control-plane socket file too. + _ = os.Remove(socketPath(card.AgentSession)) + a.onChanged() + return nil +} + +// Diff returns the card's full worktree diff against the upstream base. +func (a *App) Diff(cardID string) (string, error) { + card, err := a.store.GetCard(cardID) + if err != nil { + return "", err + } + return worktreeDiff(a.ctx, card.Worktree) +} + +// OpenEditor opens the card's worktree in the user's GUI editor: the +// command named by $BOARD_EDITOR, defaulting to VS Code's `code`. The +// editor is started detached and reaped in the background. +func (a *App) OpenEditor(cardID string) error { + card, err := a.store.GetCard(cardID) + if err != nil { + return err + } + editor := cmp.Or(os.Getenv("BOARD_EDITOR"), "code") + cmd := exec.CommandContext(a.ctx, editor, card.Worktree) + if err := cmd.Start(); err != nil { + return fmt.Errorf("open editor (%s): %w", editor, err) + } + // Reap the editor when it exits so it does not linger as a zombie. + go func() { _ = cmd.Wait() }() + return nil +} + +// ErrAgentStarting means the card's agent has not answered on its control +// plane yet, so there is no UI worth attaching to. +var ErrAgentStarting = errors.New("the agent is still starting") + +// AttachCommand returns the command that attaches the caller's terminal to +// the card's agent session. It fails with [ErrAgentStarting] until the +// agent's control plane answers, so the user never lands on a bare launch +// command. +func (a *App) AttachCommand(cardID string) (*exec.Cmd, error) { + card, err := a.store.GetCard(cardID) + if err != nil { + return nil, err + } + if !a.controller.Ready(card) { + return nil, ErrAgentStarting + } + socket, err := TmuxSocketPath() + if err != nil { + return nil, err + } + return exec.CommandContext(a.ctx, "tmux", "-S", socket, "attach", "-t", "="+card.Session), nil +} diff --git a/pkg/board/app_test.go b/pkg/board/app_test.go new file mode 100644 index 000000000..be6b5f5da --- /dev/null +++ b/pkg/board/app_test.go @@ -0,0 +1,34 @@ +package board + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/paths" +) + +func TestNormalizeProjectPath(t *testing.T) { + abs, err := normalizeProjectPath("/some/repo") + require.NoError(t, err) + assert.Equal(t, "/some/repo", abs) + + // Empty and blank paths are rejected: they would silently validate + // against the board's working directory. + _, err = normalizeProjectPath("") + require.Error(t, err) + _, err = normalizeProjectPath(" ") + require.Error(t, err) + + // A leading ~ expands to the home directory. + abs, err = normalizeProjectPath("~/src/repo") + require.NoError(t, err) + assert.Equal(t, filepath.Join(paths.GetHomeDir(), "src", "repo"), abs) + + // Relative paths are anchored to the current directory. + abs, err = normalizeProjectPath("repo") + require.NoError(t, err) + assert.True(t, filepath.IsAbs(abs)) +} diff --git a/pkg/board/client.go b/pkg/board/client.go new file mode 100644 index 000000000..af3ddf2c7 --- /dev/null +++ b/pkg/board/client.go @@ -0,0 +1,195 @@ +package board + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" +) + +// Event types the board reacts to on the session event stream. Every other +// runtime event is ignored. +const ( + eventStreamStarted = "stream_started" + eventStreamStopped = "stream_stopped" + eventSessionTitle = "session_title" + eventSessionExited = "session_exited" + // eventUserMessage marks a real user prompt entering the session. The + // runtime emits it only for human-authored turns (sub-agent and skill + // sub-sessions suppress it), right before the turn's outermost + // stream_started, which makes it a turn-boundary marker. + eventUserMessage = "user_message" + // eventError is emitted when a turn fails (model error, tool failure, + // hook block…). Unlike stream_stopped it is delivered on the blocking + // sink and buffered for replay, so it is the reliable failure signal. + eventError = "error" + // eventRuntimePaused is emitted when the run loop blocks at an iteration + // boundary because /pause was toggled on. There is no matching resume + // event: the loop simply starts emitting events again once resumed. + eventRuntimePaused = "runtime_paused" + eventGap = "gap" +) + +// reasonNormal is the stream_stopped reason for a turn that completed +// cleanly, as opposed to "error", "canceled", "hook_blocked"... +const reasonNormal = "normal" + +// event is the subset of a runtime event the board cares about. +type event struct { + Type string `json:"type"` + Title string `json:"title"` + // Reason classifies how a stream ended (stream_stopped only). It is + // authoritative for the turn's outcome, unlike mid-turn error events + // which a parent agent may have recovered from. + Reason string `json:"reason"` + // Seq is the event's position in the session's buffer, parsed from the + // SSE "id:" line. It is 0 when the server sent no id. Compared with + // [snapshot.LastEventSeq] it tells replayed history from live events. + Seq uint64 `json:"-"` +} + +// snapshot is the part of GET /snapshot the board uses to (re)build a card's +// state and find the stream position to resume from. +type snapshot struct { + Title string `json:"title"` + LastEventSeq uint64 `json:"last_event_seq"` +} + +// client drives one session's control plane over its unix socket. +type client struct { + http *http.Client + base string + session string +} + +// newClient returns a client that reaches the control plane over the given +// unix socket and targets the given session id. +func newClient(socket, session string) *client { + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socket) + }, + } + return &client{ + http: &http.Client{Transport: transport}, + base: "http://agent", + session: session, + } +} + +func (c *client) endpoint(name string) string { + return c.base + "/api/sessions/" + url.PathEscape(c.session) + "/" + name +} + +// Snapshot reads the session's state and the stream position it corresponds to. +func (c *client) Snapshot(ctx context.Context) (snapshot, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("snapshot"), http.NoBody) + if err != nil { + return snapshot{}, err + } + resp, err := c.http.Do(req) + if err != nil { + return snapshot{}, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return snapshot{}, fmt.Errorf("snapshot: %s", resp.Status) + } + var snap snapshot + if err := json.NewDecoder(resp.Body).Decode(&snap); err != nil { + return snapshot{}, fmt.Errorf("decode snapshot: %w", err) + } + return snap, nil +} + +// Followup enqueues a message to run after the current turn. A non-empty +// idempotencyKey makes the call safe to retry. +func (c *client) Followup(ctx context.Context, idempotencyKey, message string) error { + body, err := json.Marshal(map[string]any{ + "messages": []map[string]string{{"content": message}}, + }) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("followup"), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if idempotencyKey != "" { + req.Header.Set("Idempotency-Key", idempotencyKey) + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("followup: %s", resp.Status) + } + // Drain so the connection can be reused; the body only reports whether + // the delivery was a duplicate, which the board does not care about. + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} + +// StreamEvents tails the session event stream starting after `since` (0 from +// the beginning of the buffer). onEvent is called for every event; returning +// false stops the stream cleanly. It returns nil on a clean stop and an +// error when the connection fails. +func (c *client) StreamEvents(ctx context.Context, since uint64, onEvent func(event) bool) error { + u := c.endpoint("events") + if since > 0 { + u += "?since=" + strconv.FormatUint(since, 10) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, http.NoBody) + if err != nil { + return err + } + req.Header.Set("Accept", "text/event-stream") + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("events: %s", resp.Status) + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + var seq uint64 + for scanner.Scan() { + line := scanner.Text() + if id, ok := strings.CutPrefix(line, "id:"); ok { + seq, _ = strconv.ParseUint(strings.TrimSpace(id), 10, 64) + continue + } + data, ok := strings.CutPrefix(line, "data:") + if !ok { + continue + } + var ev event + if json.Unmarshal([]byte(strings.TrimSpace(data)), &ev) != nil { + continue + } + ev.Seq = seq + seq = 0 + if !onEvent(ev) { + return nil + } + } + if err := scanner.Err(); err != nil { + return err + } + return errors.New("event stream closed") +} diff --git a/pkg/board/controller.go b/pkg/board/controller.go new file mode 100644 index 000000000..e4677c3ab --- /dev/null +++ b/pkg/board/controller.go @@ -0,0 +1,390 @@ +package board + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "time" +) + +// sessionClient is the slice of the control-plane client the controller +// needs, as an interface so tests can inject a fake without real sockets. +type sessionClient interface { + Snapshot(ctx context.Context) (snapshot, error) + StreamEvents(ctx context.Context, since uint64, onEvent func(event) bool) error + Followup(ctx context.Context, idempotencyKey, message string) error +} + +const ( + // retryDelay paces reconnect and relaunch attempts. + retryDelay = 500 * time.Millisecond + // snapshotTimeout bounds a single snapshot request so a wedged server + // cannot block a watcher forever. + snapshotTimeout = 10 * time.Second + // followupTimeout bounds a single follow-up delivery. + followupTimeout = 10 * time.Second + // readyProbeTimeout bounds the control-plane probe behind "attach" so + // the user gets quick feedback instead of hanging. + readyProbeTimeout = 2 * time.Second +) + +// controller keeps each card in sync with its agent's control plane. One +// watcher goroutine per card tails the session event stream and mirrors the +// running/waiting status and the title into the store, reconnecting — and +// relaunching the tmux session if the agent died — as needed. +type controller struct { + // ctx is the board-lifetime context watchers derive from; they are + // started lazily (Start) after construction, so it is held here rather + // than passed. + ctx context.Context //nolint:containedctx // base context for background watchers + store *Store + sessions sessionManager + onChanged func() + clientFor func(socket, session string) sessionClient + + mu sync.Mutex + watchers map[string]*watcher + + // relaunchMu serializes session relaunches. A watcher's background + // resume and a prompt-bearing relaunch (SendPrompt) can otherwise race: + // one kills the session the other just created, dropping its prompt. + relaunchMu sync.Mutex +} + +// watcher tracks a running watch goroutine so it can be cancelled and waited on. +type watcher struct { + cancel context.CancelFunc + done chan struct{} +} + +func newController(ctx context.Context, store *Store, sessions sessionManager, onChanged func()) *controller { + return &controller{ + ctx: ctx, + store: store, + sessions: sessions, + onChanged: onChanged, + clientFor: func(socket, session string) sessionClient { return newClient(socket, session) }, + watchers: make(map[string]*watcher), + } +} + +// ReconcileAll starts a watcher for every existing card. Called on startup +// so the board reattaches to sessions still running in tmux. +func (c *controller) ReconcileAll() { + for _, card := range c.store.ListCards() { + c.Start(card) + } +} + +// Start ensures a watcher is running for the card. Idempotent. +func (c *controller) Start(card *Card) { + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.watchers[card.ID]; ok { + return + } + ctx, cancel := context.WithCancel(c.ctx) + w := &watcher{cancel: cancel, done: make(chan struct{})} + c.watchers[card.ID] = w + go func() { + defer close(w.done) + c.watch(ctx, card.ID) + }() +} + +// Stop cancels the card's watcher and waits for it to exit. Waiting matters: +// it guarantees the watcher cannot relaunch the session after the caller +// goes on to tear it down (kill the tmux session, remove the worktree), +// which would otherwise leave an orphaned session. +func (c *controller) Stop(cardID string) { + c.mu.Lock() + w, ok := c.watchers[cardID] + delete(c.watchers, cardID) + c.mu.Unlock() + if ok { + w.cancel() + <-w.done + } +} + +// watch keeps one card mirrored to its control plane: snapshot to resync, +// then tail events; on a drop, reconnect; if the agent is gone, relaunch and +// resume. +func (c *controller) watch(ctx context.Context, cardID string) { + for ctx.Err() == nil { + card, err := c.store.GetCard(cardID) + if errors.Is(err, ErrCardNotFound) { + return // card deleted + } + if err != nil { + if sleep(ctx, retryDelay) { + return + } + continue + } + client := c.clientFor(socketPath(card.AgentSession), card.AgentSession) + + sctx, scancel := context.WithTimeout(ctx, snapshotTimeout) + snap, err := client.Snapshot(sctx) + scancel() + if err != nil { + // The control plane is unreachable. If the agent's tmux pane is + // gone, relaunch to resume; otherwise it is still starting, so + // just wait and retry. + if alive, aerr := c.sessions.Alive(card.Session); aerr == nil && !alive { + _ = c.relaunch(card, "") + } + if sleep(ctx, retryDelay) { + return + } + continue + } + + if snap.Title != "" { + c.setTitle(cardID, snap.Title) + } + + // The control plane answers: the agent has started. If the card is + // still marked starting, default to waiting; the event replay below + // promptly corrects it if a turn is already underway. Checking the + // loop-top read is safe: this watcher is the only status writer. + if card.Status == StatusStarting { + c.setStatus(cardID, StatusWaiting) + } + + // Derive the running state from the event stream. Tail from the + // start of the buffer (since 0) so the whole backlog is replayed: a + // turn that began before this watcher connected is still seen and + // keeps the card running. + // + // A turn can spawn nested streams: every sub-agent and skill emits + // its own stream_started/stream_stopped pair. The depth keeps the + // card running until the outermost stream stops. Delivery of stream + // events is best-effort, so user_message — emitted only for real + // user turns, right before the turn's outermost stream_started — is + // the recovery point that resets a drifted depth. + depth := 0 + // failed marks that the current turn emitted an error event. It is + // applied immediately (the error event is delivered reliably, the + // stream_stopped that follows is not), and cleared when the + // outermost stop reports a "normal" completion or a new turn begins. + failed := false + // paused marks that the run loop is blocked on /pause. There is no + // matching resume event, so any subsequent event — the loop emits + // nothing while blocked — means the session resumed. + paused := false + + // Events at or below the snapshot's seq are replayed history. Their + // intermediate statuses must not be broadcast on every reconnect — a + // long-resolved error would flash the card red each time — so they + // only update the derived state, which is applied once, when the + // replay catches up with the snapshot. Replayed titles are dropped + // entirely: the snapshot's title already reflects them. + replaying := snap.LastEventSeq > 0 + var replayStatus CardStatus + flushReplay := func() { + replaying = false + if replayStatus != "" { + c.setStatus(cardID, replayStatus) + } + } + setStatus := func(status CardStatus) { + if replaying { + replayStatus = status + } else { + c.setStatus(cardID, status) + } + } + + exited := false + _ = client.StreamEvents(ctx, 0, func(ev event) bool { + if replaying && (ev.Seq == 0 || ev.Seq > snap.LastEventSeq) { + flushReplay() // past the snapshot: this event is live + } + switch ev.Type { + case eventGap: + return false // resume point evicted: reconnect and re-snapshot + case eventSessionExited: + exited = true + return false + case eventUserMessage: + // A new user turn begins: any leftover depth is drift from + // dropped stream events. Resync here so one lost stop cannot + // leave the card stuck running forever. + depth = 0 + failed = false + case eventStreamStarted: + failed = false + paused = false + depth++ + setStatus(StatusRunning) + case eventError: + failed = true + paused = false + setStatus(StatusError) + case eventStreamStopped: + paused = false + if depth > 0 { + depth-- + } + if depth == 0 { + // The outermost stream ended: a "normal" reason means + // the turn completed even if a nested sub-agent errored + // along the way, so the sticky error is cleared. Any + // other reason leaves a failed turn red. + if ev.Reason == reasonNormal { + failed = false + } + if !failed { + setStatus(StatusWaiting) + } + } + case eventRuntimePaused: + paused = true + setStatus(StatusPaused) + case eventSessionTitle: + if !replaying { + c.setTitle(cardID, ev.Title) + } + default: + // The run loop emits nothing while blocked on /pause, so any + // other event means the session resumed mid-turn. + if paused { + paused = false + setStatus(StatusRunning) + } + } + if replaying && ev.Seq == snap.LastEventSeq { + flushReplay() // caught up with the snapshot + } + return true + }) + + if exited && ctx.Err() == nil { + // The agent process ended; resume it so the card stays usable. + _ = c.relaunch(card, "") + } + if sleep(ctx, retryDelay) { + return + } + } +} + +// setStatus writes only the status field, and only on change, notifying so +// the UI refreshes. +func (c *controller) setStatus(cardID string, status CardStatus) { + if changed, err := c.store.UpdateCardStatus(cardID, status); err == nil && changed { + c.onChanged() + } +} + +// setTitle writes only the title field, and only on change. +func (c *controller) setTitle(cardID, title string) { + if changed, err := c.store.UpdateCardTitle(cardID, title); err == nil && changed { + c.onChanged() + } +} + +// Ready reports whether the card's agent control plane answers, i.e. the +// agent process has really started and its UI is worth attaching to; +// otherwise the session still shows the bare launch command. +func (c *controller) Ready(card *Card) bool { + client := c.clientFor(socketPath(card.AgentSession), card.AgentSession) + ctx, cancel := context.WithTimeout(c.ctx, readyProbeTimeout) + defer cancel() + _, err := client.Snapshot(ctx) + return err == nil +} + +// SendPrompt delivers a prompt to the card's agent through the control +// plane. The follow-up carries an idempotency key so the control plane can +// dedupe a retried delivery. If the follow-up fails only because the agent +// (or its tmux session) is gone, the session is relaunched with the prompt +// as its next message; any other failure (busy, queue full, timeout) is +// surfaced rather than destroying a live session. +func (c *controller) SendPrompt(card *Card, prompt string) error { + if prompt == "" { + return nil + } + + client := c.clientFor(socketPath(card.AgentSession), card.AgentSession) + ctx, cancel := context.WithTimeout(c.ctx, followupTimeout) + defer cancel() + if err := client.Followup(ctx, newID(), prompt); err == nil { + return nil + } else if alive, aerr := c.sessions.Alive(card.Session); aerr != nil || alive { + return fmt.Errorf("deliver prompt: %w", err) + } + + return c.relaunch(card, prompt) +} + +// relaunch recreates the card's tmux session under the same name, resuming +// the same docker-agent session (and its worktree) on the same control-plane +// socket. A non-empty prompt is delivered as the resumed session's next +// message. Launching from the worktree keeps the agent isolated even if +// docker-agent's own worktree reattachment does not happen. +func (c *controller) relaunch(card *Card, prompt string) error { + c.relaunchMu.Lock() + defer c.relaunchMu.Unlock() + + // The card may have been deleted while this relaunch was pending + // (SendPrompt runs outside the watcher, so Stop does not cover it). + // Teardown holds the same lock, so after this check the session cannot + // be resurrected behind a delete. + if _, err := c.store.GetCard(card.ID); err != nil { + return err + } + + // A concurrent relaunch may have already resurrected the session; a + // plain resume must not kill it (and drop its queued prompt) just to + // start over. A prompt-bearing relaunch proceeds: its prompt must be + // delivered, and the session it kills is one that just rejected the + // follow-up. + if prompt == "" { + if alive, err := c.sessions.Alive(card.Session); err == nil && alive { + return nil + } + } + + _ = c.sessions.KillSession(card.Session) + socket := socketPath(card.AgentSession) + // A killed agent leaves its control-plane socket file behind. Remove it + // so the resumed run can bind --listen; otherwise the new agent fails to + // start and the card stays stuck "starting". + _ = os.Remove(socket) + err := c.sessions.NewSession( + card.Session, card.Worktree, card.Agent, card.AgentSession, + socket, "", "", prompt, + ) + if err == nil { + // The agent is launching again: show it as starting until its + // control plane answers and the event stream drives the status. + c.setStatus(card.ID, StatusStarting) + } + return err +} + +// Teardown kills the card's tmux session under the relaunch lock, so an +// in-flight relaunch cannot recreate a session the caller is tearing down. +// The caller must have removed the card from the store first: that is what +// makes a later relaunch abort instead of resurrecting the session. +func (c *controller) Teardown(card *Card) { + c.relaunchMu.Lock() + defer c.relaunchMu.Unlock() + _ = c.sessions.KillSession(card.Session) +} + +// sleep waits for d or until ctx is done, reporting whether ctx was done. +func sleep(ctx context.Context, d time.Duration) bool { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return true + case <-t.C: + return false + } +} diff --git a/pkg/board/controller_test.go b/pkg/board/controller_test.go new file mode 100644 index 000000000..626bf3057 --- /dev/null +++ b/pkg/board/controller_test.go @@ -0,0 +1,202 @@ +package board + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeSessions struct{} + +func (fakeSessions) NewSession(_, _, _, _, _, _, _, _ string) error { return nil } +func (fakeSessions) KillSession(string) error { return nil } +func (fakeSessions) Alive(string) (bool, error) { return true, nil } + +// fakeClient replays a scripted event stream, then blocks like a live +// connection until the watcher is cancelled. +type fakeClient struct { + snap snapshot + events []event +} + +func (f *fakeClient) Snapshot(context.Context) (snapshot, error) { return f.snap, nil } + +func (f *fakeClient) Followup(context.Context, string, string) error { return nil } + +func (f *fakeClient) StreamEvents(ctx context.Context, _ uint64, onEvent func(event) bool) error { + for _, ev := range f.events { + if !onEvent(ev) { + return nil + } + } + <-ctx.Done() + return ctx.Err() +} + +// watchCard spins up a controller whose client replays the given events for +// a fresh card, and returns the store to observe the mirrored state. +func watchCard(t *testing.T, snap snapshot, events []event) (*Store, *controller) { + t.Helper() + store := testStore(t) + require.NoError(t, store.InsertCard(&Card{ID: "c1", Title: "Task", Column: "dev", Status: StatusStarting})) + + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + + c := newController(ctx, store, fakeSessions{}, func() {}) + c.clientFor = func(_, _ string) sessionClient { + return &fakeClient{snap: snap, events: events} + } + card, err := store.GetCard("c1") + require.NoError(t, err) + c.Start(card) + t.Cleanup(func() { c.Stop("c1") }) + return store, c +} + +func waitForStatus(t *testing.T, store *Store, want CardStatus) { + t.Helper() + assert.Eventually(t, func() bool { + card, err := store.GetCard("c1") + return err == nil && card.Status == want + }, 3*time.Second, 10*time.Millisecond, "expected status %s", want) +} + +func TestControllerRunningThenWaiting(t *testing.T) { + t.Parallel() + + store, _ := watchCard(t, snapshot{}, []event{ + {Type: eventUserMessage}, + {Type: eventStreamStarted}, + {Type: eventStreamStopped, Reason: reasonNormal}, + }) + waitForStatus(t, store, StatusWaiting) +} + +func TestControllerStaysRunningWithNestedStreams(t *testing.T) { + t.Parallel() + + store, _ := watchCard(t, snapshot{}, []event{ + {Type: eventStreamStarted}, + {Type: eventStreamStarted}, // sub-agent + {Type: eventStreamStopped, Reason: reasonNormal}, + }) + // The outer stream is still open: the card stays running. + waitForStatus(t, store, StatusRunning) +} + +func TestControllerErrorIsSticky(t *testing.T) { + t.Parallel() + + store, _ := watchCard(t, snapshot{}, []event{ + {Type: eventStreamStarted}, + {Type: eventError}, + {Type: eventStreamStopped, Reason: "error"}, + }) + waitForStatus(t, store, StatusError) +} + +func TestControllerNormalStopClearsSubAgentError(t *testing.T) { + t.Parallel() + + // A sub-agent error the parent recovered from: the outermost stop's + // "normal" reason is authoritative. + store, _ := watchCard(t, snapshot{}, []event{ + {Type: eventStreamStarted}, + {Type: eventError}, + {Type: eventStreamStopped, Reason: reasonNormal}, + }) + waitForStatus(t, store, StatusWaiting) +} + +func TestControllerPause(t *testing.T) { + t.Parallel() + + store, _ := watchCard(t, snapshot{}, []event{ + {Type: eventStreamStarted}, + {Type: eventRuntimePaused}, + }) + waitForStatus(t, store, StatusPaused) +} + +func TestControllerReplayAppliesFinalStatusOnly(t *testing.T) { + t.Parallel() + + // Replayed history contains a long-resolved error: only the state at the + // snapshot's seq lands in the store. + store, _ := watchCard(t, snapshot{LastEventSeq: 3}, []event{ + {Type: eventStreamStarted, Seq: 1}, + {Type: eventError, Seq: 2}, + {Type: eventStreamStopped, Reason: reasonNormal, Seq: 3}, + }) + waitForStatus(t, store, StatusWaiting) +} + +func TestControllerTitleFromSnapshot(t *testing.T) { + t.Parallel() + + store, _ := watchCard(t, snapshot{Title: "Real title"}, nil) + assert.Eventually(t, func() bool { + card, err := store.GetCard("c1") + return err == nil && card.Title == "Real title" + }, 3*time.Second, 10*time.Millisecond) +} + +// recordingSessions counts session creations so tests can assert whether a +// relaunch really happened. +type recordingSessions struct { + alive bool + newSessions int +} + +func (r *recordingSessions) NewSession(_, _, _, _, _, _, _, _ string) error { + r.newSessions++ + return nil +} +func (r *recordingSessions) KillSession(string) error { return nil } +func (r *recordingSessions) Alive(string) (bool, error) { return r.alive, nil } + +func TestRelaunchAbortsForDeletedCard(t *testing.T) { + t.Parallel() + + sessions := &recordingSessions{} + c := newController(t.Context(), testStore(t), sessions, func() {}) + + err := c.relaunch(&Card{ID: "gone", Session: "s"}, "prompt") + require.ErrorIs(t, err, ErrCardNotFound) + assert.Zero(t, sessions.newSessions) +} + +func TestRelaunchSkipsResurrectedSession(t *testing.T) { + t.Parallel() + + store := testStore(t) + require.NoError(t, store.InsertCard(&Card{ID: "c1", Session: "s"})) + sessions := &recordingSessions{alive: true} + c := newController(t.Context(), store, sessions, func() {}) + card, err := store.GetCard("c1") + require.NoError(t, err) + + // A plain resume backs off when the session is already alive again… + require.NoError(t, c.relaunch(card, "")) + assert.Zero(t, sessions.newSessions) + + // …but a prompt-bearing relaunch proceeds: its prompt must be delivered. + require.NoError(t, c.relaunch(card, "do it")) + assert.Equal(t, 1, sessions.newSessions) +} + +func TestControllerStopWaits(t *testing.T) { + t.Parallel() + + store, c := watchCard(t, snapshot{}, []event{{Type: eventStreamStarted}}) + waitForStatus(t, store, StatusRunning) + + c.Stop("c1") + // Stopping twice (or a never-watched card) is safe. + c.Stop("c1") + c.Stop("unknown") +} diff --git a/pkg/board/git.go b/pkg/board/git.go new file mode 100644 index 000000000..b05ab2929 --- /dev/null +++ b/pkg/board/git.go @@ -0,0 +1,171 @@ +package board + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "slices" + "strings" +) + +// removeWorktree removes a card's git worktree and its branch. Best-effort: +// the worktree may never have been created if the agent failed to start. +func removeWorktree(ctx context.Context, repoPath, worktreePath, branch string) { + cmd := exec.CommandContext(ctx, "git", "worktree", "remove", "--force", worktreePath) + cmd.Dir = repoPath + _ = cmd.Run() + + cmd = exec.CommandContext(ctx, "git", "branch", "-D", branch) + cmd.Dir = repoPath + _ = cmd.Run() +} + +// isGitRepo reports whether path is inside a git working tree. +func isGitRepo(ctx context.Context, path string) bool { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree") + cmd.Dir = path + return cmd.Run() == nil +} + +// worktreeDiff returns the full diff of all changes in the worktree relative +// to the merge-base with the upstream default branch. This includes +// committed, staged, unstaged, and untracked files. It never mutates the +// worktree. +func worktreeDiff(ctx context.Context, worktree string) (string, error) { + // The worktree may not exist yet while the agent is still starting up + // and docker-agent has not created it. Report no changes rather than an + // error; any other stat failure (permissions, corrupt path) is surfaced. + if _, err := os.Stat(worktree); os.IsNotExist(err) { + return "", nil + } else if err != nil { + return "", err + } + + base, err := runGit(ctx, worktree, "merge-base", "HEAD", upstreamBase(ctx, worktree)) + if err != nil { + return "", err + } + + // Mark untracked files as intent-to-add so they appear in the diff — in + // a throwaway copy of the index, so this read never mutates the + // worktree's real index (which would surprise git status, stash, …). + indexCopy, cleanup, err := copyIndex(ctx, worktree) + if err != nil { + return "", err + } + defer cleanup() + + env := append(os.Environ(), "GIT_INDEX_FILE="+indexCopy) + if _, err := runGitEnv(ctx, worktree, env, "add", "--intent-to-add", "."); err != nil { + return "", err + } + + return runGitEnv(ctx, worktree, env, "diff", strings.TrimSpace(base)) +} + +// copyIndex copies the worktree's git index to a temporary file and returns +// its path and a cleanup func. A missing index (fresh repository) yields an +// empty temporary index. +func copyIndex(ctx context.Context, worktree string) (string, func(), error) { + out, err := runGit(ctx, worktree, "rev-parse", "--path-format=absolute", "--git-path", "index") + if err != nil { + return "", nil, err + } + indexPath := strings.TrimSpace(out) + + tmp, err := os.CreateTemp("", "board-index-*") + if err != nil { + return "", nil, err + } + cleanup := func() { _ = os.Remove(tmp.Name()) } + + err = func() error { + defer func() { _ = tmp.Close() }() + src, err := os.Open(indexPath) + if os.IsNotExist(err) { + return nil // no index yet: start from an empty one + } + if err != nil { + return err + } + defer func() { _ = src.Close() }() + _, err = io.Copy(tmp, src) + return err + }() + if err != nil { + cleanup() + return "", nil, fmt.Errorf("copy index: %w", err) + } + + return tmp.Name(), cleanup, nil +} + +// upstreamBase returns the ref worktrees branch from and diffs compare +// against: the default branch of the repository's upstream remote, as +// "/". +// +// Remote names are not universal: some users keep the canonical repo as +// "origin", others name it "upstream" and point "origin" at their fork. So +// the remote is detected rather than assumed: a remote named "upstream" wins +// when present, otherwise "origin". The branch is read from the remote's +// recorded HEAD; when that is not recorded, the conventional default +// branches are probed. Repositories without a usable remote fall back to +// the local default branch, and finally HEAD, so diffs keep working in +// local-only repositories. +func upstreamBase(ctx context.Context, dir string) string { + remote := upstreamRemote(ctx, dir) + if out, err := runGit(ctx, dir, "symbolic-ref", "--short", "refs/remotes/"+remote+"/HEAD"); err == nil { + if ref := strings.TrimSpace(out); ref != "" { + return ref + } + } + for _, branch := range []string{"main", "master"} { + ref := remote + "/" + branch + if _, err := runGit(ctx, dir, "rev-parse", "--verify", "--quiet", "refs/remotes/"+ref); err == nil { + return ref + } + } + for _, branch := range []string{"main", "master"} { + if _, err := runGit(ctx, dir, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch); err == nil { + return branch + } + } + return "HEAD" +} + +// upstreamRemote returns "upstream" when the repository has a remote by that +// name, otherwise "origin". +func upstreamRemote(ctx context.Context, dir string) string { + out, err := runGit(ctx, dir, "remote") + if err != nil { + return "origin" + } + if slices.Contains(strings.Fields(out), "upstream") { + return "upstream" + } + return "origin" +} + +// runGit runs `git ` in dir and returns stdout, including stderr in +// any returned error. +func runGit(ctx context.Context, dir string, args ...string) (string, error) { + return runGitEnv(ctx, dir, nil, args...) +} + +// runGitEnv is runGit with an explicit environment (nil inherits the process +// environment). +func runGitEnv(ctx context.Context, dir string, env []string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + cmd.Env = env + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git %s: %s: %w", args[0], strings.TrimSpace(stderr.String()), err) + } + return string(out), nil +} diff --git a/pkg/board/git_test.go b/pkg/board/git_test.go new file mode 100644 index 000000000..343b6ae48 --- /dev/null +++ b/pkg/board/git_test.go @@ -0,0 +1,68 @@ +package board + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newLocalRepo creates a git repository with no remotes. +func newLocalRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + git(t, dir, "init", "-q", "-b", "main") + return dir +} + +func git(t *testing.T, dir string, args ...string) { + t.Helper() + args = append([]string{"-c", "user.email=test@test", "-c", "user.name=test", "-c", "commit.gpgsign=false"}, args...) + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) +} + +func TestUpstreamBaseFallsBackToLocalBranch(t *testing.T) { + t.Parallel() + ctx := t.Context() + + dir := newLocalRepo(t) + // Before the first commit no branch resolves: fall back to HEAD. + assert.Equal(t, "HEAD", upstreamBase(ctx, dir)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hi\n"), 0o644)) + git(t, dir, "add", ".") + git(t, dir, "commit", "-q", "-m", "initial") + + // No remotes: fall back to the local default branch. + assert.Equal(t, "main", upstreamBase(ctx, dir)) +} + +func TestWorktreeDiffInLocalOnlyRepo(t *testing.T) { + t.Parallel() + ctx := t.Context() + + dir := newLocalRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hi\n"), 0o644)) + git(t, dir, "add", ".") + git(t, dir, "commit", "-q", "-m", "initial") + + // Untracked and modified files show up even without any remote. + require.NoError(t, os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("changed\n"), 0o644)) + + diff, err := worktreeDiff(ctx, dir) + require.NoError(t, err) + assert.Contains(t, diff, "new.txt") + assert.Contains(t, diff, "changed") + + // A missing worktree is still reported as an empty diff. + diff, err = worktreeDiff(ctx, filepath.Join(dir, "missing")) + require.NoError(t, err) + assert.Empty(t, diff) +} diff --git a/pkg/board/lock_unix.go b/pkg/board/lock_unix.go new file mode 100644 index 000000000..00bb307e8 --- /dev/null +++ b/pkg/board/lock_unix.go @@ -0,0 +1,37 @@ +//go:build !windows + +package board + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "syscall" +) + +// lockFile keeps the flock'd file referenced for the process lifetime; if +// it were garbage collected, its finalizer would close the descriptor and +// drop the lock. +var lockFile *os.File + +// acquireLock takes an exclusive, non-blocking lock on path so only one +// board owns the cards and their sessions: a second instance would run its +// own watchers and race this one relaunching agents. The lock is held for +// the process lifetime and released by the OS on exit, so a crash never +// leaves a stale lock behind. +func acquireLock(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return fmt.Errorf("open board lock: %w", err) + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + return errors.New("another docker agent board is already running") + } + lockFile = f + return nil +} diff --git a/pkg/board/lock_unix_test.go b/pkg/board/lock_unix_test.go new file mode 100644 index 000000000..8bba55538 --- /dev/null +++ b/pkg/board/lock_unix_test.go @@ -0,0 +1,29 @@ +//go:build !windows + +package board + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAcquireLockIsExclusive(t *testing.T) { + path := filepath.Join(t.TempDir(), "board.lock") + + require.NoError(t, acquireLock(path)) + held := lockFile + t.Cleanup(func() { _ = held.Close(); lockFile = nil }) + + // A second acquisition (a second board) is rejected while the first + // lock is held. + err := acquireLock(path) + require.ErrorContains(t, err, "already running") + + // Releasing the first lock frees the state for the next instance. + require.NoError(t, held.Close()) + require.NoError(t, acquireLock(path)) + _ = lockFile.Close() + lockFile = nil +} diff --git a/pkg/board/lock_windows.go b/pkg/board/lock_windows.go new file mode 100644 index 000000000..d1e2b2811 --- /dev/null +++ b/pkg/board/lock_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package board + +// acquireLock is a no-op on Windows: the board requires tmux and cannot run +// there, but the package must still compile. +func acquireLock(string) error { return nil } diff --git a/pkg/board/model.go b/pkg/board/model.go new file mode 100644 index 000000000..7d69a2daf --- /dev/null +++ b/pkg/board/model.go @@ -0,0 +1,152 @@ +// Package board implements a Kanban board for orchestrating docker agents. +// Each card owns an agent running in a tmux session on an isolated git +// worktree; the board observes and drives the agent through the control +// plane its run exposes with --listen. Columns form a pipeline: moving a +// card forward sends the destination column's prompt to its agent. +package board + +import ( + "crypto/rand" + "encoding/hex" + "path/filepath" + "strings" + + "github.com/docker/docker-agent/pkg/paths" + "github.com/docker/docker-agent/pkg/userconfig" +) + +// Column is one kanban column with the prompt sent to a card's agent when +// the card moves forward into it. +type Column struct { + ID string + Name string + Emoji string + Prompt string +} + +// DefaultColumns is the pipeline used when the user config defines none. +var DefaultColumns = []Column{ + {ID: "dev", Name: "Dev", Emoji: "🔨"}, + {ID: "review", Name: "Review", Emoji: "🔍", Prompt: "Review the local changes. Look for bugs, security issues, and code quality problems. Fix any issues you find."}, + {ID: "push", Name: "Push", Emoji: "🚀", Prompt: "Start by committing any remaining uncommitted files. Then rebase on top of the upstream default branch and fix any test failures and linter issues. Finally, squash all commits on this branch into a single commit with a clear and concise commit message. Push the branch to your fork (or the appropriate remote). Then use gh to open a pull request."}, + {ID: "done", Name: "Done", Emoji: "✅"}, +} + +// ColumnsFromConfig maps user-configured columns to board columns, falling +// back to [DefaultColumns] when none are configured. +func ColumnsFromConfig(cols []userconfig.BoardColumn) []Column { + if len(cols) == 0 { + return DefaultColumns + } + out := make([]Column, 0, len(cols)) + for _, c := range cols { + out = append(out, Column{ID: c.ID, Name: c.Name, Emoji: c.Emoji, Prompt: c.Prompt}) + } + return out +} + +// CardStatus tracks what a card's agent is doing. +type CardStatus string + +const ( + // StatusStarting marks a card whose agent is launching but has not yet + // answered on its control plane. The watcher replaces it with a real + // status as soon as the agent emits events. + StatusStarting CardStatus = "starting" + StatusRunning CardStatus = "running" + StatusWaiting CardStatus = "waiting" + // StatusPaused marks a card whose turn is blocked on /pause. It lasts + // until the runtime emits events again (resume) or the turn ends. + StatusPaused CardStatus = "paused" + // StatusError marks a card whose last turn failed. It is sticky: the + // watcher keeps it until the next turn starts. + StatusError CardStatus = "error" +) + +// Busy reports whether the card's agent cannot accept a prompt right now: it +// is either still starting or in the middle of a turn. +func (s CardStatus) Busy() bool { + return s == StatusStarting || s == StatusRunning +} + +// Card is one task on the board. +type Card struct { + ID string `json:"id"` + Title string `json:"title"` + Column string `json:"column"` + Status CardStatus `json:"status"` + Project string `json:"project"` + Agent string `json:"agent"` + RepoPath string `json:"repoPath"` + Branch string `json:"branch"` + Worktree string `json:"worktree"` + Session string `json:"session"` + // AgentSession is the docker-agent conversation ID the card owns. It is + // passed to `docker agent run --session` on every launch, so a session + // recreated after the agent (or tmux) dies resumes the same conversation + // instead of starting over. + AgentSession string `json:"agentSession"` +} + +func newID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// newWorktreeName returns a unique worktree name for a card. docker-agent +// derives the worktree directory (/worktrees/) and branch +// (worktree-) from it, so the name must be a single path segment. +func newWorktreeName() string { + return "board-" + newID() +} + +// newSessionName returns a unique tmux session name for a card. +func newSessionName() string { + return "board-" + newID()[:8] +} + +// socketPath returns the unix socket a card's agent control plane listens +// on. It is derived from the (unique) docker-agent session id, so it is +// stable across board restarts and needs no extra storage. Kept short to +// stay under the ~104-byte unix sun_path limit. +func socketPath(agentSession string) string { + return filepath.Join(paths.GetDataDir(), "run", "board-"+agentSession+".sock") +} + +// worktreeDir returns the directory docker-agent creates for a worktree of +// the given name, mirroring its --worktree convention so the board can +// locate the worktree for diffs and cleanup. +func worktreeDir(name string) string { + return filepath.Join(paths.GetDataDir(), "worktrees", name) +} + +// worktreeBranch returns the branch docker-agent checks out for a worktree +// of the given name. +func worktreeBranch(name string) string { + return "worktree-" + name +} + +// placeholderTitle is the short temporary title shown until the agent emits +// its session_title event. It is the prompt's first line, trimmed and cut to +// a few words so a long prompt never becomes an unwieldy card title. +func placeholderTitle(prompt string) string { + title := prompt + if i := strings.IndexByte(title, '\n'); i >= 0 { + title = title[:i] + } + title = strings.TrimSpace(title) + + const maxLen = 40 + runes := []rune(title) + if len(runes) <= maxLen { + return title + } + + cut := string(runes[:maxLen]) + // Prefer a word boundary so the title does not end mid-word. + if i := strings.LastIndexByte(cut, ' '); i > 0 { + cut = cut[:i] + } + return strings.TrimSpace(cut) + "…" +} diff --git a/pkg/board/model_test.go b/pkg/board/model_test.go new file mode 100644 index 000000000..58d5736b0 --- /dev/null +++ b/pkg/board/model_test.go @@ -0,0 +1,55 @@ +package board + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/docker/docker-agent/pkg/userconfig" +) + +func TestPlaceholderTitle(t *testing.T) { + t.Parallel() + + assert.Equal(t, "Fix the bug", placeholderTitle("Fix the bug")) + assert.Equal(t, "First line", placeholderTitle("First line\nsecond line")) + assert.Equal(t, "Trimmed", placeholderTitle(" Trimmed ")) + + long := placeholderTitle(strings.Repeat("word ", 20)) + assert.LessOrEqual(t, len([]rune(long)), 41) + assert.True(t, strings.HasSuffix(long, "…")) + + // A long word without boundaries is cut mid-word. + assert.True(t, strings.HasSuffix(placeholderTitle(strings.Repeat("a", 50)), "…")) +} + +func TestColumnsFromConfig(t *testing.T) { + t.Parallel() + + assert.Equal(t, DefaultColumns, ColumnsFromConfig(nil)) + + cols := ColumnsFromConfig([]userconfig.BoardColumn{ + {ID: "todo", Name: "Todo", Emoji: "📝", Prompt: "do it"}, + }) + assert.Equal(t, []Column{{ID: "todo", Name: "Todo", Emoji: "📝", Prompt: "do it"}}, cols) +} + +func TestCardStatusBusy(t *testing.T) { + t.Parallel() + + assert.True(t, StatusStarting.Busy()) + assert.True(t, StatusRunning.Busy()) + assert.False(t, StatusWaiting.Busy()) + assert.False(t, StatusPaused.Busy()) + assert.False(t, StatusError.Busy()) +} + +func TestNewWorktreeName(t *testing.T) { + t.Parallel() + + name := newWorktreeName() + assert.True(t, strings.HasPrefix(name, "board-")) + assert.NotContains(t, name, "/") + assert.NotEqual(t, name, newWorktreeName()) +} diff --git a/pkg/board/store.go b/pkg/board/store.go new file mode 100644 index 000000000..f75cb8b66 --- /dev/null +++ b/pkg/board/store.go @@ -0,0 +1,181 @@ +package board + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sync" + + "github.com/docker/docker-agent/pkg/atomicfile" + "github.com/docker/docker-agent/pkg/paths" +) + +// ErrCardNotFound reports a lookup of a card that does not exist (anymore). +var ErrCardNotFound = errors.New("card not found") + +// ErrCardBusy rejects a forward move of a busy card. It is checked under the +// store lock so a watcher flipping the status concurrently cannot slip a +// running card past the caller's check. +var ErrCardBusy = errors.New("cannot move a busy card forward") + +// Store persists the board's cards as a JSON file under the data directory. +// All methods are safe for concurrent use and return copies, so callers and +// background watchers can never alias the same Card. +type Store struct { + mu sync.Mutex + path string + cards []*Card +} + +// StatePath returns the file the board persists its cards to. +func StatePath() string { + return filepath.Join(paths.GetDataDir(), "board", "cards.json") +} + +// OpenStore loads the card store from path, starting empty when the file +// does not exist yet. +func OpenStore(path string) (*Store, error) { + s := &Store{path: path} + + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("read board state: %w", err) + } + if err := json.Unmarshal(data, &s.cards); err != nil { + return nil, fmt.Errorf("parse board state %s: %w", path, err) + } + return s, nil +} + +// save persists the cards. Callers must hold s.mu. +func (s *Store) save() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil { + return err + } + data, err := json.MarshalIndent(s.cards, "", " ") + if err != nil { + return err + } + return atomicfile.Write(s.path, bytes.NewReader(data), 0o644) +} + +// indexOf returns the position of the card with the given id, or -1. +// Callers must hold s.mu. +func (s *Store) indexOf(id string) int { + return slices.IndexFunc(s.cards, func(c *Card) bool { return c.ID == id }) +} + +// ListCards returns all cards in board order. +func (s *Store) ListCards() []*Card { + s.mu.Lock() + defer s.mu.Unlock() + cards := make([]*Card, 0, len(s.cards)) + for _, c := range s.cards { + clone := *c + cards = append(cards, &clone) + } + return cards +} + +// GetCard returns the card with the given id. +func (s *Store) GetCard(id string) (*Card, error) { + s.mu.Lock() + defer s.mu.Unlock() + i := s.indexOf(id) + if i < 0 { + return nil, fmt.Errorf("%w: %s", ErrCardNotFound, id) + } + clone := *s.cards[i] + return &clone, nil +} + +// InsertCard appends a card to the board. +func (s *Store) InsertCard(c *Card) error { + s.mu.Lock() + defer s.mu.Unlock() + clone := *c + s.cards = append(s.cards, &clone) + return s.save() +} + +// UpdateCardStatus persists only the status field of a card, so background +// watchers holding a stale snapshot of the card cannot revert concurrent +// edits. It reports whether the status actually changed. +func (s *Store) UpdateCardStatus(id string, status CardStatus) (bool, error) { + return s.updateField(id, func(c *Card) bool { + if c.Status == status { + return false + } + c.Status = status + return true + }) +} + +// UpdateCardTitle persists only the title field of a card. Same rationale as +// [Store.UpdateCardStatus]. It reports whether the title actually changed. +func (s *Store) UpdateCardTitle(id, title string) (bool, error) { + return s.updateField(id, func(c *Card) bool { + if c.Title == title { + return false + } + c.Title = title + return true + }) +} + +func (s *Store) updateField(id string, update func(*Card) bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + i := s.indexOf(id) + if i < 0 { + return false, fmt.Errorf("%w: %s", ErrCardNotFound, id) + } + if !update(s.cards[i]) { + return false, nil + } + return true, s.save() +} + +// DeleteCard removes a card. Deleting a missing card is a no-op. +func (s *Store) DeleteCard(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + i := s.indexOf(id) + if i < 0 { + return nil + } + s.cards = slices.Delete(s.cards, i, i+1) + return s.save() +} + +// MoveCard atomically moves a card to the given column and re-inserts it at +// the end of the board order. The card is re-read under the lock so the move +// preserves the current status; when requireIdle is set, a card whose +// watcher concurrently flipped it to busy is rejected with [ErrCardBusy]. +// The updated card is returned. +func (s *Store) MoveCard(id, column string, requireIdle bool) (*Card, error) { + s.mu.Lock() + defer s.mu.Unlock() + i := s.indexOf(id) + if i < 0 { + return nil, fmt.Errorf("%w: %s", ErrCardNotFound, id) + } + card := s.cards[i] + if requireIdle && card.Status.Busy() { + return nil, ErrCardBusy + } + card.Column = column + s.cards = append(slices.Delete(s.cards, i, i+1), card) + if err := s.save(); err != nil { + return nil, err + } + clone := *card + return &clone, nil +} diff --git a/pkg/board/store_test.go b/pkg/board/store_test.go new file mode 100644 index 000000000..e7265d2de --- /dev/null +++ b/pkg/board/store_test.go @@ -0,0 +1,119 @@ +package board + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testStore(t *testing.T) *Store { + t.Helper() + s, err := OpenStore(filepath.Join(t.TempDir(), "cards.json")) + require.NoError(t, err) + return s +} + +func TestStoreRoundTrip(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cards.json") + s, err := OpenStore(path) + require.NoError(t, err) + + card := &Card{ID: "c1", Title: "Task", Column: "dev", Status: StatusStarting} + require.NoError(t, s.InsertCard(card)) + + // Reopen from disk. + s2, err := OpenStore(path) + require.NoError(t, err) + got, err := s2.GetCard("c1") + require.NoError(t, err) + assert.Equal(t, card, got) +} + +func TestStoreReturnsCopies(t *testing.T) { + t.Parallel() + + s := testStore(t) + require.NoError(t, s.InsertCard(&Card{ID: "c1", Title: "Task", Column: "dev"})) + + got, err := s.GetCard("c1") + require.NoError(t, err) + got.Title = "mutated" + + fresh, err := s.GetCard("c1") + require.NoError(t, err) + assert.Equal(t, "Task", fresh.Title) +} + +func TestStoreUpdateFields(t *testing.T) { + t.Parallel() + + s := testStore(t) + require.NoError(t, s.InsertCard(&Card{ID: "c1", Title: "Task", Column: "dev", Status: StatusStarting})) + + changed, err := s.UpdateCardStatus("c1", StatusRunning) + require.NoError(t, err) + assert.True(t, changed) + + // No-op update reports unchanged. + changed, err = s.UpdateCardStatus("c1", StatusRunning) + require.NoError(t, err) + assert.False(t, changed) + + changed, err = s.UpdateCardTitle("c1", "Renamed") + require.NoError(t, err) + assert.True(t, changed) + + got, err := s.GetCard("c1") + require.NoError(t, err) + assert.Equal(t, StatusRunning, got.Status) + assert.Equal(t, "Renamed", got.Title) + + _, err = s.UpdateCardStatus("missing", StatusRunning) + assert.ErrorIs(t, err, ErrCardNotFound) +} + +func TestStoreMoveCard(t *testing.T) { + t.Parallel() + + s := testStore(t) + require.NoError(t, s.InsertCard(&Card{ID: "c1", Column: "dev", Status: StatusWaiting})) + require.NoError(t, s.InsertCard(&Card{ID: "c2", Column: "dev", Status: StatusRunning})) + + // Moving re-inserts the card at the end of the board order. + moved, err := s.MoveCard("c1", "review", true) + require.NoError(t, err) + assert.Equal(t, "review", moved.Column) + assert.Equal(t, StatusWaiting, moved.Status) + + cards := s.ListCards() + require.Len(t, cards, 2) + assert.Equal(t, "c2", cards[0].ID) + assert.Equal(t, "c1", cards[1].ID) + + // A busy card cannot move forward. + _, err = s.MoveCard("c2", "review", true) + require.ErrorIs(t, err, ErrCardBusy) + + // But it can move backward (requireIdle false). + moved, err = s.MoveCard("c2", "done", false) + require.NoError(t, err) + assert.Equal(t, "done", moved.Column) +} + +func TestStoreDeleteCard(t *testing.T) { + t.Parallel() + + s := testStore(t) + require.NoError(t, s.InsertCard(&Card{ID: "c1", Column: "dev"})) + + require.NoError(t, s.DeleteCard("c1")) + _, err := s.GetCard("c1") + require.ErrorIs(t, err, ErrCardNotFound) + + // Deleting a missing card is a no-op. + require.NoError(t, s.DeleteCard("c1")) +} diff --git a/pkg/board/tmux.go b/pkg/board/tmux.go new file mode 100644 index 000000000..baa27735a --- /dev/null +++ b/pkg/board/tmux.go @@ -0,0 +1,241 @@ +package board + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" +) + +// tmuxSessions manages the tmux sessions the board runs its agents in. +type tmuxSessions struct { + // ctx is the board-lifetime context tmux commands run under. + ctx context.Context //nolint:containedctx // sessionManager methods are context-free +} + +// sessionManager abstracts tmux so tests can inject a fake. +type sessionManager interface { + // NewSession creates a tmux session running the docker agent for the + // given docker-agent session ID, from workDir. The agent exposes its + // control plane on listenSocket. A non-empty worktreeName marks the + // first run: docker agent creates an isolated worktree of that name, + // branched from worktreeBase, and workDir is the repository. On resume, + // worktreeName is empty and workDir is the worktree directory. A + // non-empty prompt is sent as the first message. + NewSession(name, workDir, agent, sessionID, listenSocket, worktreeName, worktreeBase, prompt string) error + KillSession(name string) error + // Alive reports whether the session exists and its agent pane is still + // running. It lets the controller tell a control plane that is merely + // slow to start from a session whose agent has died and must be + // relaunched. + Alive(name string) (bool, error) +} + +// tmuxSocketDir creates and validates, once per process, the per-user +// directory holding the board's private tmux socket. The checks fail +// closed: a pre-existing path owned by another user, or not a real +// directory, must never be used for the socket. +var tmuxSocketDir = sync.OnceValues(func() (string, error) { + dir := filepath.Join(os.TempDir(), "cagent-board-"+strconv.Itoa(os.Getuid())) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create tmux socket dir: %w", err) + } + info, err := os.Lstat(dir) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("tmux socket dir %s is not a directory", dir) + } + if err := checkOwner(dir, info); err != nil { + return "", err + } + // Tighten a dir that pre-existed with looser permissions; ownership was + // verified above, so chmod cannot be tricked into loosening someone + // else's directory. + if info.Mode().Perm() != 0o700 { + if err := os.Chmod(dir, 0o700); err != nil { //nolint:gosec // 0700 is the tightest usable mode for a directory + return "", fmt.Errorf("tighten tmux socket dir permissions: %w", err) + } + } + return dir, nil +}) + +// TmuxSocketPath is the dedicated tmux socket the board runs its sessions +// on. The board shares the host's tmux binary but not its default server: a +// private socket keeps the board's server-wide options from leaking into the +// user's interactive tmux. The path is stable across board restarts so the +// controller can reattach to sessions left running on it. +// +// Like tmux's own /tmp/tmux- convention, the socket lives in a +// per-user 0700 directory so other local users cannot pre-create or reach +// it. The directory is created and validated before tmux binds the socket. +func TmuxSocketPath() (string, error) { + dir, err := tmuxSocketDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "tmux.sock"), nil +} + +// serverDefaults are tmux options the board applies to its private server so +// every session feels like a native terminal when attached: no tmux chrome, +// keys passed straight through, full terminal fidelity, and client-driven +// sizing. +var serverDefaults = [][]string{ + // Visual chrome: hide every bit of tmux UI. + {"set", "-g", "status", "off"}, + {"set", "-g", "bell-action", "none"}, + {"set", "-g", "monitor-activity", "off"}, + {"set", "-g", "monitor-bell", "off"}, + + // Input behavior: every keystroke reaches the agent, ESC is instant. + // With no prefix bound, C-b reaches the agent too. extended-keys + // forwards CSI-u sequences so modified keys reach the agent. + {"set", "-g", "prefix", "none"}, + {"set", "-g", "prefix2", "none"}, + {"set", "-g", "escape-time", "0"}, + {"set", "-g", "mouse", "on"}, + {"set", "-g", "extended-keys", "always"}, + + // Terminal fidelity: truecolor, clipboard, focus events. + {"set", "-g", "allow-passthrough", "on"}, + {"set", "-g", "focus-events", "on"}, + {"set", "-g", "set-clipboard", "on"}, + {"set", "-g", "default-terminal", "tmux-256color"}, + {"set", "-g", "terminal-features", ",xterm-256color:clipboard:ccolour:cstyle:extkeys:focus:title:mouse:RGB"}, + + // Sizing: follow the attached client, not the smallest one. + {"set", "-g", "aggressive-resize", "on"}, + {"set", "-g", "window-size", "latest"}, + + // With no prefix bound, this is the one key the board reserves: it + // detaches the client and returns the user to the board. + {"bind-key", "-n", "C-q", "detach-client"}, +} + +// tmuxRun runs `tmux -S ` against the board's private +// server and returns combined output as part of any error. +func tmuxRun(ctx context.Context, args ...string) (string, error) { + socket, err := TmuxSocketPath() + if err != nil { + return "", err + } + out, err := exec.CommandContext(ctx, "tmux", append([]string{"-S", socket}, args...)...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("tmux %s: %s: %w", args[0], strings.TrimSpace(string(out)), err) + } + return string(out), nil +} + +// applyServerDefaults applies serverDefaults to the board's private server. +func applyServerDefaults(ctx context.Context) { + for _, args := range serverDefaults { + _, _ = tmuxRun(ctx, args...) + } +} + +// agentCommand builds the docker agent invocation for a session. The board +// owns sessionID and passes it via --session: the first run creates that +// session, later runs resume it. +// +// --listen exposes the run's control plane on listenSocket (a unix socket +// the board owns), so the board can observe and drive the session over HTTP +// instead of scraping the terminal. +// +// On the first run, worktreeName is non-empty: --worktree creates an +// isolated git worktree (branched from worktreeBase) and every tool runs +// inside it. On resume, worktreeName is empty and --worktree is omitted: +// docker agent reattaches the session to its original worktree +// automatically, so passing --worktree again (which would fail, the worktree +// already exists) is avoided. +func agentCommand(agent, sessionID, listenSocket, worktreeName, worktreeBase, prompt string) string { + // Launch through the current binary rather than assuming a `docker + // agent` plugin is installed; the binary supports direct invocation. + bin, err := os.Executable() + if err != nil { + bin = "docker-agent" + } + cmd := fmt.Sprintf("%s run %s --yolo --session %s --listen %s", + shQuote(bin), shQuote(agent), shQuote(sessionID), shQuote("unix://"+listenSocket)) + if worktreeName != "" { + cmd += fmt.Sprintf(" --worktree=%s --worktree-base %s", shQuote(worktreeName), shQuote(worktreeBase)) + } + if prompt != "" { + cmd += " " + shQuote(prompt) + } + return cmd +} + +// shQuote single-quotes s for POSIX shells. +func shQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// NewSession creates a tmux session and runs docker agent in it. The pane +// briefly starts the user's shell (new-session), remain-on-exit is set, and +// the pane is then respawned with the agent as its process. respawn-pane +// avoids typing the command into the user's interactive shell (send-keys): +// no shell-history pollution, no dependence on the shell supporting `exec`, +// and no race with slow shell startup swallowing the input. When the agent +// exits the pane goes dead instead of dropping back to a shell (see +// remain-on-exit), so the user can read its final output and the controller +// can detect the dead pane and relaunch. +func (t tmuxSessions) NewSession(name, workDir, agent, sessionID, listenSocket, worktreeName, worktreeBase, prompt string) error { + if _, err := tmuxRun(t.ctx, "new-session", "-d", "-s", name, "-c", workDir); err != nil { + return err + } + + applyServerDefaults(t.ctx) + + // Keep the pane (and thus the session) alive after the agent exits. Set + // before the agent replaces the pane so there is no race where the + // agent could exit before the option takes effect. + _, _ = tmuxRun(t.ctx, "set-option", "-t", name, "remain-on-exit", "on") + + // exec makes the agent the pane's process (replacing the /bin/sh tmux + // runs the command with): when it exits the pane goes dead. + cmd := "exec " + agentCommand(agent, sessionID, listenSocket, worktreeName, worktreeBase, prompt) + if _, err := tmuxRun(t.ctx, "respawn-pane", "-k", "-t", name, "-c", workDir, cmd); err != nil { + return err + } + return nil +} + +// KillSession kills a tmux session. A missing session is not an error. +func (t tmuxSessions) KillSession(name string) error { + _, err := tmuxRun(t.ctx, "kill-session", "-t", "="+name) + if err != nil && isNoSuchSession(err) { + return nil + } + return err +} + +// Alive reports whether the session exists and its agent pane is still +// running. A pane goes dead when the agent exits (remain-on-exit keeps the +// session around); a missing session reports not alive. +func (t tmuxSessions) Alive(name string) (bool, error) { + out, err := tmuxRun(t.ctx, "list-panes", "-t", "="+name, "-F", "#{pane_dead}") + if err != nil { + if isNoSuchSession(err) { + return false, nil + } + return false, err + } + first, _, _ := strings.Cut(strings.TrimSpace(out), "\n") + return first == "0", nil +} + +// isNoSuchSession matches tmux's errors for a missing session or a server +// that is not running at all. +func isNoSuchSession(err error) bool { + msg := err.Error() + return strings.Contains(msg, "can't find session") || + strings.Contains(msg, "no server running") || + strings.Contains(msg, "No such file or directory") || + strings.Contains(msg, "error connecting to") +} diff --git a/pkg/board/tmux_test.go b/pkg/board/tmux_test.go new file mode 100644 index 000000000..54e52cfc8 --- /dev/null +++ b/pkg/board/tmux_test.go @@ -0,0 +1,31 @@ +package board + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShQuote(t *testing.T) { + t.Parallel() + + assert.Equal(t, "'plain'", shQuote("plain")) + assert.Equal(t, `'it'\''s'`, shQuote("it's")) + assert.Equal(t, "'two words'", shQuote("two words")) +} + +func TestAgentCommand(t *testing.T) { + t.Parallel() + + // First run: creates the worktree from the base. + cmd := agentCommand("coder", "sess1", "/tmp/a.sock", "board-abc", "origin/main", "do the thing") + assert.Contains(t, cmd, " run 'coder' --yolo --session 'sess1' --listen 'unix:///tmp/a.sock'") + assert.Contains(t, cmd, "--worktree='board-abc' --worktree-base 'origin/main'") + assert.True(t, strings.HasSuffix(cmd, " 'do the thing'")) + + // Resume: no worktree flags, no prompt. + cmd = agentCommand("coder", "sess1", "/tmp/a.sock", "", "", "") + assert.NotContains(t, cmd, "--worktree") + assert.True(t, strings.HasSuffix(cmd, "--listen 'unix:///tmp/a.sock'")) +} diff --git a/pkg/board/tmux_unix.go b/pkg/board/tmux_unix.go new file mode 100644 index 000000000..7cd3b9438 --- /dev/null +++ b/pkg/board/tmux_unix.go @@ -0,0 +1,23 @@ +//go:build !windows + +package board + +import ( + "fmt" + "os" + "syscall" +) + +// checkOwner verifies that the tmux socket directory belongs to the current +// user, so the board never binds its private tmux server inside a directory +// another local user pre-created. +func checkOwner(dir string, info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return nil + } + if int(stat.Uid) != os.Getuid() { + return fmt.Errorf("tmux socket dir %s is not owned by the current user", dir) + } + return nil +} diff --git a/pkg/board/tmux_windows.go b/pkg/board/tmux_windows.go new file mode 100644 index 000000000..b0ae386e4 --- /dev/null +++ b/pkg/board/tmux_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package board + +import "os" + +// checkOwner is a no-op on Windows: the board requires tmux and cannot run +// there, but the package must still compile. +func checkOwner(string, os.FileInfo) error { return nil } diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go new file mode 100644 index 000000000..2df0cd4d6 --- /dev/null +++ b/pkg/board/tui/dialogs.go @@ -0,0 +1,589 @@ +package tui + +import ( + "path/filepath" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/docker/docker-agent/pkg/board" + "github.com/docker/docker-agent/pkg/tui/components/scrollbar" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + tuidialog "github.com/docker/docker-agent/pkg/tui/dialog" + "github.com/docker/docker-agent/pkg/tui/styles" + "github.com/docker/docker-agent/pkg/userconfig" +) + +// closeDialog is the command every dialog uses to dismiss itself. +func closeDialog() tea.Msg { return closeDialogMsg{} } + +// configPathHint is where the board's configuration lives. +func configPathHint() string { return userconfig.Path() } + +// dialogWidth clamps a preferred dialog width to the terminal. +func dialogWidth(preferred, termWidth int) int { + return max(min(preferred, termWidth-4), 24) +} + +// renderDialog renders a titled dialog box with the same chrome as the +// main TUI's dialogs (centered title, DialogStyle border and padding). +func renderDialog(title string, width int, sections ...string) string { + content := tuidialog.NewContent(width).AddTitle(title).AddSpace() + for _, s := range sections { + content.AddContent(s) + } + return styles.DialogStyle.Render(content.Build()) +} + +// helpLine renders key-binding help in the same style as the main TUI's +// dialogs. bindings are (key, description) pairs. +func helpLine(width int, bindings ...string) string { + return tuidialog.RenderHelpKeys(width, bindings...) +} + +// --- new card dialog --- + +// cardDialog collects the project and the first prompt of a new card. +type cardDialog struct { + projects []board.Project + projIdx int + prompt textarea.Model +} + +// newCardDialog collects the project and the first prompt of a new card. +// The selector starts on lastProject (by name) when it is still configured. +func newCardDialog(projects []board.Project, lastProject string) *cardDialog { + ta := textarea.New() + ta.SetStyles(styles.InputStyle) + ta.Placeholder = "Describe the task for the agent…" + ta.ShowLineNumbers = false + ta.SetHeight(6) + // Enter inserts a newline; the card is submitted with cmd+enter + // (super+enter in CSI-u terms), or ctrl+s for terminals without the + // Kitty keyboard protocol. + ta.KeyMap.InsertNewline.SetKeys("enter") + ta.Focus() + + d := &cardDialog{projects: projects, prompt: ta} + for i, p := range projects { + if p.Name == lastProject { + d.projIdx = i + break + } + } + return d +} + +func (d *cardDialog) Init() tea.Cmd { return textarea.Blink } + +func (d *cardDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { + if msg, ok := msg.(tea.KeyPressMsg); ok { + switch msg.String() { + case "esc": + return d, closeDialog + case "tab": + if len(d.projects) > 1 { + d.projIdx = (d.projIdx + 1) % len(d.projects) + return d, nil + } + case "shift+tab": + if len(d.projects) > 1 { + d.projIdx = (d.projIdx + len(d.projects) - 1) % len(d.projects) + return d, nil + } + case "super+enter", "ctrl+s": + prompt := strings.TrimSpace(d.prompt.Value()) + if prompt == "" { + return d, nil + } + project := d.projects[d.projIdx] + return d, func() tea.Msg { return submitNewCardMsg{project: project, prompt: prompt} } + } + } + var cmd tea.Cmd + d.prompt, cmd = d.prompt.Update(msg) + return d, cmd +} + +func (d *cardDialog) View(width, height int) string { + w := dialogWidth(100, width) + d.prompt.SetWidth(w) + // Give the prompt most of the screen: long task descriptions are the + // norm, not the exception. + d.prompt.SetHeight(max(min(height-12, 24), 6)) + + sections := []string{ + d.prompt.View(), + "", + } + hints := []string{"⌘+enter", "create", "enter", "newline", "esc", "cancel"} + + // With a single project there is nothing to choose: skip the selector. + if len(d.projects) > 1 { + chips := make([]string, 0, len(d.projects)) + for i, p := range d.projects { + style := styles.MutedStyle + if i == d.projIdx { + style = styles.BaseStyle.Foreground(projectColorAt(i)).Bold(true).Underline(true) + } + chips = append(chips, style.Render(sanitize(p.Name))) + } + projectLine := styles.SecondaryStyle.Render("Project ") + strings.Join(chips, styles.MutedStyle.Render(" · ")) + sections = append([]string{toolcommon.TruncateText(projectLine, w), ""}, sections...) + hints = []string{"⌘+enter", "create", "enter", "newline", "tab", "project", "esc", "cancel"} + } + + sections = append(sections, helpLine(w, hints...)) + return renderDialog("New card · "+sanitize(d.projects[d.projIdx].Name), w, sections...) +} + +// --- column prompt editor --- + +// promptDialog edits the prompt a column sends to incoming cards; the result +// is persisted to the user's global config file. +type promptDialog struct { + column board.Column + prompt textarea.Model +} + +func newPromptDialog(column board.Column) *promptDialog { + ta := textarea.New() + ta.SetStyles(styles.InputStyle) + ta.Placeholder = "Prompt sent to a card's agent when it enters " + column.Name + "…" + ta.ShowLineNumbers = false + ta.SetHeight(10) + ta.SetValue(column.Prompt) + ta.Focus() + return &promptDialog{column: column, prompt: ta} +} + +func (d *promptDialog) Init() tea.Cmd { return textarea.Blink } + +func (d *promptDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { + if msg, ok := msg.(tea.KeyPressMsg); ok { + switch msg.String() { + case "esc": + return d, closeDialog + case "super+enter", "ctrl+s": + colID, prompt := d.column.ID, strings.TrimSpace(d.prompt.Value()) + return d, func() tea.Msg { return submitPromptMsg{colID: colID, prompt: prompt} } + } + } + var cmd tea.Cmd + d.prompt, cmd = d.prompt.Update(msg) + return d, cmd +} + +func (d *promptDialog) View(width, height int) string { + w := dialogWidth(90, width) + d.prompt.SetWidth(w) + d.prompt.SetHeight(max(min(height-10, 16), 4)) + return renderDialog(strings.TrimSpace(d.column.Emoji+" "+d.column.Name)+" · column prompt", w, + d.prompt.View(), + "", + helpLine(w, "⌘+enter", "save", "esc", "cancel"), + ) +} + +// --- projects manager --- + +// projectsMode is the projects dialog's active view. +type projectsMode int + +const ( + projectsList projectsMode = iota + projectsPicking + projectsAdding +) + +// projectsDialog lists and edits the projects stored in the user's global +// config file. Adding a project starts with a directory picker, then a +// pre-filled form. +type projectsDialog struct { + projects []board.Project + idx int + + mode projectsMode + picker *dirPicker + inputs []textinput.Model // name, path, agent + focus int +} + +func newProjectsDialog(projects []board.Project) *projectsDialog { + return &projectsDialog{projects: projects} +} + +// setProjects refreshes the list after an add or delete, leaving the list +// view visible and keeping the cursor position (clamped). +func (d *projectsDialog) setProjects(projects []board.Project) { + d.projects = projects + d.idx = min(max(d.idx, 0), max(len(projects)-1, 0)) + d.mode = projectsList +} + +var projectFields = []struct{ label, placeholder string }{ + {"Name", "my-project"}, + {"Path", "/path/to/git/repository"}, + {"Agent", "default (or any agent ref)"}, +} + +// startAdding opens the form, pre-filled from the picked directory. +func (d *projectsDialog) startAdding(name, path string) tea.Cmd { + d.mode = projectsAdding + d.focus = 0 + d.inputs = make([]textinput.Model, len(projectFields)) + for i, f := range projectFields { + ti := textinput.New() + ti.SetStyles(styles.DialogInputStyle) + ti.Placeholder = f.placeholder + ti.SetWidth(56) + d.inputs[i] = ti + } + d.inputs[0].SetValue(name) + d.inputs[1].SetValue(path) + d.inputs[0].Focus() + return textinput.Blink +} + +func (d *projectsDialog) Init() tea.Cmd { return nil } + +func (d *projectsDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { + press, ok := msg.(tea.KeyPressMsg) + if !ok { + return d, nil + } + switch d.mode { + case projectsPicking: + return d.updatePicking(press) + case projectsAdding: + return d.updateAdding(press) + } + + switch press.String() { + case "esc", "q": + return d, closeDialog + case "up", "k": + d.idx = max(d.idx-1, 0) + case "down", "j": + d.idx = min(d.idx+1, max(len(d.projects)-1, 0)) + case "a", "n": + d.mode = projectsPicking + d.picker = newDirPicker(pickerStartDir("")) + return d, textinput.Blink + case "x", "d", "backspace", "delete": + if len(d.projects) > 0 { + name := d.projects[d.idx].Name + return d, func() tea.Msg { return deleteProjectMsg{name: name} } + } + } + return d, nil +} + +// updatePicking drives the directory picker; a picked directory pre-fills +// the add form. +func (d *projectsDialog) updatePicking(press tea.KeyPressMsg) (dialog, tea.Cmd) { + chosen, done, cmd := d.picker.Update(press) + switch { + case chosen != "": + cmd := d.startAdding(filepath.Base(chosen), chosen) + return d, cmd + case done: + d.mode = projectsList + } + return d, cmd +} + +func (d *projectsDialog) updateAdding(press tea.KeyPressMsg) (dialog, tea.Cmd) { + switch press.String() { + case "esc": + d.mode = projectsList + return d, nil + case "ctrl+o": + // Re-open the browser, starting from the path typed so far. + d.mode = projectsPicking + d.picker = newDirPicker(pickerStartDir(strings.TrimSpace(d.inputs[1].Value()))) + return d, textinput.Blink + case "tab", "down": + cmd := d.setFocus((d.focus + 1) % len(d.inputs)) + return d, cmd + case "shift+tab", "up": + cmd := d.setFocus((d.focus + len(d.inputs) - 1) % len(d.inputs)) + return d, cmd + case "enter": + project := board.Project{ + Name: strings.TrimSpace(d.inputs[0].Value()), + Path: strings.TrimSpace(d.inputs[1].Value()), + Agent: strings.TrimSpace(d.inputs[2].Value()), + } + return d, func() tea.Msg { return submitProjectMsg{project: project} } + } + var cmd tea.Cmd + d.inputs[d.focus], cmd = d.inputs[d.focus].Update(press) + return d, cmd +} + +func (d *projectsDialog) setFocus(focus int) tea.Cmd { + d.inputs[d.focus].Blur() + d.focus = focus + return d.inputs[d.focus].Focus() +} + +func (d *projectsDialog) View(width, _ int) string { + w := dialogWidth(70, width) + switch d.mode { + case projectsPicking: + return renderDialog("Add project · select repository", w, + d.picker.View(w), + "", + helpLine(w, "↑↓", "select", "enter", "open/pick", "backspace", "up", "esc", "back"), + ) + case projectsAdding: + return d.viewAdding(w) + } + + var rows []string + if len(d.projects) == 0 { + rows = append(rows, styles.MutedStyle.Italic(true).Render("No projects yet — press a to add one.")) + } + for i, p := range d.projects { + marker, nameStyle := " ", styles.BaseStyle.Foreground(projectColorAt(i)) + if i == d.idx { + marker, nameStyle = styles.SuccessStyle.Render("❯ "), nameStyle.Bold(true) + } + agent := p.Agent + if agent == "" { + agent = board.DefaultAgent + } + line := marker + nameStyle.Render(sanitize(p.Name)) + + styles.MutedStyle.Render(" "+sanitize(p.Path)+" · ") + + styles.SecondaryStyle.Render(sanitize(agent)) + rows = append(rows, toolcommon.TruncateText(line, w)) + } + + return renderDialog("Projects", w, + lipgloss.JoinVertical(lipgloss.Left, rows...), + "", + helpLine(w, "a", "add", "x", "remove", "↑↓", "select", "esc", "close"), + ) +} + +func (d *projectsDialog) viewAdding(w int) string { + rows := make([]string, 0, len(projectFields)*2) + for i, f := range projectFields { + label := styles.SecondaryStyle.Render(f.label) + if i == d.focus { + label = styles.HighlightWhiteStyle.Render(f.label) + } + rows = append(rows, label, d.inputs[i].View()) + } + return renderDialog("Add project", w, + lipgloss.JoinVertical(lipgloss.Left, rows...), + "", + helpLine(w, "enter", "save", "tab", "next field", "ctrl+o", "browse", "esc", "back"), + ) +} + +// --- diff viewer --- + +// diffDialog shows a card's worktree diff in a scrollable viewport. The +// agent may still be working, so r reloads the diff in place. +type diffDialog struct { + cardID string + title string + view viewport.Model + bar *scrollbar.Model + empty bool +} + +// maxDiffBytes caps how much diff the viewer renders. Beyond this the diff +// is cut with a notice: colorizing and holding hundreds of thousands of +// styled lines in the viewport would freeze the UI. +const maxDiffBytes = 1 << 20 + +func newDiffDialog(cardID, title, diff string, offset int) *diffDialog { + vp := viewport.New() + vp.SoftWrap = false + // Both the title (agent-controlled) and the diff (repository content) + // are untrusted; strip terminal controls before rendering. + title = strings.Join(strings.Fields(sanitize(title)), " ") + diff = sanitize(diff) + if len(diff) > maxDiffBytes { + cut := strings.LastIndexByte(diff[:maxDiffBytes], '\n') + if cut < 0 { + cut = maxDiffBytes + } + diff = diff[:cut] + "\n\n… diff truncated — open the worktree to see the rest" + } + d := &diffDialog{cardID: cardID, title: title, view: vp, bar: scrollbar.New(), empty: strings.TrimSpace(diff) == ""} + if !d.empty { + d.view.SetContent(colorizeDiff(diff)) + d.view.SetYOffset(offset) + } + return d +} + +func (d *diffDialog) Init() tea.Cmd { return nil } + +func (d *diffDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { + if press, ok := msg.(tea.KeyPressMsg); ok { + switch press.String() { + case "esc", "q", "d": + return d, closeDialog + case "r": + cardID, title, offset := d.cardID, d.title, d.view.YOffset() + return d, func() tea.Msg { return reloadDiffMsg{cardID: cardID, title: title, offset: offset} } + } + } + var cmd tea.Cmd + d.view, cmd = d.view.Update(msg) + return d, cmd +} + +func (d *diffDialog) View(width, height int) string { + w := dialogWidth(110, width) + h := max(min(height-6, 40), 5) + if d.empty { + return renderDialog("Diff · "+d.title, w, + styles.MutedStyle.Italic(true).Render("No changes yet."), + "", + helpLine(w, "r", "refresh", "esc", "close"), + ) + } + d.view.SetWidth(w - scrollbar.Width) + d.view.SetHeight(h) + // Re-clamp the offset now that the real dimensions are known: it may + // have been restored (refresh) or invalidated (resize, shrunken diff) + // while the viewport height was still zero. + d.view.SetYOffset(d.view.YOffset()) + + // Scrollbar column, kept even when the content fits so the layout is + // stable across refreshes. + d.bar.SetDimensions(h, d.view.TotalLineCount()) + d.bar.SetScrollOffset(d.view.YOffset()) + bar := d.bar.View() + if bar == "" { + bar = strings.TrimRight(strings.Repeat(" \n", h), "\n") + } + + return renderDialog("Diff · "+d.title, w, + lipgloss.JoinHorizontal(lipgloss.Top, d.view.View(), bar), + "", + helpLine(w, "↑↓", "scroll "+percentLabel(d.view.ScrollPercent()), "r", "refresh", "esc", "close"), + ) +} + +// percentLabel formats a scroll fraction (0..1) as a percentage string. +func percentLabel(frac float64) string { + return strconv.Itoa(min(max(int(frac*100), 0), 100)) + "%" +} + +// colorizeDiff applies standard diff colors line by line. +func colorizeDiff(diff string) string { + addStyle := lipgloss.NewStyle().Foreground(styles.DiffAddFg) + delStyle := lipgloss.NewStyle().Foreground(styles.DiffRemoveFg) + hunkStyle := styles.InfoStyle + fileStyle := styles.HighlightWhiteStyle + + lines := strings.Split(diff, "\n") + for i, line := range lines { + switch { + case strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"): + lines[i] = fileStyle.Render(line) + case strings.HasPrefix(line, "@@"): + lines[i] = hunkStyle.Render(line) + case strings.HasPrefix(line, "+"): + lines[i] = addStyle.Render(line) + case strings.HasPrefix(line, "-"): + lines[i] = delStyle.Render(line) + } + } + return strings.Join(lines, "\n") +} + +// --- delete confirmation --- + +type confirmDialog struct { + card *board.Card + keys tuidialog.ConfirmKeyMap +} + +func newConfirmDialog(card *board.Card) *confirmDialog { + return &confirmDialog{card: card, keys: tuidialog.DefaultConfirmKeyMap()} +} + +func (d *confirmDialog) Init() tea.Cmd { return nil } + +func (d *confirmDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { + press, ok := msg.(tea.KeyPressMsg) + if !ok { + return d, nil + } + switch { + case key.Matches(press, d.keys.Yes), press.String() == "enter": + cardID := d.card.ID + return d, func() tea.Msg { return confirmDeleteMsg{cardID: cardID} } + case key.Matches(press, d.keys.No), press.String() == "esc", press.String() == "q": + return d, closeDialog + } + return d, nil +} + +func (d *confirmDialog) View(width, _ int) string { + w := dialogWidth(60, width) + return renderDialog("Delete card?", w, + styles.BaseStyle.Render(toolcommon.TruncateText(sanitize(d.card.Title), w)), + styles.MutedStyle.Render("Kills the agent session and deletes its worktree and branch."), + "", + helpLine(w, d.keys.Yes.Help().Key, "delete", d.keys.No.Help().Key+"/esc", "cancel"), + ) +} + +// --- help --- + +type helpDialog struct{} + +func newHelpDialog() *helpDialog { return &helpDialog{} } + +func (d *helpDialog) Init() tea.Cmd { return nil } + +func (d *helpDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { + if _, ok := msg.(tea.KeyPressMsg); ok { + return d, closeDialog + } + return d, nil +} + +func (d *helpDialog) View(width, _ int) string { + w := dialogWidth(64, width) + bindings := []struct{ key, desc string }{ + {keys.New.Help().Key, "create a card (launches an agent in a worktree)"}, + {keys.Attach.Help().Key, keys.Attach.Help().Desc}, + {keys.Diff.Help().Key, "view the card's worktree diff"}, + {keys.Editor.Help().Key, keys.Editor.Help().Desc + " ($BOARD_EDITOR, default code)"}, + {"[ / ]", "move card (forward sends the column's prompt)"}, + {keys.Delete.Help().Key, "delete card, its session and worktree"}, + {keys.Projects.Help().Key, keys.Projects.Help().Desc}, + {keys.Prompt.Help().Key, "edit the selected column's prompt"}, + {"←↓↑→ hjkl", "navigate (g/G first/last card)"}, + {"mouse", "click selects · double-click attaches · wheel scrolls"}, + {keys.Quit.Help().Key, "quit (agents keep running in tmux)"}, + } + // Same row styling as the main TUI's help dialog. + keyStyle := styles.DialogHelpStyle.Foreground(styles.TextSecondary).Bold(true).Width(12) + descStyle := styles.DialogHelpStyle + rows := make([]string, 0, len(bindings)) + for _, b := range bindings { + rows = append(rows, keyStyle.Render(b.key)+descStyle.Render(b.desc)) + } + rows = append(rows, "", + styles.MutedStyle.Render("Projects and column prompts are stored in the global config"), + styles.MutedStyle.Render("file ("+toolcommon.TruncateText(configPathHint(), w-8)+")."), + ) + return renderDialog("Help", w, lipgloss.JoinVertical(lipgloss.Left, rows...)) +} diff --git a/pkg/board/tui/dirpicker.go b/pkg/board/tui/dirpicker.go new file mode 100644 index 000000000..cc050e44a --- /dev/null +++ b/pkg/board/tui/dirpicker.go @@ -0,0 +1,208 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/docker/docker-agent/pkg/paths" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +// pickerEntryKind distinguishes the rows of the directory picker. +type pickerEntryKind int + +const ( + // entryUseDir selects the directory currently being browsed. + entryUseDir pickerEntryKind = iota + // entryParent navigates to the parent directory. + entryParent + // entryDir navigates into a subdirectory. + entryDir +) + +type pickerEntry struct { + label string + path string + kind pickerEntryKind + git bool +} + +// pickerVisibleRows caps the directory list height. +const pickerVisibleRows = 12 + +// dirPicker is a minimal directory browser used to pick a project's +// repository: type to filter, enter descends, picking the current directory +// confirms. It is embedded in the projects dialog rather than being a +// dialog itself. +type dirPicker struct { + dir string + entries []pickerEntry + filtered []pickerEntry + selected int + filter textinput.Model + loadErr error +} + +// pickerStartDir returns where browsing starts: the given hint when it is +// an existing directory, otherwise the home directory. +func pickerStartDir(hint string) string { + if hint != "" { + if info, err := os.Stat(hint); err == nil && info.IsDir() { + return hint + } + } + if home := paths.GetHomeDir(); home != "" { + return home + } + return "/" +} + +func newDirPicker(start string) *dirPicker { + ti := textinput.New() + ti.SetStyles(styles.DialogInputStyle) + ti.Placeholder = "Type to filter directories…" + ti.Focus() + p := &dirPicker{filter: ti} + p.load(start) + return p +} + +// load reads dir's subdirectories (hidden ones excluded) and resets the +// filter and selection. +func (p *dirPicker) load(dir string) { + p.dir = dir + p.filter.SetValue("") + p.loadErr = nil + p.entries = []pickerEntry{{label: "use this directory", path: dir, kind: entryUseDir, git: isGitDir(dir)}} + if parent := filepath.Dir(dir); parent != dir { + p.entries = append(p.entries, pickerEntry{label: "..", path: parent, kind: entryParent}) + } + + dirEntries, err := os.ReadDir(dir) + if err != nil { + p.loadErr = err + } + for _, e := range dirEntries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + path := filepath.Join(dir, e.Name()) + p.entries = append(p.entries, pickerEntry{label: e.Name(), path: path, kind: entryDir, git: isGitDir(path)}) + } + p.applyFilter() +} + +// isGitDir cheaply reports whether dir looks like a git repository root. +func isGitDir(dir string) bool { + _, err := os.Stat(filepath.Join(dir, ".git")) + return err == nil +} + +// applyFilter narrows the subdirectory rows to those matching the filter; +// the "use this directory" and ".." rows always stay. +func (p *dirPicker) applyFilter() { + query := strings.ToLower(strings.TrimSpace(p.filter.Value())) + p.filtered = p.filtered[:0] + for _, e := range p.entries { + if query == "" || e.kind != entryDir || strings.Contains(strings.ToLower(e.label), query) { + p.filtered = append(p.filtered, e) + } + } + p.selected = min(p.selected, max(len(p.filtered)-1, 0)) +} + +// Update handles one key press. It returns the chosen directory (empty +// until one is picked) and whether the picker is done (picked or +// cancelled). +func (p *dirPicker) Update(key tea.KeyPressMsg) (chosen string, done bool, cmd tea.Cmd) { + switch key.String() { + case "esc": + return "", true, nil + case "up": + p.selected = max(p.selected-1, 0) + return "", false, nil + case "down": + p.selected = min(p.selected+1, max(len(p.filtered)-1, 0)) + return "", false, nil + case "enter": + if p.selected >= len(p.filtered) { + return "", false, nil + } + entry := p.filtered[p.selected] + if entry.kind == entryUseDir { + return entry.path, true, nil + } + p.load(entry.path) + p.selected = 0 + return "", false, nil + case "backspace": + // With an empty filter, backspace walks up one directory. + if p.filter.Value() == "" { + if parent := filepath.Dir(p.dir); parent != p.dir { + p.load(parent) + p.selected = 0 + } + return "", false, nil + } + } + p.filter, cmd = p.filter.Update(key) + p.applyFilter() + return "", false, cmd +} + +// View renders the picker's content (the surrounding dialog chrome is the +// caller's). +func (p *dirPicker) View(width int) string { + p.filter.SetWidth(width) + + lines := []string{ + styles.MutedStyle.Render(toolcommon.TruncateText(sanitize(p.dir), width)), + p.filter.View(), + "", + } + + // Window the list around the selection. + start := clamp(p.selected-pickerVisibleRows+1, 0, max(len(p.filtered)-pickerVisibleRows, 0)) + end := min(start+pickerVisibleRows, len(p.filtered)) + for i := start; i < end; i++ { + lines = append(lines, p.renderEntry(p.filtered[i], i == p.selected, width)) + } + switch { + case p.loadErr != nil: + lines = append(lines, styles.ErrorStyle.Render(toolcommon.TruncateText(sanitize(p.loadErr.Error()), width))) + case end < len(p.filtered): + lines = append(lines, styles.MutedStyle.Render(toolcommon.TruncateText("… more", width))) + } + return strings.Join(lines, "\n") +} + +func (p *dirPicker) renderEntry(entry pickerEntry, selected bool, width int) string { + marker, style := " ", styles.BaseStyle + if selected { + marker, style = styles.SuccessStyle.Render("❯ "), styles.HighlightWhiteStyle + } + + var label, suffix string + switch entry.kind { + case entryUseDir: + label = "◉ " + entry.label + if entry.git { + suffix = styles.SuccessStyle.Render(" git repository") + } else { + suffix = styles.WarningStyle.Render(" not a git repository") + } + case entryParent: + label = "📁 .." + case entryDir: + label = "📁 " + sanitize(entry.label) + if entry.git { + suffix = styles.SuccessStyle.Render(" ●") + } + } + return marker + style.Render(toolcommon.TruncateText(label, width-4)) + suffix +} diff --git a/pkg/board/tui/dirpicker_test.go b/pkg/board/tui/dirpicker_test.go new file mode 100644 index 000000000..c616947cb --- /dev/null +++ b/pkg/board/tui/dirpicker_test.go @@ -0,0 +1,123 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pickerDirs creates a directory tree for picker tests: +// +// root/ +// alpha/ (git repo) +// beta/ +// .hidden/ +// file.txt +func pickerDirs(t *testing.T) string { + t.Helper() + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "alpha", ".git"), 0o755)) + require.NoError(t, os.Mkdir(filepath.Join(root, "beta"), 0o755)) + require.NoError(t, os.Mkdir(filepath.Join(root, ".hidden"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), nil, 0o644)) + return root +} + +func keyPress(s string) tea.KeyPressMsg { + if len(s) == 1 { + return tea.KeyPressMsg{Code: rune(s[0]), Text: s} + } + switch s { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "backspace": + return tea.KeyPressMsg{Code: tea.KeyBackspace} + case "esc": + return tea.KeyPressMsg{Code: tea.KeyEscape} + } + panic("unknown key " + s) +} + +// press feeds one key to the picker, ignoring its outputs. +func press(p *dirPicker, key string) { + _, _, _ = p.Update(keyPress(key)) //nolint:dogsled // navigation side effects only +} + +func labels(entries []pickerEntry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.label) + } + return out +} + +func TestDirPickerListsDirectoriesOnly(t *testing.T) { + t.Parallel() + + root := pickerDirs(t) + p := newDirPicker(root) + + // Hidden dirs and files are excluded; "use this directory" and ".." + // always lead. + assert.Equal(t, []string{"use this directory", "..", "alpha", "beta"}, labels(p.filtered)) + assert.True(t, p.filtered[2].git, "alpha is a git repo") + assert.False(t, p.filtered[3].git, "beta is not") +} + +func TestDirPickerNavigation(t *testing.T) { + t.Parallel() + + root := pickerDirs(t) + p := newDirPicker(root) + + // Descend into alpha (down twice: use-dir -> .. -> alpha). + press(p, "down") + press(p, "down") + chosen, done, _ := p.Update(keyPress("enter")) + assert.Empty(t, chosen) + assert.False(t, done) + assert.Equal(t, filepath.Join(root, "alpha"), p.dir) + + // Backspace with an empty filter walks back up. + press(p, "backspace") + assert.Equal(t, root, p.dir) + + // Enter on "use this directory" picks it. + chosen, done, _ = p.Update(keyPress("enter")) + assert.Equal(t, root, chosen) + assert.True(t, done) +} + +func TestDirPickerFilterAndCancel(t *testing.T) { + t.Parallel() + + root := pickerDirs(t) + p := newDirPicker(root) + + // Typing filters subdirectories but keeps the fixed rows. + for _, r := range "bet" { + press(p, string(r)) + } + assert.Equal(t, []string{"use this directory", "..", "beta"}, labels(p.filtered)) + + // Esc cancels without choosing. + chosen, done, _ := p.Update(keyPress("esc")) + assert.Empty(t, chosen) + assert.True(t, done) +} + +func TestPickerStartDir(t *testing.T) { + t.Parallel() + + root := pickerDirs(t) + assert.Equal(t, root, pickerStartDir(root)) + // Missing or file hints fall back to a usable directory. + assert.NotEqual(t, filepath.Join(root, "gone"), pickerStartDir(filepath.Join(root, "gone"))) + assert.NotEqual(t, filepath.Join(root, "file.txt"), pickerStartDir(filepath.Join(root, "file.txt"))) +} diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go new file mode 100644 index 000000000..aab8892b3 --- /dev/null +++ b/pkg/board/tui/tui.go @@ -0,0 +1,672 @@ +// Package tui implements the full-screen Kanban TUI for `docker agent board`. +package tui + +import ( + "context" + "image/color" + "os/exec" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/docker/docker-agent/pkg/board" + "github.com/docker/docker-agent/pkg/tui/core" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +// Run starts the board TUI and blocks until the user quits. +func Run(ctx context.Context) error { + // The engine notifies changes from watcher goroutines; a buffered + // channel coalesces bursts and the model turns receives into refreshes. + refresh := make(chan struct{}, 16) + app, err := board.NewApp(ctx, func() { + select { + case refresh <- struct{}{}: + default: + } + }) + if err != nil { + return err + } + + p := tea.NewProgram(newModel(app, refresh), tea.WithContext(ctx)) + _, err = p.Run() + return err +} + +// Messages. +type ( + // refreshMsg means the engine changed: re-snapshot the cards. + refreshMsg struct{} + // tickMsg advances the spinner animation. + tickMsg struct{} + // flashMsg shows a transient message in the footer. + flashMsg struct { + text string + isErr bool + } + // clearFlashMsg hides an expired footer message. + clearFlashMsg struct{ id int } + // attachReadyMsg carries the tmux attach command for a card whose agent + // answered its readiness probe. + attachReadyMsg struct{ cmd *exec.Cmd } + // attachFailedMsg means the readiness probe failed; the attach guard is + // released and the error shown. + attachFailedMsg struct{ err error } + // attachDoneMsg means the user detached from a card's tmux session. + attachDoneMsg struct{ err error } + // diffLoadedMsg carries a card's worktree diff. + diffLoadedMsg struct { + cardID string + title string + diff string + offset int + } + // reloadDiffMsg re-reads an open diff dialog's worktree diff, keeping + // the scroll position. + reloadDiffMsg struct { + cardID string + title string + offset int + } + // cardCreatedMsg means a new card landed in the first column. + cardCreatedMsg struct{} + // cardMovedMsg means the selected card landed in another column. + cardMovedMsg struct{ colIdx int } + + // closeDialogMsg closes the active dialog. + closeDialogMsg struct{} + // submitNewCardMsg creates a card from the new-card dialog. + submitNewCardMsg struct { + project board.Project + prompt string + } + // submitProjectMsg adds a project from the projects dialog. + submitProjectMsg struct{ project board.Project } + // deleteProjectMsg removes a project from the projects dialog. + deleteProjectMsg struct{ name string } + // submitPromptMsg saves a column prompt from the prompt editor. + submitPromptMsg struct { + colID string + prompt string + } + // confirmDeleteMsg deletes a card after confirmation. + confirmDeleteMsg struct{ cardID string } +) + +// dialog is a modal overlay. Dialogs emit model-level messages (via +// tea.Cmd) to request actions; the model owns all engine calls. +type dialog interface { + Init() tea.Cmd + Update(msg tea.Msg) (dialog, tea.Cmd) + View(width, height int) string +} + +// keyMap holds the board's key bindings. +type keyMap struct { + Left key.Binding + Right key.Binding + Up key.Binding + Down key.Binding + First key.Binding + Last key.Binding + New key.Binding + Attach key.Binding + Diff key.Binding + MoveFwd key.Binding + MoveBack key.Binding + Delete key.Binding + Projects key.Binding + Prompt key.Binding + Editor key.Binding + Help key.Binding + Quit key.Binding +} + +var keys = keyMap{ + Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "previous column")), + Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next column")), + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "previous card")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "next card")), + First: key.NewBinding(key.WithKeys("g", "home"), key.WithHelp("g", "first card")), + Last: key.NewBinding(key.WithKeys("G", "end"), key.WithHelp("G", "last card")), + New: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new card")), + Attach: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "attach to agent (ctrl+q detaches)")), + Diff: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "view diff")), + MoveFwd: key.NewBinding(key.WithKeys("]", "shift+right", "L"), key.WithHelp("]", "move card forward")), + MoveBack: key.NewBinding(key.WithKeys("[", "shift+left", "H"), key.WithHelp("[", "move card back")), + Delete: key.NewBinding(key.WithKeys("x", "backspace", "delete"), key.WithHelp("x", "delete card")), + Projects: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "manage projects")), + Prompt: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit column prompt")), + Editor: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open worktree in editor")), + Help: key.NewBinding(key.WithKeys("?", "f1", "ctrl+h"), key.WithHelp("?", "help")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), +} + +// resolveKeys merges the user's remapped global bindings (from the config +// file, resolved by the main TUI's keymap) into the board's defaults, so a +// remapped quit works here too. Called at model construction, after the +// config directory override has been applied. +func resolveKeys() { + globalQuit := core.GetKeys().Quit + keys.Quit = key.NewBinding( + key.WithKeys(append([]string{"q"}, globalQuit.Keys()...)...), + key.WithHelp("q", "quit"), + ) +} + +// model is the top-level bubbletea model of the board. +type model struct { + app *board.App + refresh chan struct{} + + width, height int + + columns []board.Column + // cards holds each column's cards in board order, keyed by column ID. + cards map[string][]*board.Card + // projects is a snapshot of the configured projects, cached so View + // never takes the engine's config lock. + projects []board.Project + // projectColors is each configured project's accent color, keyed by name. + projectColors map[string]color.Color + + // selCol/selRow is the cursor; selRow is clamped per column. + selCol, selRow int + // scroll is each column's first visible card index. + scroll map[string]int + // colScroll is the first visible column when the terminal is too narrow + // to fit the whole pipeline (see columnWindow). + colScroll int + + frame int // spinner animation frame + ticker bool // whether a tick is scheduled + + flash string + flashID int + isErr bool + + dialog dialog + + // attaching guards against queueing a second attach while one is being + // probed or is on screen: each queued tea.ExecProcess would otherwise + // replay after the previous detach. + attaching bool + + // lastProject is the project of the most recently created card; the + // new-card dialog starts there. + lastProject string + + // lastClick* back double-click-to-attach on cards. + lastClickCard string + lastClickTime time.Time +} + +func newModel(app *board.App, refresh chan struct{}) *model { + resolveKeys() + m := &model{ + app: app, + refresh: refresh, + scroll: make(map[string]int), + } + m.reload() + return m +} + +// openDialog installs a dialog and runs its init command. +func (m *model) openDialog(d dialog) tea.Cmd { + m.dialog = d + return d.Init() +} + +// reload re-snapshots columns and cards from the engine and clamps the +// selection. +func (m *model) reload() { + m.columns = m.app.Columns() + m.cards = groupCards(m.columns, m.app.Cards()) + m.projects = m.app.Projects() + m.projectColors = make(map[string]color.Color) + for i, p := range m.projects { + m.projectColors[p.Name] = projectColorAt(i) + } + m.clampSelection() +} + +// groupCards buckets cards by column, in board order. A card whose column +// is no longer configured lands in the first column instead of silently +// disappearing from the board. +func groupCards(columns []board.Column, cards []*board.Card) map[string][]*board.Card { + known := make(map[string]bool, len(columns)) + for _, c := range columns { + known[c.ID] = true + } + grouped := make(map[string][]*board.Card, len(columns)) + for _, card := range cards { + col := card.Column + if !known[col] { + col = columns[0].ID + } + grouped[col] = append(grouped[col], card) + } + return grouped +} + +func (m *model) clampSelection() { + if len(m.columns) == 0 { + return + } + m.selCol = clamp(m.selCol, 0, len(m.columns)-1) + m.selRow = clamp(m.selRow, 0, max(len(m.selectedColumnCards())-1, 0)) +} + +func (m *model) selectedColumnCards() []*board.Card { + if len(m.columns) == 0 { + return nil + } + return m.cards[m.columns[m.selCol].ID] +} + +func (m *model) selectedCard() *board.Card { + cards := m.selectedColumnCards() + if len(cards) == 0 { + return nil + } + return cards[m.selRow] +} + +func (m *model) Init() tea.Cmd { + return tea.Batch(m.waitRefresh(), m.scheduleTick()) +} + +// waitRefresh turns engine change notifications into refresh messages. +func (m *model) waitRefresh() tea.Cmd { + return func() tea.Msg { + <-m.refresh + return refreshMsg{} + } +} + +// anyBusy reports whether any card is animating (starting or running). +func (m *model) anyBusy() bool { + for _, cards := range m.cards { + for _, c := range cards { + if c.Status.Busy() { + return true + } + } + } + return false +} + +// scheduleTick keeps the spinner animation running only while needed. +func (m *model) scheduleTick() tea.Cmd { + if m.ticker || !m.anyBusy() { + return nil + } + m.ticker = true + return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} }) +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case refreshMsg: + m.reload() + return m, tea.Batch(m.waitRefresh(), m.scheduleTick()) + + case tickMsg: + m.ticker = false + m.frame++ + cmd := m.scheduleTick() + return m, cmd + + case flashMsg: + cmd := m.setFlash(msg.text, msg.isErr) + return m, cmd + + case clearFlashMsg: + if msg.id == m.flashID { + m.flash = "" + } + return m, nil + + case cardCreatedMsg: + // Follow the new card: it lands at the end of the first column. + m.dialog = nil + m.reload() + m.selCol = 0 + m.selRow = max(len(m.selectedColumnCards())-1, 0) + cmd := m.scheduleTick() + return m, cmd + + case cardMovedMsg: + // Follow the moved card: it lands at the end of its new column. + m.reload() + m.selCol = clamp(msg.colIdx, 0, max(len(m.columns)-1, 0)) + m.selRow = max(len(m.selectedColumnCards())-1, 0) + cmd := m.scheduleTick() + return m, cmd + + case attachReadyMsg: + return m, tea.ExecProcess(msg.cmd, func(err error) tea.Msg { return attachDoneMsg{err: err} }) + + case attachFailedMsg: + m.attaching = false + cmd := m.setFlash(msg.err.Error(), true) + return m, cmd + + case attachDoneMsg: + m.attaching = false + if msg.err != nil { + cmd := m.setFlash("attach: "+msg.err.Error(), true) + return m, cmd + } + return m, nil + + case diffLoadedMsg: + cmd := m.openDialog(newDiffDialog(msg.cardID, msg.title, msg.diff, msg.offset)) + return m, cmd + + case reloadDiffMsg: + cmd := m.loadDiff(msg.cardID, msg.title, msg.offset) + return m, cmd + + case closeDialogMsg: + m.dialog = nil + return m, nil + + case submitNewCardMsg: + m.dialog = nil + m.lastProject = msg.project.Name + cmd := m.createCard(msg.project, msg.prompt) + return m, cmd + + case submitProjectMsg: + if err := m.app.AddProject(msg.project); err != nil { + cmd := m.setFlash(err.Error(), true) + return m, cmd + } + m.reload() + if d, ok := m.dialog.(*projectsDialog); ok { + d.setProjects(m.projects) + } + cmd := m.setFlash("Project added to the global config", false) + return m, cmd + + case deleteProjectMsg: + if err := m.app.RemoveProject(msg.name); err != nil { + cmd := m.setFlash(err.Error(), true) + return m, cmd + } + m.reload() + if d, ok := m.dialog.(*projectsDialog); ok { + d.setProjects(m.projects) + } + return m, nil + + case submitPromptMsg: + m.dialog = nil + if err := m.app.SetColumnPrompt(msg.colID, msg.prompt); err != nil { + cmd := m.setFlash(err.Error(), true) + return m, cmd + } + m.reload() + cmd := m.setFlash("Prompt saved to the global config", false) + return m, cmd + + case confirmDeleteMsg: + m.dialog = nil + cmd := m.deleteCard(msg.cardID) + return m, cmd + } + + if m.dialog != nil { + // The global quit binding (ctrl+c by default, user-remappable) + // always quits, even while a dialog captures the keyboard. Plain q + // stays with the dialog: it may be typed text. + if press, ok := msg.(tea.KeyPressMsg); ok && key.Matches(press, core.GetKeys().Quit) { + return m, tea.Quit + } + var cmd tea.Cmd + m.dialog, cmd = m.dialog.Update(msg) + return m, cmd + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + return m.handleKey(msg) + case tea.MouseClickMsg: + return m.handleClick(msg) + case tea.MouseWheelMsg: + m.handleWheel(msg) + return m, nil + } + return m, nil +} + +func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Quit): + return m, tea.Quit + + case key.Matches(msg, core.GetKeys().Suspend): + return m, tea.Suspend + + case key.Matches(msg, keys.Left): + m.moveSelection(-1, 0) + case key.Matches(msg, keys.Right): + m.moveSelection(1, 0) + case key.Matches(msg, keys.Up): + m.moveSelection(0, -1) + case key.Matches(msg, keys.Down): + m.moveSelection(0, 1) + case key.Matches(msg, keys.First): + m.selRow = 0 + case key.Matches(msg, keys.Last): + m.selRow = max(len(m.selectedColumnCards())-1, 0) + + case key.Matches(msg, keys.New): + projects := m.projects + if len(projects) == 0 { + cmd := tea.Batch(m.openDialog(newProjectsDialog(nil)), m.setFlash("Add a project first: cards are created against a project", false)) + return m, cmd + } + cmd := m.openDialog(newCardDialog(projects, m.lastProject)) + return m, cmd + + case key.Matches(msg, keys.Attach): + if card := m.selectedCard(); card != nil { + cmd := m.attach(card.ID) + return m, cmd + } + + case key.Matches(msg, keys.Diff): + if card := m.selectedCard(); card != nil { + cmd := m.loadDiff(card.ID, card.Title, 0) + return m, cmd + } + + case key.Matches(msg, keys.MoveFwd): + cmd := m.moveCard(1) + return m, cmd + case key.Matches(msg, keys.MoveBack): + cmd := m.moveCard(-1) + return m, cmd + + case key.Matches(msg, keys.Delete): + if card := m.selectedCard(); card != nil { + cmd := m.openDialog(newConfirmDialog(card)) + return m, cmd + } + + case key.Matches(msg, keys.Projects): + cmd := m.openDialog(newProjectsDialog(m.projects)) + return m, cmd + + case key.Matches(msg, keys.Prompt): + if len(m.columns) > 0 { + cmd := m.openDialog(newPromptDialog(m.columns[m.selCol])) + return m, cmd + } + + case key.Matches(msg, keys.Editor): + if card := m.selectedCard(); card != nil { + if err := m.app.OpenEditor(card.ID); err != nil { + cmd := m.setFlash(err.Error(), true) + return m, cmd + } + cmd := m.setFlash("Opened "+card.Worktree+" in the editor", false) + return m, cmd + } + + case key.Matches(msg, keys.Help): + cmd := m.openDialog(newHelpDialog()) + return m, cmd + } + return m, nil +} + +// moveSelection moves the cursor by column (dx) or row (dy). +func (m *model) moveSelection(dx, dy int) { + if len(m.columns) == 0 { + return + } + if dx != 0 { + m.selCol = clamp(m.selCol+dx, 0, len(m.columns)-1) + } + if dy != 0 { + m.selRow += dy + } + m.clampSelection() +} + +// handleClick selects the clicked card; a double-click attaches to it. +func (m *model) handleClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) { + if msg.Button != tea.MouseLeft { + return m, nil + } + col, row, ok := m.cardAt(msg.X, msg.Y) + if !ok { + m.lastClickCard = "" + return m, nil + } + m.selCol, m.selRow = col, row + m.clampSelection() + + card := m.selectedCard() + if card == nil { + return m, nil + } + if m.lastClickCard == card.ID && time.Since(m.lastClickTime) < styles.DoubleClickThreshold { + m.lastClickCard = "" + cmd := m.attach(card.ID) + return m, cmd + } + m.lastClickCard = card.ID + m.lastClickTime = time.Now() + return m, nil +} + +// handleWheel moves the selection through the column under the cursor, so +// scrolling anywhere on a column walks its cards (the scroll window follows +// the selection). Wheel events outside the columns area are ignored. +func (m *model) handleWheel(msg tea.MouseWheelMsg) { + col, ok := m.columnAt(msg.X, msg.Y) + if !ok { + return + } + if col != m.selCol { + m.selCol = col + m.clampSelection() + } + switch msg.Button { + case tea.MouseWheelUp: + m.moveSelection(0, -1) + case tea.MouseWheelDown: + m.moveSelection(0, 1) + } +} + +// setFlash shows a transient footer message for a few seconds. The text is +// sanitized and collapsed to one line: errors can embed untrusted content. +func (m *model) setFlash(text string, isErr bool) tea.Cmd { + m.flash = strings.Join(strings.Fields(sanitize(text)), " ") + m.isErr = isErr + m.flashID++ + id := m.flashID + return tea.Tick(4*time.Second, func(time.Time) tea.Msg { return clearFlashMsg{id: id} }) +} + +// --- engine commands (all engine calls that can block run in tea.Cmds) --- + +func (m *model) createCard(project board.Project, prompt string) tea.Cmd { + return func() tea.Msg { + if _, err := m.app.CreateCard(project, prompt); err != nil { + return flashMsg{text: err.Error(), isErr: true} + } + return cardCreatedMsg{} + } +} + +func (m *model) moveCard(direction int) tea.Cmd { + card := m.selectedCard() + if card == nil { + return nil + } + dst := m.selCol + direction + if dst < 0 || dst >= len(m.columns) { + return nil + } + colID := m.columns[dst].ID + return func() tea.Msg { + if err := m.app.MoveCard(card.ID, colID); err != nil { + return flashMsg{text: err.Error(), isErr: true} + } + return cardMovedMsg{colIdx: dst} + } +} + +func (m *model) deleteCard(cardID string) tea.Cmd { + return func() tea.Msg { + if err := m.app.DeleteCard(cardID); err != nil { + return flashMsg{text: err.Error(), isErr: true} + } + return flashMsg{text: "Card deleted, worktree and session cleaned up", isErr: false} + } +} + +// attach probes the card's agent readiness off the UI loop, then hands the +// tmux attach command back to the update loop to exec. The attaching guard +// stays set until the session detaches (or the probe fails). +func (m *model) attach(cardID string) tea.Cmd { + if m.attaching { + return nil + } + m.attaching = true + return func() tea.Msg { + cmd, err := m.app.AttachCommand(cardID) + if err != nil { + return attachFailedMsg{err: err} + } + return attachReadyMsg{cmd: cmd} + } +} + +func (m *model) loadDiff(cardID, title string, offset int) tea.Cmd { + return func() tea.Msg { + diff, err := m.app.Diff(cardID) + if err != nil { + return flashMsg{text: err.Error(), isErr: true} + } + return diffLoadedMsg{cardID: cardID, title: title, diff: diff, offset: offset} + } +} + +func clamp(v, lo, hi int) int { + return min(max(v, lo), hi) +} diff --git a/pkg/board/tui/tui_test.go b/pkg/board/tui/tui_test.go new file mode 100644 index 000000000..0feb5e81c --- /dev/null +++ b/pkg/board/tui/tui_test.go @@ -0,0 +1,176 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/board" +) + +func TestGroupCardsFallsBackToFirstColumn(t *testing.T) { + t.Parallel() + + columns := []board.Column{{ID: "dev"}, {ID: "done"}} + cards := []*board.Card{ + {ID: "a", Column: "dev"}, + {ID: "b", Column: "removed"}, // column dropped from the config + {ID: "c", Column: "done"}, + } + + grouped := groupCards(columns, cards) + assert.Len(t, grouped["dev"], 2, "orphaned card should land in the first column") + assert.Len(t, grouped["done"], 1) +} + +func TestColumnWindowFollowsSelection(t *testing.T) { + t.Parallel() + + m := &model{ + width: 50, // fits 2 columns of minColumnWidth + height: 40, + columns: []board.Column{ + {ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"}, + }, + cards: map[string][]*board.Card{}, + scroll: map[string]int{}, + } + + offset, count := m.columnWindow() + assert.Equal(t, 0, offset) + assert.Equal(t, 2, count) + + // Selecting the last column slides the window right… + m.selCol = 3 + offset, _ = m.columnWindow() + assert.Equal(t, 2, offset) + + // …and hit-testing accounts for the offset: x=0 is now column c. + col, ok := m.columnAt(0, boardTop+1) + require.True(t, ok) + assert.Equal(t, 2, col) + + // Selecting the first column slides back. + m.selCol = 0 + offset, _ = m.columnWindow() + assert.Equal(t, 0, offset) + + // A wide terminal shows everything. + m.width = 200 + offset, count = m.columnWindow() + assert.Equal(t, 0, offset) + assert.Equal(t, 4, count) +} + +func TestCardAtMirrorsLayout(t *testing.T) { + t.Parallel() + + columns := []board.Column{{ID: "dev"}, {ID: "done"}} + m := &model{ + width: 120, + height: 40, + columns: columns, + cards: map[string][]*board.Card{ + "dev": {{ID: "a"}, {ID: "b"}}, + "done": {{ID: "c"}}, + }, + scroll: map[string]int{}, + } + // colWidth = (120-1)/2 = 59; cards start at row boardTop+3 = 5. + + col, row, ok := m.cardAt(5, 5) // first card, first column + require.True(t, ok) + assert.Equal(t, 0, col) + assert.Equal(t, 0, row) + + col, row, ok = m.cardAt(5, 5+cardHeight) // second card slot, first column + require.True(t, ok) + assert.Equal(t, 0, col) + assert.Equal(t, 1, row) + + col, row, ok = m.cardAt(65, 5) // first card, second column + require.True(t, ok) + assert.Equal(t, 1, col) + assert.Equal(t, 0, row) + + _, _, ok = m.cardAt(65, 5+cardHeight) // slot exists but column has 1 card + assert.False(t, ok) + + _, _, ok = m.cardAt(5, 4) // header/separator rows + assert.False(t, ok) + _, _, ok = m.cardAt(59, 5) // gap between columns + assert.False(t, ok) +} + +func TestDiffDialogClampsRestoredOffset(t *testing.T) { + t.Parallel() + + // A restored offset can exceed the reloaded diff's length; rendering + // must clamp it once the real viewport dimensions are known. + d := newDiffDialog("card", "title", "+a\n+b\n+c", 100) + _ = d.View(120, 30) + assert.LessOrEqual(t, d.view.YOffset(), d.view.TotalLineCount()) + _ = d.View(120, 30) // stable on re-render + assert.GreaterOrEqual(t, d.view.YOffset(), 0) +} + +func TestPlaceOverlayCompositesOverBase(t *testing.T) { + t.Parallel() + + base := strings.TrimSuffix(strings.Repeat("ABCDEFGH\n", 8), "\n") + out := placeOverlay(base, "XX\nXX", 8, 8) + + // The dialog is centered… + assert.Contains(t, out, "ABCXXFGH") + // …and the board stays visible around it. + assert.Contains(t, out, "ABCDEFGH") +} + +func TestSanitize(t *testing.T) { + t.Parallel() + + // ANSI/OSC sequences and control characters are stripped. + assert.Equal(t, "title", sanitize("\x1b[31mti\x1b]2;pwned\x07tle\x00")) + // Newlines survive, tabs become spaces. + assert.Equal(t, "a\n b", sanitize("a\n\tb")) +} + +func TestSplitTitle(t *testing.T) { + t.Parallel() + + l1, l2 := splitTitle("Short", 20) + assert.Equal(t, "Short", l1) + assert.Empty(t, l2) + + l1, l2 = splitTitle("A somewhat longer card title", 10) + assert.Equal(t, "A somewhat", l1) + assert.NotEmpty(t, l2) +} + +func TestColorizeDiffKeepsLineCount(t *testing.T) { + t.Parallel() + + diff := "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n context" + colored := colorizeDiff(diff) + assert.Len(t, strings.Split(colored, "\n"), len(strings.Split(diff, "\n"))) + assert.Contains(t, colored, "old") + assert.Contains(t, colored, "new") +} + +func TestDialogWidth(t *testing.T) { + t.Parallel() + + assert.Equal(t, 80, dialogWidth(80, 200)) + assert.Equal(t, 56, dialogWidth(80, 60)) // clamped to terminal + assert.Equal(t, 24, dialogWidth(80, 10)) // floor +} + +func TestPlural(t *testing.T) { + t.Parallel() + + assert.Equal(t, "1 card", plural(1, "card")) + assert.Equal(t, "2 cards", plural(2, "card")) + assert.Equal(t, "0 projects", plural(0, "project")) +} diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go new file mode 100644 index 000000000..94af1d572 --- /dev/null +++ b/pkg/board/tui/view.go @@ -0,0 +1,433 @@ +package tui + +import ( + "fmt" + "hash/fnv" + "image/color" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/docker/docker-agent/pkg/board" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +// Layout constants. cardAt relies on these mirroring renderBoard exactly. +const ( + // boardTop is the first row of the columns area: title row + blank row. + boardTop = 2 + // footerRows is the reserved space below the columns area. + footerRows = 1 + // columnGap separates adjacent columns. + columnGap = 1 + // minColumnWidth keeps columns readable on narrow terminals. + minColumnWidth = 22 + // cardHeight is the outer height of a card: border (2) + title (2) + + // project (1) + status (1). + cardHeight = 6 + // columnHeaderRows is the number of content rows above the first card + // inside a column box: header line + separator line. + columnHeaderRows = 2 +) + +// sanitize strips ANSI escape sequences and control characters from +// untrusted strings (agent-controlled titles, repository content) so they +// cannot inject terminal controls — clipboard writes, title changes, screen +// manipulation — when rendered. Newlines are preserved; tabs become spaces. +func sanitize(s string) string { + s = ansi.Strip(s) + s = strings.ReplaceAll(s, "\t", " ") + return strings.Map(func(r rune) rune { + if r == '\n' { + return r + } + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, s) +} + +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// projectColorAt returns the accent color of the i-th configured project. +// The palette is read lazily: theme colors are only bound after ApplyTheme. +func projectColorAt(i int) color.Color { + palette := []color.Color{ + styles.BadgeCyan, styles.BadgePurple, styles.BadgeGreen, + styles.Info, styles.Warning, styles.Success, + } + return palette[((i%len(palette))+len(palette))%len(palette)] +} + +// projectColor returns the accent color shared by all of a project's cards. +// Cards whose project was removed from the config hash to a stable color. +func (m *model) projectColor(name string) color.Color { + if c, ok := m.projectColors[name]; ok { + return c + } + h := fnv.New32a() + _, _ = h.Write([]byte(name)) + return projectColorAt(int(h.Sum32() % 6)) +} + +func (m *model) View() tea.View { + var body string + switch { + case m.width <= 0 || m.height <= 0: + body = "" + default: + body = lipgloss.JoinVertical( + lipgloss.Left, + m.renderHeader(), + "", + m.renderBoard(), + m.renderFooter(), + ) + } + + if m.dialog != nil { + overlay := m.dialog.View(m.width, m.height) + body = placeOverlay(body, overlay, m.width, m.height) + } else if m.totalCards() == 0 && m.width > 0 { + body = placeOverlay(body, m.renderWelcome(), m.width, m.height) + } + + view := tea.NewView(body) + view.AltScreen = true + view.MouseMode = tea.MouseModeCellMotion + view.BackgroundColor = styles.Background + view.WindowTitle = m.windowTitle() + return view +} + +// windowTitle reflects the board's activity in the terminal title, like the +// main TUI does for its sessions. +func (m *model) windowTitle() string { + busy := 0 + for _, cards := range m.cards { + busy += busyCount(cards) + } + if busy == 0 { + return "docker agent board" + } + return fmt.Sprintf("docker agent board — %d running", busy) +} + +// placeOverlay composites a dialog over the live board, so cards and +// statuses stay visible behind the modal. +func placeOverlay(base, overlay string, width, height int) string { + dialog := lipgloss.NewLayer(overlay) + dialog = dialog.X(max((width-dialog.Width())/2, 0)).Y(max((height-dialog.Height())/2, 0)).Z(1) + canvas := lipgloss.NewCanvas(width, height) + canvas.Compose(lipgloss.NewCompositor( + lipgloss.NewLayer(base).Z(0), + dialog, + )) + return canvas.Render() +} + +func (m *model) renderHeader() string { + title := styles.HighlightWhiteStyle.Render(" 🐳 Board") + + info := fmt.Sprintf("%s · %s ", + plural(len(m.projects), "project"), plural(m.totalCards(), "card")) + // On narrow terminals, show how many columns are hidden on each side. + offset, count := m.columnWindow() + if offset > 0 { + info = "◀ " + strconv.Itoa(offset) + " · " + info + } + if hidden := len(m.columns) - offset - count; hidden > 0 { + info = info + "· " + strconv.Itoa(hidden) + " ▶ " + } + styled := styles.MutedStyle.Render(info) + + pad := max(m.width-lipgloss.Width(title)-lipgloss.Width(styled), 1) + return title + strings.Repeat(" ", pad) + styled +} + +func (m *model) totalCards() int { + total := 0 + for _, cards := range m.cards { + total += len(cards) + } + return total +} + +// renderWelcome is the first-run overlay shown while the board is empty. +func (m *model) renderWelcome() string { + key := styles.BaseStyle.Foreground(styles.BadgeCyan).Bold(true) + lines := []string{ + styles.HighlightWhiteStyle.Render("Welcome to Board"), + "", + styles.SecondaryStyle.Render("Each card runs an agent in a tmux session, on an"), + styles.SecondaryStyle.Render("isolated git worktree of one of your projects."), + styles.SecondaryStyle.Render("Move a card forward to send it the column's prompt."), + "", + key.Render("n") + styles.MutedStyle.Render(" create your first card"), + key.Render("p") + styles.MutedStyle.Render(" configure projects"), + key.Render("?") + styles.MutedStyle.Render(" all key bindings"), + } + return styles.DialogStyle.Render(strings.Join(lines, "\n")) +} + +func plural(n int, word string) string { + if n == 1 { + return "1 " + word + } + return strconv.Itoa(n) + " " + word + "s" +} + +// columnWindow returns the first visible column and how many fit. On +// terminals too narrow for the whole pipeline, the window slides to keep +// the selected column visible instead of clipping columns off screen. +func (m *model) columnWindow() (offset, count int) { + n := len(m.columns) + if n == 0 { + return 0, 0 + } + count = min(n, max((m.width+columnGap)/(minColumnWidth+columnGap), 1)) + m.colScroll = clamp(m.colScroll, 0, max(n-count, 0)) + m.colScroll = clamp(m.colScroll, m.selCol-count+1, m.selCol) + return m.colScroll, count +} + +// boardSize returns the columns area height and the outer column width. +func (m *model) boardSize() (boardHeight, colWidth int) { + boardHeight = max(m.height-boardTop-footerRows, cardHeight+columnHeaderRows+2) + _, count := m.columnWindow() + count = max(count, 1) + colWidth = max((m.width-(count-1)*columnGap)/count, minColumnWidth) + return boardHeight, colWidth +} + +// visibleSlots returns how many cards fit in a column. +func (m *model) visibleSlots() int { + boardHeight, _ := m.boardSize() + return max((boardHeight-2-columnHeaderRows)/cardHeight, 1) +} + +func (m *model) renderBoard() string { + if len(m.columns) == 0 { + return "" + } + offset, count := m.columnWindow() + boardHeight, colWidth := m.boardSize() + + cols := make([]string, 0, count*2) + for i := offset; i < offset+count; i++ { + if i > offset { + cols = append(cols, strings.Repeat(" ", columnGap)) + } + cols = append(cols, m.renderColumn(i, m.columns[i], colWidth, boardHeight)) + } + return lipgloss.JoinHorizontal(lipgloss.Top, cols...) +} + +func (m *model) renderColumn(idx int, col board.Column, colWidth, boardHeight int) string { + selected := idx == m.selCol + innerWidth := colWidth - 2 + + // Header: emoji, name, count, and a spinner while any card is busy. + nameStyle := styles.SecondaryStyle + if selected { + nameStyle = styles.HighlightWhiteStyle + } + cards := m.cards[col.ID] + count := styles.MutedStyle.Render(" " + strconv.Itoa(len(cards))) + header := " " + columnLabel(col, nameStyle) + count + if busy := busyCount(cards); busy > 0 { + header += " " + styles.InfoStyle.Render(spinnerFrames[m.frame%len(spinnerFrames)]+strconv.Itoa(busy)) + } + separator := styles.MutedStyle.Render(strings.Repeat("─", innerWidth)) + + // Keep the selection visible by adjusting this column's scroll window. + slots := m.visibleSlots() + scroll := clamp(m.scroll[col.ID], 0, max(len(cards)-slots, 0)) + if selected { + scroll = clamp(scroll, m.selRow-slots+1, m.selRow) + } + m.scroll[col.ID] = scroll + + lines := []string{header, separator} + end := min(scroll+slots, len(cards)) + for i := scroll; i < end; i++ { + lines = append(lines, m.renderCard(cards[i], innerWidth, selected && i == m.selRow)) + } + switch { + case len(cards) == 0 && selected: + lines = append(lines, "", styles.MutedStyle.Italic(true).Render(" no cards")) + case end < len(cards): + lines = append(lines, styles.MutedStyle.Render(fmt.Sprintf(" … %d more", len(cards)-end))) + } + + borderColor := styles.BorderMuted + if selected { + borderColor = styles.BorderSecondary + } + return styles.BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Width(innerWidth). + Height(boardHeight - 2). + Render(strings.Join(lines, "\n")) +} + +// columnLabel renders a column's emoji and name, without a stray gap for +// columns configured without an emoji. +func columnLabel(col board.Column, nameStyle lipgloss.Style) string { + if col.Emoji == "" { + return nameStyle.Render(sanitize(col.Name)) + } + return sanitize(col.Emoji) + " " + nameStyle.Render(sanitize(col.Name)) +} + +// busyCount returns how many cards are starting or running. +func busyCount(cards []*board.Card) int { + n := 0 + for _, c := range cards { + if c.Status.Busy() { + n++ + } + } + return n +} + +func (m *model) renderCard(card *board.Card, colInnerWidth int, selected bool) string { + cardWidth := colInnerWidth - 2 // card border + textWidth := max(cardWidth-2, 1) + + // Cards carry their project's accent color on the border and badge, so + // each project's work is recognizable at a glance. + accent := m.projectColor(card.Project) + titleStyle := styles.BaseStyle + borderColor := accent + if selected { + titleStyle = styles.HighlightWhiteStyle + borderColor = styles.BorderPrimary + } + + title1, title2 := splitTitle(sanitize(card.Title), textWidth) + project := styles.BaseStyle.Foreground(accent).Render(toolcommon.TruncateText(sanitize("◆ "+card.Project), textWidth)) + + content := strings.Join([]string{ + titleStyle.Render(title1), + titleStyle.Render(title2), + project, + m.renderStatus(card.Status, textWidth), + }, "\n") + + return styles.BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Width(cardWidth). + Padding(0, 1). + Render(content) +} + +// splitTitle wraps a title onto at most two lines of the given width, word +// wrapping when possible and truncating the rest. +func splitTitle(title string, width int) (string, string) { + words := strings.Fields(title) + var line1 string + for i, word := range words { + candidate := word + if line1 != "" { + candidate = line1 + " " + word + } + if line1 != "" && lipgloss.Width(candidate) > width { + return line1, toolcommon.TruncateText(strings.Join(words[i:], " "), width) + } + line1 = candidate + } + return toolcommon.TruncateText(line1, width), "" +} + +func (m *model) renderStatus(status board.CardStatus, width int) string { + spinner := spinnerFrames[m.frame%len(spinnerFrames)] + switch status { + case board.StatusStarting: + return styles.WarningStyle.Render(toolcommon.TruncateText(spinner+" starting", width)) + case board.StatusRunning: + return styles.InfoStyle.Render(toolcommon.TruncateText(spinner+" running", width)) + case board.StatusPaused: + return styles.WarningStyle.Render(toolcommon.TruncateText("∥ paused", width)) + case board.StatusError: + return styles.ErrorStyle.Render(toolcommon.TruncateText("✗ failed", width)) + default: // waiting + return styles.SuccessStyle.Render(toolcommon.TruncateText("● ready", width)) + } +} + +func (m *model) renderFooter() string { + if m.flash != "" { + style := styles.SuccessStyle + if m.isErr { + style = styles.ErrorStyle + } + return style.Render(" " + toolcommon.TruncateText(m.flash, max(m.width-2, 1))) + } + + hints := []string{ + "n new", "⏎ attach", "d diff", "o editor", "[ ] move", "x delete", + "p projects", "e prompt", "? help", "q quit", + } + left := styles.MutedStyle.Render(" " + toolcommon.TruncateText(strings.Join(hints, " · "), max(m.width-2, 1))) + + // Right side: where the selected card's work lives. + card := m.selectedCard() + if card == nil { + return left + } + details := styles.MutedStyle.Render(toolcommon.TruncateText( + sanitize(card.Agent+" · "+card.Branch)+" ", max(m.width-lipgloss.Width(left)-2, 0))) + pad := max(m.width-lipgloss.Width(left)-lipgloss.Width(details), 1) + return left + strings.Repeat(" ", pad) + details +} + +// columnAt maps an x/y terminal coordinate to the column under it. It +// mirrors the layout produced by renderBoard. +func (m *model) columnAt(x, y int) (int, bool) { + if len(m.columns) == 0 { + return 0, false + } + offset, count := m.columnWindow() + boardHeight, colWidth := m.boardSize() + if y < boardTop || y >= boardTop+boardHeight { + return 0, false + } + col := offset + x/(colWidth+columnGap) + if col >= offset+count || x%(colWidth+columnGap) >= colWidth { + return 0, false + } + return col, true +} + +// cardAt maps terminal coordinates to the (column, card) under them. It +// mirrors the layout produced by renderBoard. +func (m *model) cardAt(x, y int) (col, row int, ok bool) { + col, ok = m.columnAt(x, y) + if !ok { + return 0, 0, false + } + + // Rows above the first card: board top offset + column border (1) + + // header rows. + relY := y - boardTop - 1 - columnHeaderRows + if relY < 0 { + return 0, 0, false + } + slot := relY / cardHeight + if slot >= m.visibleSlots() { + return 0, 0, false + } + row = m.scroll[m.columns[col].ID] + slot + if row >= len(m.cards[m.columns[col].ID]) { + return 0, 0, false + } + return col, row, true +} diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index 2bff27dc0..e0f640f61 100644 --- a/pkg/userconfig/userconfig.go +++ b/pkg/userconfig/userconfig.go @@ -154,6 +154,35 @@ func (s *Settings) GlobalHooks() *latest.HooksConfig { return s.Hooks } +// BoardProject is a repository the board can create cards against. +type BoardProject struct { + // Name is the display name shown on cards. + Name string `yaml:"name"` + // Path is the repository's local path. + Path string `yaml:"path"` + // Agent is the agent ref launched for the project's cards (defaults to + // the built-in root agent when empty). + Agent string `yaml:"agent,omitempty"` +} + +// BoardColumn is one column of the board's pipeline. When a card moves +// forward into a column, the column's prompt is sent to the card's agent. +type BoardColumn struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Emoji string `yaml:"emoji,omitempty"` + Prompt string `yaml:"prompt,omitempty"` +} + +// Board configures the `docker agent board` Kanban TUI. +type Board struct { + // Projects are the repositories cards can be created against. + Projects []BoardProject `yaml:"projects,omitempty"` + // Columns overrides the default pipeline (Dev → Simplify → Review → + // Fix → Push → Done). Leave empty to keep the defaults. + Columns []BoardColumn `yaml:"columns,omitempty"` +} + // CredentialHelper contains configuration for a credential helper command // that retrieves Docker credentials (DOCKER_TOKEN) from an external source. type CredentialHelper struct { @@ -183,6 +212,8 @@ type Config struct { Aliases map[string]*Alias `yaml:"aliases,omitempty"` // Settings contains global user settings Settings *Settings `yaml:"settings,omitempty"` + // Board configures the `docker agent board` Kanban TUI. + Board *Board `yaml:"board,omitempty"` // CredentialHelper configures an external command to retrieve Docker credentials CredentialHelper *CredentialHelper `yaml:"credential_helper,omitempty"` // SandboxAllowlist is the persistent list of hosts the user has