From cbad163aca36dc429cbf0d968ab2cfb478ca5d58 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 09:36:41 +0200 Subject: [PATCH 01/53] feat: add docker agent board kanban TUI Assisted-By: claude-opus-4-5 --- cmd/root/board.go | 36 +++ cmd/root/root.go | 1 + docs/features/board/index.md | 80 ++++++ pkg/board/app.go | 317 +++++++++++++++++++++ pkg/board/client.go | 195 +++++++++++++ pkg/board/controller.go | 353 +++++++++++++++++++++++ pkg/board/controller_test.go | 158 +++++++++++ pkg/board/git.go | 162 +++++++++++ pkg/board/model.go | 154 ++++++++++ pkg/board/model_test.go | 55 ++++ pkg/board/store.go | 181 ++++++++++++ pkg/board/store_test.go | 119 ++++++++ pkg/board/tmux.go | 195 +++++++++++++ pkg/board/tmux_test.go | 31 ++ pkg/board/tui/dialogs.go | 445 +++++++++++++++++++++++++++++ pkg/board/tui/tui.go | 528 +++++++++++++++++++++++++++++++++++ pkg/board/tui/tui_test.go | 46 +++ pkg/board/tui/view.go | 280 +++++++++++++++++++ pkg/userconfig/userconfig.go | 31 ++ 19 files changed, 3367 insertions(+) create mode 100644 cmd/root/board.go create mode 100644 docs/features/board/index.md create mode 100644 pkg/board/app.go create mode 100644 pkg/board/client.go create mode 100644 pkg/board/controller.go create mode 100644 pkg/board/controller_test.go create mode 100644 pkg/board/git.go create mode 100644 pkg/board/model.go create mode 100644 pkg/board/model_test.go create mode 100644 pkg/board/store.go create mode 100644 pkg/board/store_test.go create mode 100644 pkg/board/tmux.go create mode 100644 pkg/board/tmux_test.go create mode 100644 pkg/board/tui/dialogs.go create mode 100644 pkg/board/tui/tui.go create mode 100644 pkg/board/tui/tui_test.go create mode 100644 pkg/board/tui/view.go diff --git a/cmd/root/board.go b/cmd/root/board.go new file mode 100644 index 000000000..e173c77a6 --- /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 → Simplify → Review → Fix → 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..c9fc6419b --- /dev/null +++ b/docs/features/board/index.md @@ -0,0 +1,80 @@ +--- +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 +--- + +_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 → Simplify → Review → Fix → 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 | +| `[` / `]` | 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 | +| `?` | 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..17ce970fb --- /dev/null +++ b/pkg/board/app.go @@ -0,0 +1,317 @@ +package board + +import ( + "context" + "errors" + "fmt" + "os/exec" + "slices" + "strings" + "sync" + + "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") + } + + 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 + } + 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. +func (a *App) AddProject(p Project) error { + if strings.TrimSpace(p.Name) == "" { + return errors.New("project name is required") + } + 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() +} + +// 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 + } + if err := a.store.DeleteCard(cardID); err != nil { + return err + } + a.controller.Stop(cardID) + _ = a.sessions.KillSession(card.Session) + removeWorktree(a.ctx, card.RepoPath, card.Worktree, card.Branch) + 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) +} + +// 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 + } + return exec.CommandContext(a.ctx, "tmux", "-S", TmuxSocketPath(), "attach", "-t", "="+card.Session), nil +} 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..7e81cf327 --- /dev/null +++ b/pkg/board/controller.go @@ -0,0 +1,353 @@ +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 +} + +// 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.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 +} + +// 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..9e7fe74f3 --- /dev/null +++ b/pkg/board/controller_test.go @@ -0,0 +1,158 @@ +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) +} + +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..b037b9094 --- /dev/null +++ b/pkg/board/git.go @@ -0,0 +1,162 @@ +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. + if _, err := os.Stat(worktree); err != nil { + return "", nil + } + + 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 before assuming "main". +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 + } + } + return remote + "/main" +} + +// 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/model.go b/pkg/board/model.go new file mode 100644 index 000000000..468d0c35b --- /dev/null +++ b/pkg/board/model.go @@ -0,0 +1,154 @@ +// 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: "simplify", Name: "Simplify", Emoji: "✨", Prompt: "Start by committing any local changes. Then look at these changes and try to simplify the code and architecture but don't remove any feature. I just want the code to be easier to read and maintain."}, + {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: "fix", Name: "Fix", Emoji: "🔧", Prompt: "Run the linter and fix any lint issues. Run the tests and fix any failing tests."}, + {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..06dc01e6c --- /dev/null +++ b/pkg/board/tmux.go @@ -0,0 +1,195 @@ +package board + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// 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) +} + +// 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. +func TmuxSocketPath() string { + return filepath.Join(os.TempDir(), "cagent-board-"+strconv.Itoa(os.Getuid())+".sock") +} + +// 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) { + out, err := exec.CommandContext(ctx, "tmux", append([]string{"-S", TmuxSocketPath()}, 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 agent +// is exec'd into the pane (replacing the shell) so that, when it exits, the +// pane becomes a dead pane instead of dropping back to a shell. Combined +// with remain-on-exit, the tmux session outlives a dead agent: the user can +// still 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 + // while the shell is still running 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 replaces the shell with the agent so the agent becomes the + // pane's process: when it exits the pane goes dead (see remain-on-exit). + cmd := "exec " + agentCommand(agent, sessionID, listenSocket, worktreeName, worktreeBase, prompt) + if _, err := tmuxRun(t.ctx, "send-keys", "-t", name, cmd, "Enter"); 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/tui/dialogs.go b/pkg/board/tui/dialogs.go new file mode 100644 index 000000000..f58b69e04 --- /dev/null +++ b/pkg/board/tui/dialogs.go @@ -0,0 +1,445 @@ +package tui + +import ( + "strings" + + "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/toolcommon" + "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. +func renderDialog(title string, width int, sections ...string) string { + content := make([]string, 0, len(sections)+2) + content = append(content, styles.DialogTitleStyle.Width(width).Render(title), "") + content = append(content, sections...) + return styles.DialogStyle.Render(lipgloss.JoinVertical(lipgloss.Left, content...)) +} + +func helpLine(width int, hints ...string) string { + return styles.MutedStyle.Width(width).Render(toolcommon.TruncateText(strings.Join(hints, " "), width)) +} + +// --- 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 +} + +func newCardDialog(projects []board.Project) *cardDialog { + ta := textarea.New() + ta.SetStyles(styles.InputStyle) + ta.Placeholder = "Describe the task for the agent…" + ta.ShowLineNumbers = false + ta.SetHeight(6) + // Enter submits the card; newlines go through shift+enter / ctrl+j. + ta.KeyMap.InsertNewline.SetKeys("shift+enter", "ctrl+j") + ta.Focus() + return &cardDialog{projects: projects, prompt: ta} +} + +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": + d.projIdx = (d.projIdx + 1) % len(d.projects) + return d, nil + case "shift+tab": + d.projIdx = (d.projIdx + len(d.projects) - 1) % len(d.projects) + return d, nil + case "enter": + 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, _ int) string { + w := dialogWidth(80, width) + d.prompt.SetWidth(w) + + chips := make([]string, 0, len(d.projects)) + for i, p := range d.projects { + style := styles.MutedStyle + if i == d.projIdx { + style = styles.BaseStyle.Foreground(styles.BadgeCyan).Bold(true).Underline(true) + } + chips = append(chips, style.Render(p.Name)) + } + projectLine := styles.SecondaryStyle.Render("Project ") + strings.Join(chips, styles.MutedStyle.Render(" · ")) + + return renderDialog("New card", w, + toolcommon.TruncateText(projectLine, w), + "", + d.prompt.View(), + "", + helpLine(w, "enter create", "shift+enter newline", "tab project", "esc cancel"), + ) +} + +// --- 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 "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, _ int) string { + w := dialogWidth(90, width) + d.prompt.SetWidth(w) + return renderDialog(d.column.Emoji+" "+d.column.Name+" · column prompt", w, + d.prompt.View(), + "", + helpLine(w, "ctrl+s save", "esc cancel"), + ) +} + +// --- projects manager --- + +// projectsDialog lists and edits the projects stored in the user's global +// config file. +type projectsDialog struct { + projects []board.Project + idx int + + adding bool + inputs []textinput.Model // name, path, agent + focus int +} + +func newProjectsDialog(projects []board.Project) *projectsDialog { + return &projectsDialog{projects: projects} +} + +var projectFields = []struct{ label, placeholder string }{ + {"Name", "my-project"}, + {"Path", "/path/to/git/repository"}, + {"Agent", "default (or any agent ref)"}, +} + +func (d *projectsDialog) startAdding() tea.Cmd { + d.adding = true + 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].Focus() + return textinput.Blink +} + +func (d *projectsDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return d, nil + } + if d.adding { + return d.updateAdding(key) + } + + switch key.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": + cmd := d.startAdding() + return d, cmd + case "x", "d", "backspace", "delete": + if len(d.projects) > 0 { + name := d.projects[d.idx].Name + d.idx = max(d.idx-1, 0) + return d, func() tea.Msg { return deleteProjectMsg{name: name} } + } + } + return d, nil +} + +func (d *projectsDialog) updateAdding(key tea.KeyPressMsg) (dialog, tea.Cmd) { + switch key.String() { + case "esc": + d.adding = false + return d, nil + 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(key) + 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) + if d.adding { + 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 + if i == d.idx { + marker, nameStyle = styles.SuccessStyle.Render("❯ "), styles.HighlightWhiteStyle + } + agent := p.Agent + if agent == "" { + agent = board.DefaultAgent + } + line := marker + nameStyle.Render(p.Name) + + styles.MutedStyle.Render(" "+p.Path+" · ") + + styles.BaseStyle.Foreground(styles.BadgeCyan).Render(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", "esc back"), + ) +} + +// --- diff viewer --- + +// diffDialog shows a card's worktree diff in a scrollable viewport. +type diffDialog struct { + title string + view viewport.Model + empty bool +} + +func newDiffDialog(title, diff string) *diffDialog { + vp := viewport.New() + vp.SoftWrap = false + d := &diffDialog{title: title, view: vp, empty: strings.TrimSpace(diff) == ""} + if !d.empty { + d.view.SetContent(colorizeDiff(diff)) + } + return d +} + +func (d *diffDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { + if key, ok := msg.(tea.KeyPressMsg); ok { + switch key.String() { + case "esc", "q", "d": + return d, closeDialog + } + } + 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, "esc close"), + ) + } + d.view.SetWidth(w) + d.view.SetHeight(h) + return renderDialog("Diff · "+d.title, w, + d.view.View(), + "", + helpLine(w, "↑↓ scroll", "esc close"), + ) +} + +// 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 +} + +func newConfirmDialog(card *board.Card) *confirmDialog { + return &confirmDialog{card: card} +} + +func (d *confirmDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { + if key, ok := msg.(tea.KeyPressMsg); ok { + switch key.String() { + case "y", "enter": + cardID := d.card.ID + return d, func() tea.Msg { return confirmDeleteMsg{cardID: cardID} } + case "n", "esc", "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(d.card.Title, w)), + styles.MutedStyle.Render("Kills the agent session and deletes its worktree and branch."), + "", + helpLine(w, "y delete", "esc cancel"), + ) +} + +// --- help --- + +type helpDialog struct{} + +func newHelpDialog() *helpDialog { return &helpDialog{} } + +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"}, + {"[ / ]", "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"}, + {keys.Quit.Help().Key, "quit (agents keep running in tmux)"}, + } + rows := make([]string, 0, len(bindings)) + for _, b := range bindings { + rows = append(rows, styles.BaseStyle.Foreground(styles.BadgeCyan).Width(12).Render(b.key)+styles.SecondaryStyle.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/tui.go b/pkg/board/tui/tui.go new file mode 100644 index 000000000..5a3cadfd1 --- /dev/null +++ b/pkg/board/tui/tui.go @@ -0,0 +1,528 @@ +// Package tui implements the full-screen Kanban TUI for `docker agent board`. +package tui + +import ( + "context" + "os/exec" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/docker/docker-agent/pkg/board" +) + +// 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 } + // attachDoneMsg means the user detached from a card's tmux session. + attachDoneMsg struct{ err error } + // diffLoadedMsg carries a card's worktree diff. + diffLoadedMsg struct { + title string + diff string + } + // 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 { + 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 + New key.Binding + Attach key.Binding + Diff key.Binding + MoveFwd key.Binding + MoveBack key.Binding + Delete key.Binding + Projects key.Binding + Prompt 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")), + 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")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), 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 + + // 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 + + frame int // spinner animation frame + ticker bool // whether a tick is scheduled + + flash string + flashID int + isErr bool + + dialog dialog + + // lastClick* back double-click-to-attach on cards. + lastClickCard string + lastClickTime time.Time +} + +func newModel(app *board.App, refresh chan struct{}) *model { + m := &model{ + app: app, + refresh: refresh, + scroll: make(map[string]int), + } + m.reload() + return m +} + +// reload re-snapshots columns and cards from the engine and clamps the +// selection. +func (m *model) reload() { + m.columns = m.app.Columns() + m.cards = make(map[string][]*board.Card, len(m.columns)) + for _, card := range m.app.Cards() { + m.cards[card.Column] = append(m.cards[card.Column], card) + } + m.clampSelection() +} + +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 attachDoneMsg: + if msg.err != nil { + cmd := m.setFlash("attach: "+msg.err.Error(), true) + return m, cmd + } + return m, nil + + case diffLoadedMsg: + m.dialog = newDiffDialog(msg.title, msg.diff) + return m, nil + + case closeDialogMsg: + m.dialog = nil + return m, nil + + case submitNewCardMsg: + m.dialog = nil + 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.dialog = newProjectsDialog(m.app.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.dialog = newProjectsDialog(m.app.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 { + // ctrl+c always quits, even while a dialog captures the keyboard. + if press, ok := msg.(tea.KeyPressMsg); ok && press.String() == "ctrl+c" { + 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) + } + 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, 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.New): + projects := m.app.Projects() + if len(projects) == 0 { + m.dialog = newProjectsDialog(nil) + cmd := m.setFlash("Add a project first: cards are created against a project", false) + return m, cmd + } + m.dialog = newCardDialog(projects) + cmd := m.dialog.(*cardDialog).Init() + 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) + 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 { + m.dialog = newConfirmDialog(card) + } + + case key.Matches(msg, keys.Projects): + m.dialog = newProjectsDialog(m.app.Projects()) + + case key.Matches(msg, keys.Prompt): + if len(m.columns) > 0 { + col := m.columns[m.selCol] + d := newPromptDialog(col) + m.dialog = d + return m, d.Init() + } + + case key.Matches(msg, keys.Help): + m.dialog = newHelpDialog() + } + 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) < 400*time.Millisecond { + m.lastClickCard = "" + cmd := m.attach(card.ID) + return m, cmd + } + m.lastClickCard = card.ID + m.lastClickTime = time.Now() + return m, nil +} + +// setFlash shows a transient footer message for a few seconds. +func (m *model) setFlash(text string, isErr bool) tea.Cmd { + m.flash = 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. +func (m *model) attach(cardID string) tea.Cmd { + return func() tea.Msg { + cmd, err := m.app.AttachCommand(cardID) + if err != nil { + return flashMsg{text: err.Error(), isErr: true} + } + return attachReadyMsg{cmd: cmd} + } +} + +func (m *model) loadDiff(card *board.Card) tea.Cmd { + return func() tea.Msg { + diff, err := m.app.Diff(card.ID) + if err != nil { + return flashMsg{text: err.Error(), isErr: true} + } + return diffLoadedMsg{title: card.Title, diff: diff} + } +} + +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..bbe778e04 --- /dev/null +++ b/pkg/board/tui/tui_test.go @@ -0,0 +1,46 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +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..9d6b26662 --- /dev/null +++ b/pkg/board/tui/view.go @@ -0,0 +1,280 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "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 +) + +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +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) + } + + view := tea.NewView(body) + view.AltScreen = true + view.MouseMode = tea.MouseModeCellMotion + view.BackgroundColor = styles.Background + view.WindowTitle = "docker agent board" + return view +} + +// 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.NewLayer(base)) + canvas.Compose(dialog) + return canvas.Render() +} + +func (m *model) renderHeader() string { + title := styles.HighlightWhiteStyle.Render(" 🐳 Board") + + total := 0 + for _, cards := range m.cards { + total += len(cards) + } + info := styles.MutedStyle.Render(fmt.Sprintf("%s · %s ", + plural(len(m.app.Projects()), "project"), plural(total, "card"))) + + pad := max(m.width-lipgloss.Width(title)-lipgloss.Width(info), 1) + return title + strings.Repeat(" ", pad) + info +} + +func plural(n int, word string) string { + if n == 1 { + return "1 " + word + } + return strconv.Itoa(n) + " " + word + "s" +} + +// 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) + n := max(len(m.columns), 1) + colWidth = max((m.width-(n-1)*columnGap)/n, 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 "" + } + boardHeight, colWidth := m.boardSize() + + cols := make([]string, 0, len(m.columns)*2) + for i, col := range m.columns { + if i > 0 { + cols = append(cols, strings.Repeat(" ", columnGap)) + } + cols = append(cols, m.renderColumn(i, col, 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. + nameStyle := styles.SecondaryStyle + if selected { + nameStyle = styles.HighlightWhiteStyle + } + cards := m.cards[col.ID] + count := styles.MutedStyle.Render(" " + strconv.Itoa(len(cards))) + header := " " + col.Emoji + " " + nameStyle.Render(col.Name) + count + 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")) +} + +func (m *model) renderCard(card *board.Card, colInnerWidth int, selected bool) string { + cardWidth := colInnerWidth - 2 // card border + textWidth := max(cardWidth-2, 1) + + titleStyle := styles.BaseStyle + borderColor := styles.BorderMuted + if selected { + titleStyle = styles.HighlightWhiteStyle + borderColor = styles.BorderPrimary + } + + title1, title2 := splitTitle(card.Title, textWidth) + project := styles.MutedStyle.Render(toolcommon.TruncateText("◆ "+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", "[ ] move", "x delete", + "p projects", "e prompt", "? help", "q quit", + } + return styles.MutedStyle.Render(" " + toolcommon.TruncateText(strings.Join(hints, " · "), max(m.width-2, 1))) +} + +// 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) { + if len(m.columns) == 0 { + return 0, 0, false + } + _, colWidth := m.boardSize() + + col = x / (colWidth + columnGap) + relX := x % (colWidth + columnGap) + if col >= len(m.columns) || relX >= colWidth { + 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 From f7f4cc26633d7af8594dc2bda673d62f8aac796a Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:05:01 +0200 Subject: [PATCH 02/53] fix(board): only treat a missing worktree as an empty diff Any other stat failure (permissions, corrupt path) was silently hidden as "no changes"; surface it instead. --- pkg/board/git.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/board/git.go b/pkg/board/git.go index b037b9094..3cf799868 100644 --- a/pkg/board/git.go +++ b/pkg/board/git.go @@ -37,9 +37,11 @@ func isGitRepo(ctx context.Context, path string) bool { 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. - if _, err := os.Stat(worktree); err != nil { + // 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)) From 37a9eae0ab902595b315ec712dd9b9d8ce481794 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:05:43 +0200 Subject: [PATCH 03/53] fix(board): require and normalize project paths An empty path silently validated against the board's working directory and a relative one depended on it. Reject blank paths, expand a leading ~, and store absolute paths. --- pkg/board/app.go | 28 +++++++++++++++++++++++++++- pkg/board/app_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 pkg/board/app_test.go diff --git a/pkg/board/app.go b/pkg/board/app.go index 17ce970fb..978daea8d 100644 --- a/pkg/board/app.go +++ b/pkg/board/app.go @@ -5,10 +5,12 @@ import ( "errors" "fmt" "os/exec" + "path/filepath" "slices" "strings" "sync" + "github.com/docker/docker-agent/pkg/paths" "github.com/docker/docker-agent/pkg/userconfig" ) @@ -136,11 +138,17 @@ func (a *App) Projects() []Project { } // AddProject validates and appends a project, persisting it to the user's -// global config file. +// 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) } @@ -163,6 +171,24 @@ func (a *App) AddProject(p Project) error { 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 { 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)) +} From ddbb080d6cd849d5c93fb83e2290283cedefaad2 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:06:26 +0200 Subject: [PATCH 04/53] fix(board): keep cards visible when their column is removed A card whose column was dropped from the configured pipeline silently disappeared from the TUI while its agent kept running: impossible to attach, move, or delete. Bucket such cards into the first column. --- pkg/board/tui/tui.go | 24 ++++++++++++++++++++---- pkg/board/tui/tui_test.go | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index 5a3cadfd1..d0b007540 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -166,13 +166,29 @@ func newModel(app *board.App, refresh chan struct{}) *model { // selection. func (m *model) reload() { m.columns = m.app.Columns() - m.cards = make(map[string][]*board.Card, len(m.columns)) - for _, card := range m.app.Cards() { - m.cards[card.Column] = append(m.cards[card.Column], card) - } + m.cards = groupCards(m.columns, m.app.Cards()) 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 diff --git a/pkg/board/tui/tui_test.go b/pkg/board/tui/tui_test.go index bbe778e04..9d2b38056 100644 --- a/pkg/board/tui/tui_test.go +++ b/pkg/board/tui/tui_test.go @@ -5,8 +5,26 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "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 TestSplitTitle(t *testing.T) { t.Parallel() From 39d5c255f927f1474e6389a45f2d8e74bce1a15a Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:06:56 +0200 Subject: [PATCH 05/53] fix(board): serialize session relaunches A watcher's background resume could race a prompt-bearing relaunch and kill the session it had just created, dropping the prompt. Relaunches now run under a lock, and a plain resume skips sessions a concurrent relaunch already resurrected. --- pkg/board/controller.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkg/board/controller.go b/pkg/board/controller.go index 7e81cf327..0d3f7c363 100644 --- a/pkg/board/controller.go +++ b/pkg/board/controller.go @@ -46,6 +46,11 @@ type controller struct { 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. @@ -322,6 +327,20 @@ func (c *controller) SendPrompt(card *Card, prompt string) error { // 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() + + // 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 From 309715a638afaabf342fb0aa644fc02ee2b96ae1 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:07:17 +0200 Subject: [PATCH 06/53] fix(board): move the private tmux socket into a per-user 0700 dir A predictable socket path directly under /tmp let other local users pre-create or interfere with it. Follow tmux's own /tmp/tmux- convention. --- pkg/board/tmux.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/board/tmux.go b/pkg/board/tmux.go index 06dc01e6c..b67e75a4c 100644 --- a/pkg/board/tmux.go +++ b/pkg/board/tmux.go @@ -39,8 +39,14 @@ type sessionManager interface { // 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 here, before tmux binds the socket. func TmuxSocketPath() string { - return filepath.Join(os.TempDir(), "cagent-board-"+strconv.Itoa(os.Getuid())+".sock") + dir := filepath.Join(os.TempDir(), "cagent-board-"+strconv.Itoa(os.Getuid())) + _ = os.MkdirAll(dir, 0o700) + return filepath.Join(dir, "tmux.sock") } // serverDefaults are tmux options the board applies to its private server so From b04e682818cc2b349bc9fc5e6ab7d8b19cf39d30 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:08:05 +0200 Subject: [PATCH 07/53] refactor(board): let dialogs provide their own init command Removes the concrete-type assertions at dialog open sites: every dialog now implements Init and a single openDialog helper installs it. --- pkg/board/tui/dialogs.go | 8 ++++++++ pkg/board/tui/tui.go | 38 +++++++++++++++++++++----------------- pkg/board/tui/tui_test.go | 1 - 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index f58b69e04..143e4d513 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -194,6 +194,8 @@ func (d *projectsDialog) startAdding() tea.Cmd { return textinput.Blink } +func (d *projectsDialog) Init() tea.Cmd { return nil } + func (d *projectsDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { key, ok := msg.(tea.KeyPressMsg) if !ok { @@ -320,6 +322,8 @@ func newDiffDialog(title, diff string) *diffDialog { return d } +func (d *diffDialog) Init() tea.Cmd { return nil } + func (d *diffDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { if key, ok := msg.(tea.KeyPressMsg); ok { switch key.String() { @@ -384,6 +388,8 @@ func newConfirmDialog(card *board.Card) *confirmDialog { return &confirmDialog{card: card} } +func (d *confirmDialog) Init() tea.Cmd { return nil } + func (d *confirmDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { if key, ok := msg.(tea.KeyPressMsg); ok { switch key.String() { @@ -413,6 +419,8 @@ 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 diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index d0b007540..44bed8d15 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -83,6 +83,7 @@ type ( // 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 } @@ -162,6 +163,12 @@ func newModel(app *board.App, refresh chan struct{}) *model { 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() { @@ -299,8 +306,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case diffLoadedMsg: - m.dialog = newDiffDialog(msg.title, msg.diff) - return m, nil + cmd := m.openDialog(newDiffDialog(msg.title, msg.diff)) + return m, cmd case closeDialogMsg: m.dialog = nil @@ -316,8 +323,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.setFlash(err.Error(), true) return m, cmd } - m.dialog = newProjectsDialog(m.app.Projects()) - cmd := m.setFlash("Project added to the global config", false) + cmd := tea.Batch(m.openDialog(newProjectsDialog(m.app.Projects())), m.setFlash("Project added to the global config", false)) return m, cmd case deleteProjectMsg: @@ -325,8 +331,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.setFlash(err.Error(), true) return m, cmd } - m.dialog = newProjectsDialog(m.app.Projects()) - return m, nil + return m, m.openDialog(newProjectsDialog(m.app.Projects())) case submitPromptMsg: m.dialog = nil @@ -380,12 +385,10 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.New): projects := m.app.Projects() if len(projects) == 0 { - m.dialog = newProjectsDialog(nil) - cmd := m.setFlash("Add a project first: cards are created against a project", false) + cmd := tea.Batch(m.openDialog(newProjectsDialog(nil)), m.setFlash("Add a project first: cards are created against a project", false)) return m, cmd } - m.dialog = newCardDialog(projects) - cmd := m.dialog.(*cardDialog).Init() + cmd := m.openDialog(newCardDialog(projects)) return m, cmd case key.Matches(msg, keys.Attach): @@ -409,22 +412,23 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Delete): if card := m.selectedCard(); card != nil { - m.dialog = newConfirmDialog(card) + cmd := m.openDialog(newConfirmDialog(card)) + return m, cmd } case key.Matches(msg, keys.Projects): - m.dialog = newProjectsDialog(m.app.Projects()) + cmd := m.openDialog(newProjectsDialog(m.app.Projects())) + return m, cmd case key.Matches(msg, keys.Prompt): if len(m.columns) > 0 { - col := m.columns[m.selCol] - d := newPromptDialog(col) - m.dialog = d - return m, d.Init() + cmd := m.openDialog(newPromptDialog(m.columns[m.selCol])) + return m, cmd } case key.Matches(msg, keys.Help): - m.dialog = newHelpDialog() + cmd := m.openDialog(newHelpDialog()) + return m, cmd } return m, nil } diff --git a/pkg/board/tui/tui_test.go b/pkg/board/tui/tui_test.go index 9d2b38056..9d9248dc5 100644 --- a/pkg/board/tui/tui_test.go +++ b/pkg/board/tui/tui_test.go @@ -24,7 +24,6 @@ func TestGroupCardsFallsBackToFirstColumn(t *testing.T) { assert.Len(t, grouped["done"], 1) } - func TestSplitTitle(t *testing.T) { t.Parallel() From a75e33bed9c0f5a78a5cbb0df5ee1bd8d8a25af1 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:08:27 +0200 Subject: [PATCH 08/53] refactor(board): reuse the TUI's shared double-click threshold --- pkg/board/tui/tui.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index 44bed8d15..3e65d04fa 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/docker/docker-agent/pkg/board" + "github.com/docker/docker-agent/pkg/tui/styles" ) // Run starts the board TUI and blocks until the user quits. @@ -464,7 +465,7 @@ func (m *model) handleClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) { if card == nil { return m, nil } - if m.lastClickCard == card.ID && time.Since(m.lastClickTime) < 400*time.Millisecond { + if m.lastClickCard == card.ID && time.Since(m.lastClickTime) < styles.DoubleClickThreshold { m.lastClickCard = "" cmd := m.attach(card.ID) return m, cmd From 31fcb141d4cd5e60d4a5505c1fdddf59aec3ddf5 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:09:24 +0200 Subject: [PATCH 09/53] feat(board): show a welcome overlay while the board is empty First-run users saw six empty columns with no guidance; the overlay explains the model and points at the n/p/? keys. --- pkg/board/tui/view.go | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 9d6b26662..67a0ea335 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -51,6 +51,8 @@ func (m *model) View() tea.View { 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) @@ -75,15 +77,36 @@ func placeOverlay(base, overlay string, width, height int) string { func (m *model) renderHeader() string { title := styles.HighlightWhiteStyle.Render(" 🐳 Board") + info := styles.MutedStyle.Render(fmt.Sprintf("%s · %s ", + plural(len(m.app.Projects()), "project"), plural(m.totalCards(), "card"))) + + pad := max(m.width-lipgloss.Width(title)-lipgloss.Width(info), 1) + return title + strings.Repeat(" ", pad) + info +} + +func (m *model) totalCards() int { total := 0 for _, cards := range m.cards { total += len(cards) } - info := styles.MutedStyle.Render(fmt.Sprintf("%s · %s ", - plural(len(m.app.Projects()), "project"), plural(total, "card"))) + return total +} - pad := max(m.width-lipgloss.Width(title)-lipgloss.Width(info), 1) - return title + strings.Repeat(" ", pad) + info +// 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 { From 942e1c6f1de1980d2f481910a6c820b0e475e3c9 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:09:50 +0200 Subject: [PATCH 10/53] feat(board): show the selected card's agent and branch in the footer Makes it easy to find the branch to check out or push without opening the agent session. --- pkg/board/tui/view.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 67a0ea335..4dff08916 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -268,7 +268,17 @@ func (m *model) renderFooter() string { "n new", "⏎ attach", "d diff", "[ ] move", "x delete", "p projects", "e prompt", "? help", "q quit", } - return styles.MutedStyle.Render(" " + toolcommon.TruncateText(strings.Join(hints, " · "), max(m.width-2, 1))) + 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( + 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 } // cardAt maps terminal coordinates to the (column, card) under them. It From e57bf6843459a978ec7ca6ecb253f0c35b161e3d Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:10:31 +0200 Subject: [PATCH 11/53] fix(board): keep the projects dialog cursor across add and delete The dialog was rebuilt from scratch on every change, resetting the cursor to the first entry. --- pkg/board/tui/dialogs.go | 9 ++++++++- pkg/board/tui/tui.go | 10 ++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 143e4d513..517114b69 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -173,6 +173,14 @@ 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.adding = false +} + var projectFields = []struct{ label, placeholder string }{ {"Name", "my-project"}, {"Path", "/path/to/git/repository"}, @@ -218,7 +226,6 @@ func (d *projectsDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { case "x", "d", "backspace", "delete": if len(d.projects) > 0 { name := d.projects[d.idx].Name - d.idx = max(d.idx-1, 0) return d, func() tea.Msg { return deleteProjectMsg{name: name} } } } diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index 3e65d04fa..b28e99398 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -324,7 +324,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.setFlash(err.Error(), true) return m, cmd } - cmd := tea.Batch(m.openDialog(newProjectsDialog(m.app.Projects())), m.setFlash("Project added to the global config", false)) + if d, ok := m.dialog.(*projectsDialog); ok { + d.setProjects(m.app.Projects()) + } + cmd := m.setFlash("Project added to the global config", false) return m, cmd case deleteProjectMsg: @@ -332,7 +335,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.setFlash(err.Error(), true) return m, cmd } - return m, m.openDialog(newProjectsDialog(m.app.Projects())) + if d, ok := m.dialog.(*projectsDialog); ok { + d.setProjects(m.app.Projects()) + } + return m, nil case submitPromptMsg: m.dialog = nil From 966f361546e990ec842d01f38bd8ea610fd8edca Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 10:11:38 +0200 Subject: [PATCH 12/53] test(board): cover mouse hit-testing geometry --- pkg/board/tui/tui_test.go | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pkg/board/tui/tui_test.go b/pkg/board/tui/tui_test.go index 9d9248dc5..e6e22f68f 100644 --- a/pkg/board/tui/tui_test.go +++ b/pkg/board/tui/tui_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/docker/docker-agent/pkg/board" ) @@ -24,6 +25,46 @@ func TestGroupCardsFallsBackToFirstColumn(t *testing.T) { assert.Len(t, grouped["done"], 1) } +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 TestSplitTitle(t *testing.T) { t.Parallel() From eb2a0ce6d4184382f37c402f244098b0ff8d6fea Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 12:27:51 +0200 Subject: [PATCH 13/53] fix(board): sanitize untrusted strings rendered in the TUI Session titles arrive from agent-controlled control planes and diffs are repository content: both could embed ANSI/OSC sequences (clipboard writes, title changes, screen manipulation). Strip terminal controls from titles, project names, flash messages, and diff content before rendering. --- pkg/board/tui/dialogs.go | 6 +++++- pkg/board/tui/tui.go | 6 ++++-- pkg/board/tui/tui_test.go | 9 +++++++++ pkg/board/tui/view.go | 23 +++++++++++++++++++++-- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 517114b69..75c4505d0 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -322,6 +322,10 @@ type diffDialog struct { func newDiffDialog(title, diff string) *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) d := &diffDialog{title: title, view: vp, empty: strings.TrimSpace(diff) == ""} if !d.empty { d.view.SetContent(colorizeDiff(diff)) @@ -413,7 +417,7 @@ func (d *confirmDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { func (d *confirmDialog) View(width, _ int) string { w := dialogWidth(60, width) return renderDialog("Delete card?", w, - styles.BaseStyle.Render(toolcommon.TruncateText(d.card.Title, 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, "y delete", "esc cancel"), diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index b28e99398..b899359b1 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -4,6 +4,7 @@ package tui import ( "context" "os/exec" + "strings" "time" "charm.land/bubbles/v2/key" @@ -481,9 +482,10 @@ func (m *model) handleClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) { return m, nil } -// setFlash shows a transient footer message for a few seconds. +// 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 = text + m.flash = strings.Join(strings.Fields(sanitize(text)), " ") m.isErr = isErr m.flashID++ id := m.flashID diff --git a/pkg/board/tui/tui_test.go b/pkg/board/tui/tui_test.go index e6e22f68f..9a213f4a2 100644 --- a/pkg/board/tui/tui_test.go +++ b/pkg/board/tui/tui_test.go @@ -65,6 +65,15 @@ func TestCardAtMirrorsLayout(t *testing.T) { assert.False(t, ok) } +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() diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 4dff08916..cb213b90d 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -7,6 +7,7 @@ import ( 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" @@ -31,6 +32,24 @@ const ( 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{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} func (m *model) View() tea.View { @@ -203,8 +222,8 @@ func (m *model) renderCard(card *board.Card, colInnerWidth int, selected bool) s borderColor = styles.BorderPrimary } - title1, title2 := splitTitle(card.Title, textWidth) - project := styles.MutedStyle.Render(toolcommon.TruncateText("◆ "+card.Project, textWidth)) + title1, title2 := splitTitle(sanitize(card.Title), textWidth) + project := styles.MutedStyle.Render(toolcommon.TruncateText(sanitize("◆ "+card.Project), textWidth)) content := strings.Join([]string{ titleStyle.Render(title1), From cb01c6570ca890b072c4e480189a83bd5ac03b55 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 12:28:46 +0200 Subject: [PATCH 14/53] fix(board): never relaunch the session of a deleted card SendPrompt runs outside the watcher, so a delete racing an in-flight prompt delivery could resurrect the tmux session (and worktree) of a card already gone from the store. relaunch now aborts when the card no longer exists, and DeleteCard kills the session under the same lock. --- pkg/board/app.go | 5 +++- pkg/board/controller.go | 18 +++++++++++++++ pkg/board/controller_test.go | 44 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/pkg/board/app.go b/pkg/board/app.go index 978daea8d..0bfa243ef 100644 --- a/pkg/board/app.go +++ b/pkg/board/app.go @@ -304,11 +304,14 @@ func (a *App) DeleteCard(cardID string) error { 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.sessions.KillSession(card.Session) + a.controller.Teardown(card) removeWorktree(a.ctx, card.RepoPath, card.Worktree, card.Branch) a.onChanged() return nil diff --git a/pkg/board/controller.go b/pkg/board/controller.go index 0d3f7c363..e4677c3ab 100644 --- a/pkg/board/controller.go +++ b/pkg/board/controller.go @@ -330,6 +330,14 @@ 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 @@ -359,6 +367,16 @@ func (c *controller) relaunch(card *Card, prompt string) error { 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) diff --git a/pkg/board/controller_test.go b/pkg/board/controller_test.go index 9e7fe74f3..626bf3057 100644 --- a/pkg/board/controller_test.go +++ b/pkg/board/controller_test.go @@ -145,6 +145,50 @@ func TestControllerTitleFromSnapshot(t *testing.T) { }, 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() From 3162923c2145482be4cb8d2c5025028814fd2fdd Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 12:30:25 +0200 Subject: [PATCH 15/53] fix(board): fail closed on an untrusted tmux socket directory The per-user socket dir under /tmp was created best-effort: a path pre-created by another local user would have been used silently. The directory is now validated once per process (real directory, owned by the current user, 0700) and tmux is never started when the checks fail. --- pkg/board/app.go | 6 ++++- pkg/board/tmux.go | 49 ++++++++++++++++++++++++++++++++++----- pkg/board/tmux_unix.go | 23 ++++++++++++++++++ pkg/board/tmux_windows.go | 9 +++++++ 4 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 pkg/board/tmux_unix.go create mode 100644 pkg/board/tmux_windows.go diff --git a/pkg/board/app.go b/pkg/board/app.go index 0bfa243ef..a4c70d28d 100644 --- a/pkg/board/app.go +++ b/pkg/board/app.go @@ -342,5 +342,9 @@ func (a *App) AttachCommand(cardID string) (*exec.Cmd, error) { if !a.controller.Ready(card) { return nil, ErrAgentStarting } - return exec.CommandContext(a.ctx, "tmux", "-S", TmuxSocketPath(), "attach", "-t", "="+card.Session), nil + 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/tmux.go b/pkg/board/tmux.go index b67e75a4c..163cc99dc 100644 --- a/pkg/board/tmux.go +++ b/pkg/board/tmux.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" ) // tmuxSessions manages the tmux sessions the board runs its agents in. @@ -34,6 +35,36 @@ type sessionManager interface { 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 @@ -42,11 +73,13 @@ type sessionManager interface { // // 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 here, before tmux binds the socket. -func TmuxSocketPath() string { - dir := filepath.Join(os.TempDir(), "cagent-board-"+strconv.Itoa(os.Getuid())) - _ = os.MkdirAll(dir, 0o700) - return filepath.Join(dir, "tmux.sock") +// 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 @@ -88,7 +121,11 @@ var serverDefaults = [][]string{ // 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) { - out, err := exec.CommandContext(ctx, "tmux", append([]string{"-S", TmuxSocketPath()}, args...)...).CombinedOutput() + 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) } 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 } From 56e586e89a6d42d7c765653f557e56e717458369 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 13:18:42 +0200 Subject: [PATCH 16/53] =?UTF-8?q?feat(board):=20trim=20the=20default=20pip?= =?UTF-8?q?eline=20to=20Dev=20=E2=86=92=20Review=20=E2=86=92=20Push=20?= =?UTF-8?q?=E2=86=92=20Done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the Simplify and Fix columns. Also stop freezing the built-in columns into the config file on every save: the columns section is only persisted when it differs from the defaults, so future default changes reach users who never customized their pipeline. --- cmd/root/board.go | 2 +- docs/features/board/index.md | 2 +- pkg/board/app.go | 12 +++++++++--- pkg/board/model.go | 2 -- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/cmd/root/board.go b/cmd/root/board.go index e173c77a6..c2298461f 100644 --- a/cmd/root/board.go +++ b/cmd/root/board.go @@ -17,7 +17,7 @@ func newBoardCmd() *cobra.Command { 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 → Simplify → Review → Fix → Push → Done) sends the +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 diff --git a/docs/features/board/index.md b/docs/features/board/index.md index c9fc6419b..b4f402a72 100644 --- a/docs/features/board/index.md +++ b/docs/features/board/index.md @@ -25,7 +25,7 @@ Requirements: `tmux` and `git` must be installed. 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 → Simplify → Review → Fix → Push → Done. Moving a card forward (`]`) + 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 diff --git a/pkg/board/app.go b/pkg/board/app.go index a4c70d28d..5da9307ff 100644 --- a/pkg/board/app.go +++ b/pkg/board/app.go @@ -115,9 +115,15 @@ func (a *App) saveConfigLocked() error { if a.config.Board != nil { *board = *a.config.Board } - 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}) + 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() diff --git a/pkg/board/model.go b/pkg/board/model.go index 468d0c35b..7d69a2daf 100644 --- a/pkg/board/model.go +++ b/pkg/board/model.go @@ -27,9 +27,7 @@ type Column struct { // DefaultColumns is the pipeline used when the user config defines none. var DefaultColumns = []Column{ {ID: "dev", Name: "Dev", Emoji: "🔨"}, - {ID: "simplify", Name: "Simplify", Emoji: "✨", Prompt: "Start by committing any local changes. Then look at these changes and try to simplify the code and architecture but don't remove any feature. I just want the code to be easier to read and maintain."}, {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: "fix", Name: "Fix", Emoji: "🔧", Prompt: "Run the linter and fix any lint issues. Run the tests and fix any failing tests."}, {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: "✅"}, } From 662b77c2d08f9466e6a0b14d0d6af1fd49972ce1 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 13:19:45 +0200 Subject: [PATCH 17/53] feat(board): color cards by project All of a project's cards share an accent color (border and project badge), matching the project chip in the new-card dialog, so each project's work is recognizable at a glance. Cards whose project was removed hash to a stable color. --- pkg/board/tui/dialogs.go | 2 +- pkg/board/tui/tui.go | 7 +++++++ pkg/board/tui/view.go | 30 ++++++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 75c4505d0..b73071127 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -94,7 +94,7 @@ func (d *cardDialog) View(width, _ int) string { for i, p := range d.projects { style := styles.MutedStyle if i == d.projIdx { - style = styles.BaseStyle.Foreground(styles.BadgeCyan).Bold(true).Underline(true) + style = styles.BaseStyle.Foreground(projectColorAt(i)).Bold(true).Underline(true) } chips = append(chips, style.Render(p.Name)) } diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index b899359b1..a0a4e8436 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -3,6 +3,7 @@ package tui import ( "context" + "image/color" "os/exec" "strings" "time" @@ -135,6 +136,8 @@ type model struct { columns []board.Column // cards holds each column's cards in board order, keyed by column ID. cards map[string][]*board.Card + // 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 @@ -176,6 +179,10 @@ func (m *model) openDialog(d dialog) tea.Cmd { func (m *model) reload() { m.columns = m.app.Columns() m.cards = groupCards(m.columns, m.app.Cards()) + m.projectColors = make(map[string]color.Color) + for i, p := range m.app.Projects() { + m.projectColors[p.Name] = projectColorAt(i) + } m.clampSelection() } diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index cb213b90d..116f803d3 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -2,6 +2,8 @@ package tui import ( "fmt" + "hash/fnv" + "image/color" "strconv" "strings" @@ -52,6 +54,27 @@ func sanitize(s string) string { 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 { @@ -215,15 +238,18 @@ func (m *model) renderCard(card *board.Card, colInnerWidth int, selected bool) s 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 := styles.BorderMuted + borderColor := accent if selected { titleStyle = styles.HighlightWhiteStyle borderColor = styles.BorderPrimary } title1, title2 := splitTitle(sanitize(card.Title), textWidth) - project := styles.MutedStyle.Render(toolcommon.TruncateText(sanitize("◆ "+card.Project), textWidth)) + project := styles.BaseStyle.Foreground(accent).Render(toolcommon.TruncateText(sanitize("◆ "+card.Project), textWidth)) content := strings.Join([]string{ titleStyle.Render(title1), From 7dfe8a16ebde065ddf17990f55a8209593bb82f4 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 13:20:04 +0200 Subject: [PATCH 18/53] feat(board): insert newlines with cmd+enter in the new-card prompt Enter still creates the card; the newline binding moves from shift+enter to cmd+enter (super+enter in CSI-u terms), with ctrl+j as a fallback for terminals without the Kitty keyboard protocol. --- pkg/board/tui/dialogs.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index b73071127..a36bb2ab9 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -53,8 +53,9 @@ func newCardDialog(projects []board.Project) *cardDialog { ta.Placeholder = "Describe the task for the agent…" ta.ShowLineNumbers = false ta.SetHeight(6) - // Enter submits the card; newlines go through shift+enter / ctrl+j. - ta.KeyMap.InsertNewline.SetKeys("shift+enter", "ctrl+j") + // Enter submits the card; newlines go through cmd+enter (super in CSI-u + // terms; ctrl+j works everywhere as a fallback). + ta.KeyMap.InsertNewline.SetKeys("super+enter", "ctrl+j") ta.Focus() return &cardDialog{projects: projects, prompt: ta} } @@ -105,7 +106,7 @@ func (d *cardDialog) View(width, _ int) string { "", d.prompt.View(), "", - helpLine(w, "enter create", "shift+enter newline", "tab project", "esc cancel"), + helpLine(w, "enter create", "⌘+enter newline", "tab project", "esc cancel"), ) } From 3171fd2a35f841c3f1ed0f2261783bb9b680bdce Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 13:20:56 +0200 Subject: [PATCH 19/53] feat(board): roomier new-card dialog, hide single-project selector The prompt textarea now spans most of the screen (up to 24 rows, width 100): long task descriptions are the norm, not the exception. When only one project is configured the selector row disappears entirely; the dialog title still names the target project. --- pkg/board/tui/dialogs.go | 53 +++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index a36bb2ab9..a478e260b 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -68,11 +68,15 @@ func (d *cardDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { case "esc": return d, closeDialog case "tab": - d.projIdx = (d.projIdx + 1) % len(d.projects) - return d, nil + if len(d.projects) > 1 { + d.projIdx = (d.projIdx + 1) % len(d.projects) + return d, nil + } case "shift+tab": - d.projIdx = (d.projIdx + len(d.projects) - 1) % len(d.projects) - return d, nil + if len(d.projects) > 1 { + d.projIdx = (d.projIdx + len(d.projects) - 1) % len(d.projects) + return d, nil + } case "enter": prompt := strings.TrimSpace(d.prompt.Value()) if prompt == "" { @@ -87,27 +91,36 @@ func (d *cardDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { return d, cmd } -func (d *cardDialog) View(width, _ int) string { - w := dialogWidth(80, width) +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)) - 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) + 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(p.Name)) } - chips = append(chips, style.Render(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"} } - projectLine := styles.SecondaryStyle.Render("Project ") + strings.Join(chips, styles.MutedStyle.Render(" · ")) - return renderDialog("New card", w, - toolcommon.TruncateText(projectLine, w), - "", - d.prompt.View(), - "", - helpLine(w, "enter create", "⌘+enter newline", "tab project", "esc cancel"), - ) + sections = append(sections, helpLine(w, hints...)) + return renderDialog("New card · "+d.projects[d.projIdx].Name, w, sections...) } // --- column prompt editor --- From 1572ce32e4a98e79d5ccb4c5056c847a10898d66 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 13:24:16 +0200 Subject: [PATCH 20/53] feat(board): pick the project path with a directory browser Adding a project now starts in a small directory picker (type to filter, enter descends, backspace walks up, git repositories are marked) instead of a bare text field. The picked directory pre-fills the form's name and path; ctrl+o re-opens the browser from the form. --- pkg/board/tui/dialogs.go | 66 ++++++++++-- pkg/board/tui/dirpicker.go | 208 +++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 pkg/board/tui/dirpicker.go diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index a478e260b..430dac57c 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -1,6 +1,7 @@ package tui import ( + "path/filepath" "strings" "charm.land/bubbles/v2/textarea" @@ -172,13 +173,24 @@ func (d *promptDialog) View(width, _ int) string { // --- 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. +// config file. Adding a project starts with a directory picker, then a +// pre-filled form. type projectsDialog struct { projects []board.Project idx int - adding bool + mode projectsMode + picker *dirPicker inputs []textinput.Model // name, path, agent focus int } @@ -192,7 +204,7 @@ func newProjectsDialog(projects []board.Project) *projectsDialog { func (d *projectsDialog) setProjects(projects []board.Project) { d.projects = projects d.idx = min(max(d.idx, 0), max(len(projects)-1, 0)) - d.adding = false + d.mode = projectsList } var projectFields = []struct{ label, placeholder string }{ @@ -201,8 +213,9 @@ var projectFields = []struct{ label, placeholder string }{ {"Agent", "default (or any agent ref)"}, } -func (d *projectsDialog) startAdding() tea.Cmd { - d.adding = true +// 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 { @@ -212,6 +225,8 @@ func (d *projectsDialog) startAdding() tea.Cmd { ti.SetWidth(56) d.inputs[i] = ti } + d.inputs[0].SetValue(name) + d.inputs[1].SetValue(path) d.inputs[0].Focus() return textinput.Blink } @@ -223,7 +238,10 @@ func (d *projectsDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { if !ok { return d, nil } - if d.adding { + switch d.mode { + case projectsPicking: + return d.updatePicking(key) + case projectsAdding: return d.updateAdding(key) } @@ -235,8 +253,9 @@ func (d *projectsDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { case "down", "j": d.idx = min(d.idx+1, max(len(d.projects)-1, 0)) case "a", "n": - cmd := d.startAdding() - return d, cmd + 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 @@ -246,11 +265,29 @@ func (d *projectsDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { return d, nil } +// updatePicking drives the directory picker; a picked directory pre-fills +// the add form. +func (d *projectsDialog) updatePicking(key tea.KeyPressMsg) (dialog, tea.Cmd) { + chosen, done, cmd := d.picker.Update(key) + switch { + case chosen != "": + return d, d.startAdding(filepath.Base(chosen), chosen) + case done: + d.mode = projectsList + } + return d, cmd +} + func (d *projectsDialog) updateAdding(key tea.KeyPressMsg) (dialog, tea.Cmd) { switch key.String() { case "esc": - d.adding = false + 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 @@ -278,7 +315,14 @@ func (d *projectsDialog) setFocus(focus int) tea.Cmd { func (d *projectsDialog) View(width, _ int) string { w := dialogWidth(70, width) - if d.adding { + 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) } @@ -320,7 +364,7 @@ func (d *projectsDialog) viewAdding(w int) string { return renderDialog("Add project", w, lipgloss.JoinVertical(lipgloss.Left, rows...), "", - helpLine(w, "enter save", "tab next field", "esc back"), + helpLine(w, "enter save", "tab next field", "ctrl+o browse", "esc back"), ) } 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 +} From 6ec6671ef4a49e21ff39f986371eaf7fa307fb6d Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 13:25:44 +0200 Subject: [PATCH 21/53] style(board): reuse the main TUI's dialog chrome Board dialogs now build their titles and help-key lines through the shared dialog content builder (pkg/tui/dialog), so they render exactly like every other docker-agent dialog: centered title, highlighted keys with muted descriptions, centered help row. --- pkg/board/tui/dialogs.go | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 430dac57c..3102318b7 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -12,6 +12,7 @@ import ( "github.com/docker/docker-agent/pkg/board" "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" ) @@ -27,16 +28,20 @@ func dialogWidth(preferred, termWidth int) int { return max(min(preferred, termWidth-4), 24) } -// renderDialog renders a titled dialog box. +// 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 := make([]string, 0, len(sections)+2) - content = append(content, styles.DialogTitleStyle.Width(width).Render(title), "") - content = append(content, sections...) - return styles.DialogStyle.Render(lipgloss.JoinVertical(lipgloss.Left, content...)) + content := tuidialog.NewContent(width).AddTitle(title).AddSpace() + for _, s := range sections { + content.AddContent(s) + } + return styles.DialogStyle.Render(content.Build()) } -func helpLine(width int, hints ...string) string { - return styles.MutedStyle.Width(width).Render(toolcommon.TruncateText(strings.Join(hints, " "), width)) +// 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 --- @@ -103,7 +108,7 @@ func (d *cardDialog) View(width, height int) string { d.prompt.View(), "", } - hints := []string{"enter create", "⌘+enter newline", "esc cancel"} + 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 { @@ -117,7 +122,7 @@ func (d *cardDialog) View(width, height int) string { } 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"} + hints = []string{"enter", "create", "⌘+enter", "newline", "tab", "project", "esc", "cancel"} } sections = append(sections, helpLine(w, hints...)) @@ -167,7 +172,7 @@ func (d *promptDialog) View(width, _ int) string { return renderDialog(d.column.Emoji+" "+d.column.Name+" · column prompt", w, d.prompt.View(), "", - helpLine(w, "ctrl+s save", "esc cancel"), + helpLine(w, "ctrl+s", "save", "esc", "cancel"), ) } @@ -271,7 +276,8 @@ func (d *projectsDialog) updatePicking(key tea.KeyPressMsg) (dialog, tea.Cmd) { chosen, done, cmd := d.picker.Update(key) switch { case chosen != "": - return d, d.startAdding(filepath.Base(chosen), chosen) + cmd := d.startAdding(filepath.Base(chosen), chosen) + return d, cmd case done: d.mode = projectsList } @@ -320,7 +326,7 @@ func (d *projectsDialog) View(width, _ int) string { return renderDialog("Add project · select repository", w, d.picker.View(w), "", - helpLine(w, "↑↓ select", "enter open/pick", "backspace up", "esc back"), + helpLine(w, "↑↓", "select", "enter", "open/pick", "backspace", "up", "esc", "back"), ) case projectsAdding: return d.viewAdding(w) @@ -348,7 +354,7 @@ func (d *projectsDialog) View(width, _ int) string { return renderDialog("Projects", w, lipgloss.JoinVertical(lipgloss.Left, rows...), "", - helpLine(w, "a add", "x remove", "↑↓ select", "esc close"), + helpLine(w, "a", "add", "x", "remove", "↑↓", "select", "esc", "close"), ) } @@ -364,7 +370,7 @@ func (d *projectsDialog) viewAdding(w int) string { return renderDialog("Add project", w, lipgloss.JoinVertical(lipgloss.Left, rows...), "", - helpLine(w, "enter save", "tab next field", "ctrl+o browse", "esc back"), + helpLine(w, "enter", "save", "tab", "next field", "ctrl+o", "browse", "esc", "back"), ) } @@ -412,7 +418,7 @@ func (d *diffDialog) View(width, height int) string { return renderDialog("Diff · "+d.title, w, styles.MutedStyle.Italic(true).Render("No changes yet."), "", - helpLine(w, "esc close"), + helpLine(w, "esc", "close"), ) } d.view.SetWidth(w) @@ -420,7 +426,7 @@ func (d *diffDialog) View(width, height int) string { return renderDialog("Diff · "+d.title, w, d.view.View(), "", - helpLine(w, "↑↓ scroll", "esc close"), + helpLine(w, "↑↓", "scroll", "esc", "close"), ) } @@ -478,7 +484,7 @@ func (d *confirmDialog) View(width, _ int) string { 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, "y delete", "esc cancel"), + helpLine(w, "y", "delete", "esc", "cancel"), ) } From b6acaae6e0a9a54ce3d77e54121072894f8e6328 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 13:31:04 +0200 Subject: [PATCH 22/53] fix(board): actually composite dialogs over the board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lipgloss Layer.Draw ignores a layer's X/Y/Z — positioning lives in the Compositor — so each Compose painted the layer's content at the origin over the whole canvas: dialogs erased the board behind them. Route the layers through a Compositor and pin the regression with a test. --- pkg/board/tui/tui_test.go | 12 ++++++++++++ pkg/board/tui/view.go | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/board/tui/tui_test.go b/pkg/board/tui/tui_test.go index 9a213f4a2..b6443d8a0 100644 --- a/pkg/board/tui/tui_test.go +++ b/pkg/board/tui/tui_test.go @@ -65,6 +65,18 @@ func TestCardAtMirrorsLayout(t *testing.T) { assert.False(t, ok) } +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() diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 116f803d3..b6da50728 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -111,8 +111,10 @@ 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.NewLayer(base)) - canvas.Compose(dialog) + canvas.Compose(lipgloss.NewCompositor( + lipgloss.NewLayer(base).Z(0), + dialog, + )) return canvas.Render() } From c38ed12312e6b1f2f1094ec1d544b961fc211086 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 14:12:34 +0200 Subject: [PATCH 23/53] feat(board): swap enter and cmd+enter in the new-card dialog Enter now inserts a newline (multiline prompts are the norm) and cmd+enter submits the card, with ctrl+s as a fallback for terminals without the Kitty keyboard protocol. --- pkg/board/tui/dialogs.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 3102318b7..2cae42d0c 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -59,9 +59,10 @@ func newCardDialog(projects []board.Project) *cardDialog { ta.Placeholder = "Describe the task for the agent…" ta.ShowLineNumbers = false ta.SetHeight(6) - // Enter submits the card; newlines go through cmd+enter (super in CSI-u - // terms; ctrl+j works everywhere as a fallback). - ta.KeyMap.InsertNewline.SetKeys("super+enter", "ctrl+j") + // 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() return &cardDialog{projects: projects, prompt: ta} } @@ -83,7 +84,7 @@ func (d *cardDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { d.projIdx = (d.projIdx + len(d.projects) - 1) % len(d.projects) return d, nil } - case "enter": + case "super+enter", "ctrl+s": prompt := strings.TrimSpace(d.prompt.Value()) if prompt == "" { return d, nil @@ -108,7 +109,7 @@ func (d *cardDialog) View(width, height int) string { d.prompt.View(), "", } - hints := []string{"enter", "create", "⌘+enter", "newline", "esc", "cancel"} + 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 { @@ -122,7 +123,7 @@ func (d *cardDialog) View(width, height int) string { } 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"} + hints = []string{"⌘+enter", "create", "enter", "newline", "tab", "project", "esc", "cancel"} } sections = append(sections, helpLine(w, hints...)) From 61cd1ba0330a422fddc92b287533f267659c8681 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 14:47:11 +0200 Subject: [PATCH 24/53] perf(board): snapshot projects in the TUI model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View called App.Projects() — a config lock plus slice rebuild — on every render frame (120ms while any card is busy). The model now keeps a snapshot, refreshed on reload and after project add/delete, which also keeps project colors and the header count fresh without waiting for the next card change. --- pkg/board/tui/tui.go | 16 +++++++++++----- pkg/board/tui/view.go | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index a0a4e8436..25284f8f1 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -136,6 +136,9 @@ type model struct { 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 @@ -179,8 +182,9 @@ func (m *model) openDialog(d dialog) tea.Cmd { 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.app.Projects() { + for i, p := range m.projects { m.projectColors[p.Name] = projectColorAt(i) } m.clampSelection() @@ -332,8 +336,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.setFlash(err.Error(), true) return m, cmd } + m.reload() if d, ok := m.dialog.(*projectsDialog); ok { - d.setProjects(m.app.Projects()) + d.setProjects(m.projects) } cmd := m.setFlash("Project added to the global config", false) return m, cmd @@ -343,8 +348,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.setFlash(err.Error(), true) return m, cmd } + m.reload() if d, ok := m.dialog.(*projectsDialog); ok { - d.setProjects(m.app.Projects()) + d.setProjects(m.projects) } return m, nil @@ -398,7 +404,7 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.moveSelection(0, 1) case key.Matches(msg, keys.New): - projects := m.app.Projects() + 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 @@ -432,7 +438,7 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, keys.Projects): - cmd := m.openDialog(newProjectsDialog(m.app.Projects())) + cmd := m.openDialog(newProjectsDialog(m.projects)) return m, cmd case key.Matches(msg, keys.Prompt): diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index b6da50728..43c7f4d5c 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -122,7 +122,7 @@ func (m *model) renderHeader() string { title := styles.HighlightWhiteStyle.Render(" 🐳 Board") info := styles.MutedStyle.Render(fmt.Sprintf("%s · %s ", - plural(len(m.app.Projects()), "project"), plural(m.totalCards(), "card"))) + plural(len(m.projects), "project"), plural(m.totalCards(), "card"))) pad := max(m.width-lipgloss.Width(title)-lipgloss.Width(info), 1) return title + strings.Repeat(" ", pad) + info From e3f4ae1035434ec4e20e9c37ddbea3bb6adafe80 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 14:47:28 +0200 Subject: [PATCH 25/53] feat(board): save the column prompt with cmd+enter Same submit binding as the new-card dialog (ctrl+s still works). --- pkg/board/tui/dialogs.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 2cae42d0c..897cc3952 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -157,7 +157,7 @@ func (d *promptDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { switch msg.String() { case "esc": return d, closeDialog - case "ctrl+s": + 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} } } @@ -173,7 +173,7 @@ func (d *promptDialog) View(width, _ int) string { return renderDialog(d.column.Emoji+" "+d.column.Name+" · column prompt", w, d.prompt.View(), "", - helpLine(w, "ctrl+s", "save", "esc", "cancel"), + helpLine(w, "⌘+enter", "save", "esc", "cancel"), ) } From e77f4522d04a756f16e0b68b459762d19866096f Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 14:48:02 +0200 Subject: [PATCH 26/53] feat(board): scroll columns with the mouse wheel Wheel events move the selection through the column under the cursor; the scroll window already follows the selection. The diff viewport keeps its native wheel scrolling. --- pkg/board/tui/tui.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index 25284f8f1..be65458d5 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -385,6 +385,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleKey(msg) case tea.MouseClickMsg: return m.handleClick(msg) + case tea.MouseWheelMsg: + m.handleWheel(msg) + return m, nil } return m, nil } @@ -495,6 +498,30 @@ func (m *model) handleClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) { 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). +func (m *model) handleWheel(msg tea.MouseWheelMsg) { + if len(m.columns) == 0 { + return + } + _, colWidth := m.boardSize() + col := msg.X / (colWidth + columnGap) + if col >= len(m.columns) || msg.X%(colWidth+columnGap) >= colWidth { + 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 { From 997c9eb479e8637d509a446237f22f3118015798 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 14:48:29 +0200 Subject: [PATCH 27/53] feat(board): show a busy indicator in column headers Columns with starting or running agents get an animated spinner and count next to the card count, so activity is visible even when the busy cards are scrolled out of view. --- pkg/board/tui/view.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 43c7f4d5c..338464bee 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -194,7 +194,7 @@ func (m *model) renderColumn(idx int, col board.Column, colWidth, boardHeight in selected := idx == m.selCol innerWidth := colWidth - 2 - // Header: emoji, name, count. + // Header: emoji, name, count, and a spinner while any card is busy. nameStyle := styles.SecondaryStyle if selected { nameStyle = styles.HighlightWhiteStyle @@ -202,6 +202,9 @@ func (m *model) renderColumn(idx int, col board.Column, colWidth, boardHeight in cards := m.cards[col.ID] count := styles.MutedStyle.Render(" " + strconv.Itoa(len(cards))) header := " " + col.Emoji + " " + nameStyle.Render(col.Name) + 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. @@ -236,6 +239,17 @@ func (m *model) renderColumn(idx int, col board.Column, colWidth, boardHeight in Render(strings.Join(lines, "\n")) } +// 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) From 392d6f222e941eed0aa471c52e87bde4751a3de1 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 14:48:57 +0200 Subject: [PATCH 28/53] feat(board): start the new-card dialog on the last used project --- pkg/board/tui/dialogs.go | 14 ++++++++++++-- pkg/board/tui/tui.go | 7 ++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 897cc3952..c40046967 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -53,7 +53,9 @@ type cardDialog struct { prompt textarea.Model } -func newCardDialog(projects []board.Project) *cardDialog { +// 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…" @@ -64,7 +66,15 @@ func newCardDialog(projects []board.Project) *cardDialog { // Kitty keyboard protocol. ta.KeyMap.InsertNewline.SetKeys("enter") ta.Focus() - return &cardDialog{projects: projects, prompt: ta} + + 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 } diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index be65458d5..72141f094 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -156,6 +156,10 @@ type model struct { dialog dialog + // 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 @@ -328,6 +332,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case submitNewCardMsg: m.dialog = nil + m.lastProject = msg.project.Name cmd := m.createCard(msg.project, msg.prompt) return m, cmd @@ -412,7 +417,7 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { 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)) + cmd := m.openDialog(newCardDialog(projects, m.lastProject)) return m, cmd case key.Matches(msg, keys.Attach): From 2fafd8acebc3c7b1edde8a2e830bfebb32b1ea15 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 14:49:49 +0200 Subject: [PATCH 29/53] feat(board): open the card's worktree in an editor with o Runs $BOARD_EDITOR (default: code) on the selected card's worktree, matching the web board's editor button. --- pkg/board/app.go | 20 ++++++++++++++++++++ pkg/board/tui/tui.go | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/pkg/board/app.go b/pkg/board/app.go index 5da9307ff..e4255414d 100644 --- a/pkg/board/app.go +++ b/pkg/board/app.go @@ -1,9 +1,11 @@ package board import ( + "cmp" "context" "errors" "fmt" + "os" "os/exec" "path/filepath" "slices" @@ -332,6 +334,24 @@ func (a *App) Diff(cardID string) (string, error) { 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") diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index 72141f094..415310a4e 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -105,6 +105,7 @@ type keyMap struct { Delete key.Binding Projects key.Binding Prompt key.Binding + Editor key.Binding Help key.Binding Quit key.Binding } @@ -122,6 +123,7 @@ var keys = keyMap{ 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("?"), key.WithHelp("?", "help")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), } @@ -455,6 +457,16 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { 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 From 2851278d85edc93b14d295fd30733e51ab044106 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 14:50:22 +0200 Subject: [PATCH 30/53] docs(board): document the editor key and mouse support --- docs/features/board/index.md | 2 ++ pkg/board/tui/dialogs.go | 2 ++ pkg/board/tui/view.go | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/features/board/index.md b/docs/features/board/index.md index b4f402a72..52e6f2949 100644 --- a/docs/features/board/index.md +++ b/docs/features/board/index.md @@ -42,11 +42,13 @@ Requirements: `tmux` and `git` must be installed. | `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) | diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index c40046967..af4f7f9cb 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -520,11 +520,13 @@ func (d *helpDialog) View(width, _ int) 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"}, + {"mouse", "click selects · double-click attaches · wheel scrolls"}, {keys.Quit.Help().Key, "quit (agents keep running in tmux)"}, } rows := make([]string, 0, len(bindings)) diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 338464bee..634d9172b 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -326,7 +326,7 @@ func (m *model) renderFooter() string { } hints := []string{ - "n new", "⏎ attach", "d diff", "[ ] move", "x delete", + "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))) From f2590bd14a7cfe2adca81b5fdecb0ed67f67b8e9 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:17:56 +0200 Subject: [PATCH 31/53] fix(board): sanitize project metadata rendered in dialogs Project names, paths, and agent refs come from the config file (or are typed into dialogs); strip terminal controls before rendering them in chips, titles, project rows, and the footer, like every other untrusted string. --- pkg/board/tui/dialogs.go | 10 +++++----- pkg/board/tui/view.go | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index af4f7f9cb..314cf3917 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -129,7 +129,7 @@ func (d *cardDialog) View(width, height int) string { if i == d.projIdx { style = styles.BaseStyle.Foreground(projectColorAt(i)).Bold(true).Underline(true) } - chips = append(chips, style.Render(p.Name)) + 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...) @@ -137,7 +137,7 @@ func (d *cardDialog) View(width, height int) string { } sections = append(sections, helpLine(w, hints...)) - return renderDialog("New card · "+d.projects[d.projIdx].Name, w, sections...) + return renderDialog("New card · "+sanitize(d.projects[d.projIdx].Name), w, sections...) } // --- column prompt editor --- @@ -356,9 +356,9 @@ func (d *projectsDialog) View(width, _ int) string { if agent == "" { agent = board.DefaultAgent } - line := marker + nameStyle.Render(p.Name) + - styles.MutedStyle.Render(" "+p.Path+" · ") + - styles.BaseStyle.Foreground(styles.BadgeCyan).Render(agent) + line := marker + nameStyle.Render(sanitize(p.Name)) + + styles.MutedStyle.Render(" "+sanitize(p.Path)+" · ") + + styles.BaseStyle.Foreground(styles.BadgeCyan).Render(sanitize(agent)) rows = append(rows, toolcommon.TruncateText(line, w)) } diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 634d9172b..b13dad7da 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -337,7 +337,7 @@ func (m *model) renderFooter() string { return left } details := styles.MutedStyle.Render(toolcommon.TruncateText( - card.Agent+" · "+card.Branch+" ", max(m.width-lipgloss.Width(left)-2, 0))) + 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 } From 08c8bdbde3104c80d91e924c370bedd9e7d2a2c0 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:18:28 +0200 Subject: [PATCH 32/53] fix(board): ignore wheel events outside the columns area Scrolling over the header or footer changed the selection. Column hit-testing now bounds Y to the board area and is shared between cardAt and handleWheel so the math cannot drift apart. --- pkg/board/tui/tui.go | 10 +++------- pkg/board/tui/view.go | 25 ++++++++++++++++++------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index 415310a4e..3646e60ef 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -517,14 +517,10 @@ func (m *model) handleClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) { // 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). +// the selection). Wheel events outside the columns area are ignored. func (m *model) handleWheel(msg tea.MouseWheelMsg) { - if len(m.columns) == 0 { - return - } - _, colWidth := m.boardSize() - col := msg.X / (colWidth + columnGap) - if col >= len(m.columns) || msg.X%(colWidth+columnGap) >= colWidth { + col, ok := m.columnAt(msg.X, msg.Y) + if !ok { return } if col != m.selCol { diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index b13dad7da..bc900464e 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -342,17 +342,28 @@ func (m *model) renderFooter() string { return left + strings.Repeat(" ", pad) + details } -// cardAt maps terminal coordinates to the (column, card) under them. It +// columnAt maps an x/y terminal coordinate to the column under it. It // mirrors the layout produced by renderBoard. -func (m *model) cardAt(x, y int) (col, row int, ok bool) { +func (m *model) columnAt(x, y int) (int, bool) { if len(m.columns) == 0 { - return 0, 0, false + return 0, false + } + boardHeight, colWidth := m.boardSize() + if y < boardTop || y >= boardTop+boardHeight { + return 0, false } - _, colWidth := m.boardSize() + col := x / (colWidth + columnGap) + if col >= len(m.columns) || x%(colWidth+columnGap) >= colWidth { + return 0, false + } + return col, true +} - col = x / (colWidth + columnGap) - relX := x % (colWidth + columnGap) - if col >= len(m.columns) || relX >= colWidth { +// 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 } From 30535d3ab148f942bc05a284edf8429294c170aa Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:19:30 +0200 Subject: [PATCH 33/53] fix(board): make diffs work in repositories without remotes upstreamBase blindly assumed /main when nothing resolved, so git merge-base failed and the diff view errored in local-only repos. Fall back to the local default branch, then HEAD (+ tests). --- pkg/board/git.go | 11 +++++-- pkg/board/git_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 pkg/board/git_test.go diff --git a/pkg/board/git.go b/pkg/board/git.go index 3cf799868..b05ab2929 100644 --- a/pkg/board/git.go +++ b/pkg/board/git.go @@ -112,7 +112,9 @@ func copyIndex(ctx context.Context, worktree string) (string, func(), error) { // 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 before assuming "main". +// 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 { @@ -126,7 +128,12 @@ func upstreamBase(ctx context.Context, dir string) string { return ref } } - return remote + "/main" + 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 diff --git a/pkg/board/git_test.go b/pkg/board/git_test.go new file mode 100644 index 000000000..5cc854fb3 --- /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.Command("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) +} From 0ae00bd7d3c56ad108367f0d063f5fc1d15c52f3 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:19:54 +0200 Subject: [PATCH 34/53] feat(board): color project rows with their board accent The projects dialog now shows each project in the same color its cards carry on the board. --- pkg/board/tui/dialogs.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 314cf3917..bcf594ee4 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -348,9 +348,9 @@ func (d *projectsDialog) View(width, _ int) string { 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 + marker, nameStyle := " ", styles.BaseStyle.Foreground(projectColorAt(i)) if i == d.idx { - marker, nameStyle = styles.SuccessStyle.Render("❯ "), styles.HighlightWhiteStyle + marker, nameStyle = styles.SuccessStyle.Render("❯ "), nameStyle.Bold(true) } agent := p.Agent if agent == "" { @@ -358,7 +358,7 @@ func (d *projectsDialog) View(width, _ int) string { } line := marker + nameStyle.Render(sanitize(p.Name)) + styles.MutedStyle.Render(" "+sanitize(p.Path)+" · ") + - styles.BaseStyle.Foreground(styles.BadgeCyan).Render(sanitize(agent)) + styles.SecondaryStyle.Render(sanitize(agent)) rows = append(rows, toolcommon.TruncateText(line, w)) } From 02269c532175c40b7958ea88e5a3e0f1a25000a8 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:20:36 +0200 Subject: [PATCH 35/53] feat(board): refresh the diff view with r The diff is a snapshot and the agent may still be working; r reloads it in place, preserving the scroll position. --- pkg/board/tui/dialogs.go | 22 ++++++++++++++-------- pkg/board/tui/tui.go | 27 ++++++++++++++++++++------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index bcf594ee4..75089affb 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -387,23 +387,26 @@ func (d *projectsDialog) viewAdding(w int) string { // --- diff viewer --- -// diffDialog shows a card's worktree diff in a scrollable viewport. +// 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 { - title string - view viewport.Model - empty bool + cardID string + title string + view viewport.Model + empty bool } -func newDiffDialog(title, diff string) *diffDialog { +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) - d := &diffDialog{title: title, view: vp, empty: strings.TrimSpace(diff) == ""} + d := &diffDialog{cardID: cardID, title: title, view: vp, empty: strings.TrimSpace(diff) == ""} if !d.empty { d.view.SetContent(colorizeDiff(diff)) + d.view.SetYOffset(offset) } return d } @@ -415,6 +418,9 @@ func (d *diffDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { switch key.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 @@ -429,7 +435,7 @@ func (d *diffDialog) View(width, height int) string { return renderDialog("Diff · "+d.title, w, styles.MutedStyle.Italic(true).Render("No changes yet."), "", - helpLine(w, "esc", "close"), + helpLine(w, "r", "refresh", "esc", "close"), ) } d.view.SetWidth(w) @@ -437,7 +443,7 @@ func (d *diffDialog) View(width, height int) string { return renderDialog("Diff · "+d.title, w, d.view.View(), "", - helpLine(w, "↑↓", "scroll", "esc", "close"), + helpLine(w, "↑↓", "scroll", "r", "refresh", "esc", "close"), ) } diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index 3646e60ef..8c861e3d6 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -55,8 +55,17 @@ type ( attachDoneMsg struct{ err error } // diffLoadedMsg carries a card's worktree diff. diffLoadedMsg struct { - title string - diff string + 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{} @@ -325,7 +334,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case diffLoadedMsg: - cmd := m.openDialog(newDiffDialog(msg.title, msg.diff)) + 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: @@ -430,7 +443,7 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Diff): if card := m.selectedCard(); card != nil { - cmd := m.loadDiff(card) + cmd := m.loadDiff(card.ID, card.Title, 0) return m, cmd } @@ -595,13 +608,13 @@ func (m *model) attach(cardID string) tea.Cmd { } } -func (m *model) loadDiff(card *board.Card) tea.Cmd { +func (m *model) loadDiff(cardID, title string, offset int) tea.Cmd { return func() tea.Msg { - diff, err := m.app.Diff(card.ID) + diff, err := m.app.Diff(cardID) if err != nil { return flashMsg{text: err.Error(), isErr: true} } - return diffLoadedMsg{title: card.Title, diff: diff} + return diffLoadedMsg{cardID: cardID, title: title, diff: diff, offset: offset} } } From 0560f10ff5298caa301d214d5aeafd0a4819c123 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:21:14 +0200 Subject: [PATCH 36/53] test(board): cover the directory picker Listing (hidden dirs and files excluded, git detection), descend and walk-up navigation, filtering, picking, and cancellation. --- pkg/board/git_test.go | 2 +- pkg/board/tui/dirpicker_test.go | 123 ++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 pkg/board/tui/dirpicker_test.go diff --git a/pkg/board/git_test.go b/pkg/board/git_test.go index 5cc854fb3..343b6ae48 100644 --- a/pkg/board/git_test.go +++ b/pkg/board/git_test.go @@ -21,7 +21,7 @@ func newLocalRepo(t *testing.T) string { 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.Command("git", args...) + cmd := exec.CommandContext(t.Context(), "git", args...) cmd.Dir = dir out, err := cmd.CombinedOutput() require.NoError(t, err, "git %v: %s", args, out) 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"))) +} From 28f4f704a9d98e765e750f8b9d6edcdc4763d760 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:29:31 +0200 Subject: [PATCH 37/53] feat(board): honor the main TUI's shortcut conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quit binding now merges the user's remapped global quit (from the config file, resolved through the main TUI's keymap) instead of hard-coding ctrl+c — inside dialogs too. Help additionally answers to the main TUI's f1/ctrl+h. --- pkg/board/tui/tui.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index 8c861e3d6..62aab66cc 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -12,6 +12,7 @@ import ( 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" ) @@ -133,10 +134,22 @@ var keys = keyMap{ 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("?"), key.WithHelp("?", "help")), + 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 @@ -177,6 +190,7 @@ type model struct { } func newModel(app *board.App, refresh chan struct{}) *model { + resolveKeys() m := &model{ app: app, refresh: refresh, @@ -391,8 +405,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.dialog != nil { - // ctrl+c always quits, even while a dialog captures the keyboard. - if press, ok := msg.(tea.KeyPressMsg); ok && press.String() == "ctrl+c" { + // 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 From 6e7a43bd7567cdf1ea6cd8ef66ed99baf4530984 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:30:09 +0200 Subject: [PATCH 38/53] feat(board): scrollbar and scroll percent in the diff dialog Matches the main TUI's viewer dialogs: a scrollbar column next to the viewport (reserved even when the content fits, so refreshes don't shift the layout) and the scroll percentage in the help row. --- pkg/board/tui/dialogs.go | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 75089affb..594464b97 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -2,6 +2,7 @@ package tui import ( "path/filepath" + "strconv" "strings" "charm.land/bubbles/v2/textarea" @@ -11,6 +12,7 @@ import ( "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" @@ -393,6 +395,7 @@ type diffDialog struct { cardID string title string view viewport.Model + bar *scrollbar.Model empty bool } @@ -403,7 +406,7 @@ func newDiffDialog(cardID, title, diff string, offset int) *diffDialog { // are untrusted; strip terminal controls before rendering. title = strings.Join(strings.Fields(sanitize(title)), " ") diff = sanitize(diff) - d := &diffDialog{cardID: cardID, title: title, view: vp, empty: strings.TrimSpace(diff) == ""} + 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) @@ -438,15 +441,30 @@ func (d *diffDialog) View(width, height int) string { helpLine(w, "r", "refresh", "esc", "close"), ) } - d.view.SetWidth(w) + d.view.SetWidth(w - scrollbar.Width) d.view.SetHeight(h) + + // 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, - d.view.View(), + lipgloss.JoinHorizontal(lipgloss.Top, d.view.View(), bar), "", - helpLine(w, "↑↓", "scroll", "r", "refresh", "esc", "close"), + 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) From 88863ddf904ad554c80faf7743058beb0429b1e1 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:30:39 +0200 Subject: [PATCH 39/53] style(board): use the shared Yes/No confirmation keymap The delete confirmation now runs on dialog.DefaultConfirmKeyMap (Y/N, case-insensitive) like every other docker-agent confirmation dialog; enter still confirms and esc still cancels. --- pkg/board/tui/dialogs.go | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 594464b97..e3c517d38 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/viewport" @@ -492,23 +493,26 @@ func colorizeDiff(diff string) string { type confirmDialog struct { card *board.Card + keys tuidialog.ConfirmKeyMap } func newConfirmDialog(card *board.Card) *confirmDialog { - return &confirmDialog{card: card} + 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) { - if key, ok := msg.(tea.KeyPressMsg); ok { - switch key.String() { - case "y", "enter": - cardID := d.card.ID - return d, func() tea.Msg { return confirmDeleteMsg{cardID: cardID} } - case "n", "esc", "q": - return d, closeDialog - } + 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 } @@ -519,7 +523,7 @@ func (d *confirmDialog) View(width, _ int) string { 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, "y", "delete", "esc", "cancel"), + helpLine(w, d.keys.Yes.Help().Key, "delete", d.keys.No.Help().Key+"/esc", "cancel"), ) } From 25d9421f0aaae2288ccc66c4da3b65bf5b76900f Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:31:27 +0200 Subject: [PATCH 40/53] feat(board): support suspend like the main TUI ctrl+z (or the user's remapped suspend binding) suspends the board to the shell; fg resumes it. --- pkg/board/tui/tui.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index 62aab66cc..e28f2a291 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -433,6 +433,9 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { 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): From 6111c4671ce3b199f87e70387e123775f1cc90f7 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:32:30 +0200 Subject: [PATCH 41/53] style(board): render help rows like the main TUI's help dialog Keys in bold secondary, descriptions in the shared dialog help style, instead of ad-hoc badge colors. --- pkg/board/tui/dialogs.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index e3c517d38..61bf529b3 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -557,9 +557,12 @@ func (d *helpDialog) View(width, _ int) string { {"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, styles.BaseStyle.Foreground(styles.BadgeCyan).Width(12).Render(b.key)+styles.SecondaryStyle.Render(b.desc)) + 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"), From 7d2f49e2b84be3c68bbd20486fbcd8f6f4156014 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:32:51 +0200 Subject: [PATCH 42/53] feat(board): reflect running agents in the window title --- pkg/board/tui/dialogs.go | 22 +++++++++++----------- pkg/board/tui/view.go | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 61bf529b3..88ac5bf37 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -253,18 +253,18 @@ func (d *projectsDialog) startAdding(name, path string) tea.Cmd { func (d *projectsDialog) Init() tea.Cmd { return nil } func (d *projectsDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { - key, ok := msg.(tea.KeyPressMsg) + press, ok := msg.(tea.KeyPressMsg) if !ok { return d, nil } switch d.mode { case projectsPicking: - return d.updatePicking(key) + return d.updatePicking(press) case projectsAdding: - return d.updateAdding(key) + return d.updateAdding(press) } - switch key.String() { + switch press.String() { case "esc", "q": return d, closeDialog case "up", "k": @@ -286,8 +286,8 @@ func (d *projectsDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { // updatePicking drives the directory picker; a picked directory pre-fills // the add form. -func (d *projectsDialog) updatePicking(key tea.KeyPressMsg) (dialog, tea.Cmd) { - chosen, done, cmd := d.picker.Update(key) +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) @@ -298,8 +298,8 @@ func (d *projectsDialog) updatePicking(key tea.KeyPressMsg) (dialog, tea.Cmd) { return d, cmd } -func (d *projectsDialog) updateAdding(key tea.KeyPressMsg) (dialog, tea.Cmd) { - switch key.String() { +func (d *projectsDialog) updateAdding(press tea.KeyPressMsg) (dialog, tea.Cmd) { + switch press.String() { case "esc": d.mode = projectsList return d, nil @@ -323,7 +323,7 @@ func (d *projectsDialog) updateAdding(key tea.KeyPressMsg) (dialog, tea.Cmd) { return d, func() tea.Msg { return submitProjectMsg{project: project} } } var cmd tea.Cmd - d.inputs[d.focus], cmd = d.inputs[d.focus].Update(key) + d.inputs[d.focus], cmd = d.inputs[d.focus].Update(press) return d, cmd } @@ -418,8 +418,8 @@ func newDiffDialog(cardID, title, diff string, offset int) *diffDialog { func (d *diffDialog) Init() tea.Cmd { return nil } func (d *diffDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { - if key, ok := msg.(tea.KeyPressMsg); ok { - switch key.String() { + if press, ok := msg.(tea.KeyPressMsg); ok { + switch press.String() { case "esc", "q", "d": return d, closeDialog case "r": diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index bc900464e..06f61f310 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -101,10 +101,23 @@ func (m *model) View() tea.View { view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion view.BackgroundColor = styles.Background - view.WindowTitle = "docker agent board" + 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 { From acc35a79aa1a85199b85898ae58c03ac404590a2 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:42:07 +0200 Subject: [PATCH 43/53] fix(board): don't queue attaches while one is in flight Pressing enter repeatedly stacked tea.ExecProcess commands, replaying an attach after every detach. A guard now holds from the readiness probe until the session detaches or the probe fails. --- pkg/board/tui/tui.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index e28f2a291..cc6f05f49 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -52,6 +52,9 @@ type ( // 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. @@ -180,6 +183,11 @@ type model struct { 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 @@ -340,7 +348,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.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 @@ -616,12 +630,17 @@ func (m *model) deleteCard(cardID string) tea.Cmd { } // attach probes the card's agent readiness off the UI loop, then hands the -// tmux attach command back to the update loop to exec. +// 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 flashMsg{text: err.Error(), isErr: true} + return attachFailedMsg{err: err} } return attachReadyMsg{cmd: cmd} } From e17d4582205afebc3798b8b3a924fc06ad979763 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:42:30 +0200 Subject: [PATCH 44/53] fix(board): cap the diff viewer at 1MB Colorizing and holding an unbounded diff in the viewport could freeze the UI; larger diffs are cut at a line boundary with a notice. --- pkg/board/tui/dialogs.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 88ac5bf37..b9292e99e 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -400,6 +400,11 @@ type diffDialog struct { 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 @@ -407,6 +412,13 @@ func newDiffDialog(cardID, title, diff string, offset int) *diffDialog { // 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)) From c128adf9a761a21099f3273b46fd47631f7b1d8e Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:43:05 +0200 Subject: [PATCH 45/53] feat(board): jump to the first/last card with g/G (home/end) --- pkg/board/tui/dialogs.go | 2 +- pkg/board/tui/tui.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index b9292e99e..6f8bcc326 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -565,7 +565,7 @@ func (d *helpDialog) View(width, _ int) string { {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"}, + {"←↓↑→ 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)"}, } diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index cc6f05f49..dc3d63d34 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -110,6 +110,8 @@ type keyMap struct { Right key.Binding Up key.Binding Down key.Binding + First key.Binding + Last key.Binding New key.Binding Attach key.Binding Diff key.Binding @@ -128,6 +130,8 @@ var keys = keyMap{ 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")), @@ -458,6 +462,10 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { 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 From 8c4ba3ce8d37ad813e33962a6fcfb2ab9b884840 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:54:08 +0200 Subject: [PATCH 46/53] fix(board): re-clamp the diff viewport offset after sizing A restored scroll offset (diff refresh) was applied while the viewport height was still zero, so it clamped against the wrong bound; a shrunken diff or a taller terminal could then render blank content past the real bottom. Re-clamp once the real dimensions are known. --- pkg/board/tui/dialogs.go | 4 ++++ pkg/board/tui/tui_test.go | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 6f8bcc326..14f7addb4 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -456,6 +456,10 @@ func (d *diffDialog) View(width, height int) string { } 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. diff --git a/pkg/board/tui/tui_test.go b/pkg/board/tui/tui_test.go index b6443d8a0..2ec1271c8 100644 --- a/pkg/board/tui/tui_test.go +++ b/pkg/board/tui/tui_test.go @@ -65,6 +65,18 @@ func TestCardAtMirrorsLayout(t *testing.T) { 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() From d3c3ffdc9c4022dc3d29d2320ef171eeb21ef0d9 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:58:13 +0200 Subject: [PATCH 47/53] fix(board): remove the control-plane socket when a card is deleted The per-card unix socket file lingered in ~/.cagent/run forever. --- pkg/board/app.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/board/app.go b/pkg/board/app.go index e4255414d..fb94c5c1f 100644 --- a/pkg/board/app.go +++ b/pkg/board/app.go @@ -321,6 +321,8 @@ func (a *App) DeleteCard(cardID string) error { 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 } From af54cbe29d72c8af37e85852f44ce2c6b31669f6 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:59:01 +0200 Subject: [PATCH 48/53] feat(board): allow only one board instance at a time Two boards over the same state file would each run per-card watchers and race one another relaunching agent sessions. NewApp now takes an exclusive flock on the state file; a second instance fails fast with a clear message. The OS drops the lock on exit, so a crash never leaves a stale lock. --- pkg/board/app.go | 6 ++++++ pkg/board/lock_unix.go | 37 +++++++++++++++++++++++++++++++++++++ pkg/board/lock_unix_test.go | 29 +++++++++++++++++++++++++++++ pkg/board/lock_windows.go | 7 +++++++ 4 files changed, 79 insertions(+) create mode 100644 pkg/board/lock_unix.go create mode 100644 pkg/board/lock_unix_test.go create mode 100644 pkg/board/lock_windows.go diff --git a/pkg/board/app.go b/pkg/board/app.go index fb94c5c1f..b24c8498e 100644 --- a/pkg/board/app.go +++ b/pkg/board/app.go @@ -55,6 +55,12 @@ func NewApp(ctx context.Context, onChanged func()) (*App, error) { 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) 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 } From 83c7f89dbfe5d7ac1f46177cceb8161a8669f164 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:59:19 +0200 Subject: [PATCH 49/53] fix(board): fit the prompt editor to short terminals The textarea was a fixed 10 rows; it now adapts between 4 and 16 rows like the new-card dialog. --- pkg/board/tui/dialogs.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index 14f7addb4..d0c07b0d4 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -180,9 +180,10 @@ func (d *promptDialog) Update(msg tea.Msg) (dialog, tea.Cmd) { return d, cmd } -func (d *promptDialog) View(width, _ int) string { +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(d.column.Emoji+" "+d.column.Name+" · column prompt", w, d.prompt.View(), "", From be6bc00f50c0a7ea400d27b543da883d73e7a6b1 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 15:59:41 +0200 Subject: [PATCH 50/53] fix(board): render emoji-less columns without a stray gap User-defined columns may omit the emoji; the header and prompt-editor title no longer show a leading double space (and column names/emojis are sanitized like other config strings). --- pkg/board/tui/dialogs.go | 2 +- pkg/board/tui/view.go | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pkg/board/tui/dialogs.go b/pkg/board/tui/dialogs.go index d0c07b0d4..2df0cd4d6 100644 --- a/pkg/board/tui/dialogs.go +++ b/pkg/board/tui/dialogs.go @@ -184,7 +184,7 @@ 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(d.column.Emoji+" "+d.column.Name+" · column prompt", w, + return renderDialog(strings.TrimSpace(d.column.Emoji+" "+d.column.Name)+" · column prompt", w, d.prompt.View(), "", helpLine(w, "⌘+enter", "save", "esc", "cancel"), diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 06f61f310..2a28ecac9 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -214,7 +214,7 @@ func (m *model) renderColumn(idx int, col board.Column, colWidth, boardHeight in } cards := m.cards[col.ID] count := styles.MutedStyle.Render(" " + strconv.Itoa(len(cards))) - header := " " + col.Emoji + " " + nameStyle.Render(col.Name) + count + header := " " + columnLabel(col, nameStyle) + count if busy := busyCount(cards); busy > 0 { header += " " + styles.InfoStyle.Render(spinnerFrames[m.frame%len(spinnerFrames)]+strconv.Itoa(busy)) } @@ -252,6 +252,15 @@ func (m *model) renderColumn(idx int, col board.Column, colWidth, boardHeight in 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 From 1df9fad9f8a13fdeb4f16e8b50872a0b4828237e Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 16:21:39 +0200 Subject: [PATCH 51/53] fix(board): start agents with respawn-pane instead of send-keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing 'exec docker-agent …' into the user's interactive shell was fragile: it depends on the shell supporting exec, pollutes shell history, and a slow shell startup could swallow or garble the input. respawn-pane -k replaces the pane process directly (tmux runs the command via /bin/sh), keeping the same remain-on-exit dead-pane semantics. Verified live: create card, agent starts, relaunch works. --- pkg/board/tmux.go | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/pkg/board/tmux.go b/pkg/board/tmux.go index 163cc99dc..baa27735a 100644 --- a/pkg/board/tmux.go +++ b/pkg/board/tmux.go @@ -176,12 +176,15 @@ func shQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } -// NewSession creates a tmux session and runs docker agent in it. The agent -// is exec'd into the pane (replacing the shell) so that, when it exits, the -// pane becomes a dead pane instead of dropping back to a shell. Combined -// with remain-on-exit, the tmux session outlives a dead agent: the user can -// still read its final output and the controller can detect the dead pane -// and relaunch. +// 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 @@ -190,14 +193,14 @@ func (t tmuxSessions) NewSession(name, workDir, agent, sessionID, listenSocket, applyServerDefaults(t.ctx) // Keep the pane (and thus the session) alive after the agent exits. Set - // while the shell is still running so there is no race where the agent - // could exit before the option takes effect. + // 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 replaces the shell with the agent so the agent becomes the - // pane's process: when it exits the pane goes dead (see remain-on-exit). + // 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, "send-keys", "-t", name, cmd, "Enter"); err != nil { + if _, err := tmuxRun(t.ctx, "respawn-pane", "-k", "-t", name, "-c", workDir, cmd); err != nil { return err } return nil From 8f3cf449a824898b10b2cf791eb8f902dab3db41 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 16:23:38 +0200 Subject: [PATCH 52/53] feat(board): slide columns into view on narrow terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns that did not fit were clipped off screen and unreachable by mouse. The board now windows the pipeline around the selected column, with ◀/▶ hidden-column counts in the header; hit-testing follows the window (+ tests). Verified live on a 55-column terminal. --- pkg/board/tui/tui.go | 3 +++ pkg/board/tui/tui_test.go | 39 ++++++++++++++++++++++++++++++ pkg/board/tui/view.go | 50 +++++++++++++++++++++++++++++---------- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/pkg/board/tui/tui.go b/pkg/board/tui/tui.go index dc3d63d34..aab8892b3 100644 --- a/pkg/board/tui/tui.go +++ b/pkg/board/tui/tui.go @@ -177,6 +177,9 @@ type model struct { 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 diff --git a/pkg/board/tui/tui_test.go b/pkg/board/tui/tui_test.go index 2ec1271c8..0feb5e81c 100644 --- a/pkg/board/tui/tui_test.go +++ b/pkg/board/tui/tui_test.go @@ -25,6 +25,45 @@ func TestGroupCardsFallsBackToFirstColumn(t *testing.T) { 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() diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 2a28ecac9..94af1d572 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -134,11 +134,20 @@ func placeOverlay(base, overlay string, width, height int) string { func (m *model) renderHeader() string { title := styles.HighlightWhiteStyle.Render(" 🐳 Board") - info := styles.MutedStyle.Render(fmt.Sprintf("%s · %s ", - plural(len(m.projects), "project"), plural(m.totalCards(), "card"))) + 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(info), 1) - return title + strings.Repeat(" ", pad) + info + pad := max(m.width-lipgloss.Width(title)-lipgloss.Width(styled), 1) + return title + strings.Repeat(" ", pad) + styled } func (m *model) totalCards() int { @@ -173,11 +182,26 @@ func plural(n int, word string) string { 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) - n := max(len(m.columns), 1) - colWidth = max((m.width-(n-1)*columnGap)/n, minColumnWidth) + _, count := m.columnWindow() + count = max(count, 1) + colWidth = max((m.width-(count-1)*columnGap)/count, minColumnWidth) return boardHeight, colWidth } @@ -191,14 +215,15 @@ func (m *model) renderBoard() string { if len(m.columns) == 0 { return "" } + offset, count := m.columnWindow() boardHeight, colWidth := m.boardSize() - cols := make([]string, 0, len(m.columns)*2) - for i, col := range m.columns { - if i > 0 { + 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, col, colWidth, boardHeight)) + cols = append(cols, m.renderColumn(i, m.columns[i], colWidth, boardHeight)) } return lipgloss.JoinHorizontal(lipgloss.Top, cols...) } @@ -370,12 +395,13 @@ 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 := x / (colWidth + columnGap) - if col >= len(m.columns) || x%(colWidth+columnGap) >= colWidth { + col := offset + x/(colWidth+columnGap) + if col >= offset+count || x%(colWidth+columnGap) >= colWidth { return 0, false } return col, true From 69386955461ad2115ed5323e236fc046aac7c83d Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Jul 2026 17:25:32 +0200 Subject: [PATCH 53/53] docs(board): add canonical front matter Assisted-By: Claude --- docs/features/board/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/features/board/index.md b/docs/features/board/index.md index 52e6f2949..dbda07e67 100644 --- a/docs/features/board/index.md +++ b/docs/features/board/index.md @@ -4,6 +4,7 @@ description: "Orchestrate multiple agents from a Kanban TUI: each card runs an a 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