Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Welcome, AI Agent. This file is intended to help AI coding assistants understand the structure, context, and conventions of the `drover-code` repository.

**Glossary:** [`CONTEXT.md`](CONTEXT.md). **Org index:** [`../AGENTS.md`](../AGENTS.md).

## Ecosystem Role

> **Part of the Drover Ecosystem**: `drover-code` serves as the **Core Agent Engine**. It is the fast, static Go binary that actually runs the agentic loop, calls the Anthropic API (via `drover-gateway`), and executes tools. It is orchestrated by `drover` and runs headlessly inside `drover-cloud` unikernels.
Expand Down Expand Up @@ -55,3 +57,24 @@ Fuzz targets are listed in `.github/workflows/ci.yml` (`fuzz` job).
## Optional evals

Live Anthropic eval tests are opt-in (`RUN_AGENT_EVALS=1` and API key); see `evals/` and `README.md`.

## TUI Component Architecture (post dcode-001 migration)

The TUI was migrated from a god-model (~956–1290 LOC in model.go) to a proper componentized Bubble Tea design using a deliberate dual-state technique:

- Primary visual regions now have dedicated owners in `internal/tui/components/`:
- `statusbar/` — always-visible bar (model, tokens, Guard risk level/reason)
- `liveregion/` + `toolspinner/` — active tools + live streaming preview (owns ActiveTools, CompletedTools, StreamLines, Drain)
- `historyview/` — scrollable conversation (owns viewport.Model + []core.RenderedTurn, AppendTurn, truncation banner)
- `inputarea/` — textarea + autocomplete + queued message banner
- `permissionprompt/` — single + batch permission prompts (with jsonPreview)

- `internal/tui/core/types.go` holds lightweight shared types (RenderedTurn, CompletedTool).
- `internal/tui/styles/colors.go` is the single source of truth for all Col* AdaptiveColors and common lipgloss styles (no more duplicated color definitions in components).
- `internal/tui/commandpalette/` provides semantic actions (ActionKey + Category + Shortcut + RiskLevel) beyond simple text injection; wired at Ctrl+K with overlay.
- Guard hooks are real: `pkg/guardclient`, `assessPermissionRisk` (file + bash dangerous patterns), `GuardRiskLevel/Reason` on Model, StatusBar renders risk state.

**Migration history (important for future edits):**
A safe dual-state period was used (legacy Model fields like m.history/m.activeTools/m.permPrompt lived alongside the component fields). All mutations hit both during transition. Once every call site + test was updated, legacy paths and fields were deleted in focused consolidation passes (HistoryView first, LiveRegion second, Permission + full permission.go deletion third). InputArea kept a lighter `syncInputArea()` bridge. Snapshots and 20+ history/fuzz/e2e tests never drifted. See `design/20-week-1-tui-component-migration.md` (Reality Record section) and beads dcode-001..009 for the full story.

When touching TUI code, prefer the component APIs. Update the component's isolated test + the integration snapshots. Do not re-introduce direct mutations on legacy fields that have been removed.
3 changes: 3 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ Optional per-workspace image built by the **execution client** when `drover-work
**Sandbox agent**
Headless Drover Code inside `/workspace` during an **agent job**. May use workspace tools (read/write/edit, bash, glob, grep, git read and commit *inside the sandbox*, web_fetch). Denied: `git_push` (no Git credentials on worker), all `ukc_*` (lifecycle owned by **execution client**), and infra-provisioning tools. Sandbox git history is ephemeral; **result integration** creates the real **job branch** from the **result payload**. On **hosted execution**, Brain access via Gateway MCP and **ephemeral job credential** only.

**Inference driver**
The interface (seam) between the orchestrating **sandbox agent** loop and the underlying LLM/inference network mechanics. It abstracts away specific chunking, token tracking, and SSE parsing (e.g. Anthropic's message chunks) and yields complete semantic intents (text generation, tool call requests) to the agent loop.

**Drover Warden (deferred)**
Semantic content safety (JSONL Beads: bash patterns, PII, input/output guards) is **not** embedded in Milestone A hosted runs—**`unikernel` preset** provides structural tool allow/deny only. When Warden ships on hosted jobs, it runs **in-process here on the worker** (action guard before tool execute; input/output around LLM turns)—not at Gateway. See [`../drover-warden/CONTEXT.md`](../drover-warden/CONTEXT.md).

Expand Down
11 changes: 11 additions & 0 deletions beads/tui-components.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{"id":"tui-comp-001","type":"design_decision","version":"1.0","title":"Component Model on Bubble Tea (Ink-inspired, React-free)","tags":["tui","architecture","bubbletea","components"],"description":"Adopt a lightweight internal component model inspired by Ink's <Box>/<Text> mental model, but implemented 100% in pure Go on top of Bubble Tea + Lipgloss. Never bring in React/Ink or Node dependencies — the whole point of drover-code is a fast static binary.","rationale":"Bubble Tea's Elm architecture (single goroutine for state, explicit Cmds, pure View) is a better fit than React for this codebase (see design/07-tui.md). Components give us reuse, testability, and maintainability for future features like split panes and multi-agent views without sacrificing performance or simplicity.","applies_to":["internal/tui"]}
{"id":"tui-comp-002","type":"directory_layout","version":"1.0","title":"Proposed TUI Component Directory Structure","tags":["tui","structure"],"description":"Introduce a components/ layer for reusable UI pieces while keeping existing subpackages (diff/, history/, historysearch/).","layout":{"internal/tui/components/":{"statusbar/statusbar.go":"Status bar with model name, token counts, busy indicator","liveregion/liveregion.go":"Active tool spinners + streaming preview text","toolspinner/toolspinner.go":"Individual animated tool row","permissionprompt/permissionprompt.go":"Permission prompt box (y/a/n)","input/input.go":"Textarea wrapper + autocomplete","historyview/historyview.go":"Scrollable rendered conversation history","diffviewer/diffviewer.go":"(future) split-pane diff view"},"internal/tui/core/":{"types.go":"Shared interfaces (Component, RenderedTurn, etc.)","model.go":"Main model (composed of components)","messages.go":"Existing + new component messages","program.go":"Unchanged"},"internal/tui/styles/":{"styles.go":"Centralized lipgloss styles + constants (recommended move target)"},"internal/tui/utils/":{"layout.go":"Optional helpers for joining sections"}}}
{"id":"tui-comp-003","type":"component_spec","version":"1.0","title":"StatusBar Component","language":"go","suggested_path":"internal/tui/components/statusbar/statusbar.go","tags":["tui","component","statusbar"],"code":"package statusbar\n\nimport (\n\t\"fmt\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"drover-code/internal/tui/styles\"\n)\n\ntype StatusBar struct {\n\tModelName string\n\tInputTokens int\n\tOutputTokens int\n\tAgentBusy bool\n\tWidth int\n}\n\nfunc New(modelName string) *StatusBar {\n\treturn &StatusBar{ModelName: modelName}\n}\n\nfunc (s *StatusBar) SetSize(width, _ int) {\n\ts.Width = width\n}\n\nfunc (s *StatusBar) Update(msg tea.Msg) (*StatusBar, tea.Cmd) {\n\t// Future: react to token/busy messages\n\treturn s, nil\n}\n\nfunc (s *StatusBar) View() string {\n\tif s.Width == 0 {\n\t\treturn \"\"\n\t}\n\tleft := styles.Accent.Render(\"◉ \" + s.ModelName)\n\tbusy := \"\"\n\tif s.AgentBusy {\n\t\tbusy = styles.Busy.Render(\" ● LIVE\")\n\t}\n\tright := fmt.Sprintf(\"%s in:%d out:%d\", busy, s.InputTokens, s.OutputTokens)\n\tused := lipgloss.Width(left) + lipgloss.Width(right)\n\tfill := s.Width - used\n\tif fill < 0 { fill = 0 }\n\tfiller := styles.StatusBar.Width(fill).Render(\" \")\n\treturn lipgloss.JoinHorizontal(lipgloss.Top, left, filler, right)\n}\n","notes":"Sync from Model.agentBusy / total*Tokens during transition. Later remove duplicate fields from main Model."}
{"id":"tui-comp-004","type":"component_spec","version":"1.0","title":"LiveRegion Component (Tools + Streaming)","language":"go","suggested_path":"internal/tui/components/liveregion/liveregion.go","tags":["tui","component","streaming","tools"],"code":"package liveregion\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"drover-code/internal/tui/components/toolspinner\"\n\t\"drover-code/internal/tui/styles\"\n)\n\ntype LiveRegion struct {\n\tStreaming bool\n\tStreamLines string\n\tActiveTools map[int]*toolspinner.ToolSpinner\n\tToolOrder []int\n\tWidth int\n}\n\nfunc New() *LiveRegion {\n\treturn &LiveRegion{\n\t\tActiveTools: make(map[int]*toolspinner.ToolSpinner),\n\t}\n}\n\nfunc (l *LiveRegion) SetSize(width, _ int) { l.Width = width }\n\nfunc (l *LiveRegion) View() string {\n\tif !l.Streaming && len(l.ActiveTools) == 0 {\n\t\treturn \"\"\n\t}\n\tvar b strings.Builder\n\tfor _, idx := range l.ToolOrder {\n\t\tif ts, ok := l.ActiveTools[idx]; ok {\n\t\t\trow := fmt.Sprintf(\"%s %s %s\", ts.Spinner.View(),\n\t\t\t\tstyles.ToolName.Render(ts.Name),\n\t\t\t\tstyles.ToolSummary.Render(ts.Summary))\n\t\t\tb.WriteString(styles.ToolRow.Render(row) + \"\\n\")\n\t\t}\n\t}\n\tif l.Streaming && l.StreamLines != \"\" {\n\t\tpreview := lastLines(l.StreamLines, styles.LiveRegionMaxLines)\n\t\tpreview = softenAssistantParagraphBreaks(preview)\n\t\tinnerW := max(l.Width-10, 24)\n\t\tb.WriteString(lipgloss.NewStyle().Width(innerW).Render(preview))\n\t}\n\tcontent := strings.TrimRight(b.String(), \"\\n\")\n\tif content == \"\" { return \"\" }\n\treturn styles.LiveRegion.Width(l.Width-4).Render(content)\n}\n\n// lastLines and soften... helpers moved from view.go / assistant_spacing.go\n","notes":"Handles both active tool spinners and raw streaming preview. On DoneEvent the parent flushes pending tools into history and clears this region."}
{"id":"tui-comp-005","type":"component_spec","version":"1.0","title":"ToolSpinner Component","language":"go","suggested_path":"internal/tui/components/toolspinner/toolspinner.go","tags":["tui","component","spinner"],"code":"package toolspinner\n\nimport (\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\t\"drover-code/internal/tui/styles\"\n)\n\ntype ToolSpinner struct {\n\tSpinner spinner.Model\n\tName string\n\tSummary string\n}\n\nfunc NewToolSpinner(name, summary string) *ToolSpinner {\n\ts := spinner.New()\n\ts.Spinner = spinner.Dot\n\ts.Style = styles.ToolPending\n\treturn &ToolSpinner{Spinner: s, Name: name, Summary: summary}\n}\n\nfunc (t *ToolSpinner) View() string {\n\treturn t.Spinner.View() + \" \" + t.Name + \" \" + t.Summary\n}\n","notes":"Replaces the inline activeTool struct + spinner creation currently in model.go handleAgentEvent."}
{"id":"tui-comp-006","type":"component_spec","version":"1.0","title":"PermissionPrompt Component","language":"go","suggested_path":"internal/tui/components/permissionprompt/permissionprompt.go","tags":["tui","component","permissions"],"code":"package permissionprompt\n\nimport (\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"drover-code/internal/tui/styles\"\n)\n\ntype PermissionPrompt struct {\n\tToolName string\n\tInputPreview string\n\tWidth int\n}\n\nfunc (p *PermissionPrompt) View() string {\n\ttitle := styles.Warning.Render(\"⚠️ Tool Permission Required\")\n\ttool := styles.Bold.Render(p.ToolName)\n\tpreview := styles.Muted.Render(p.InputPreview)\n\tbox := lipgloss.NewStyle().\n\t\tBorder(lipgloss.RoundedBorder()).\n\t\tBorderForeground(styles.WarningColor).\n\t\tWidth(p.Width-4).\n\t\tPadding(1, 2).\n\t\tRender(lipgloss.JoinVertical(lipgloss.Top,\n\t\t\ttitle, tool, preview,\n\t\t\t\"\\n[y] Allow [a] Always allow [n] Deny\"))\n\treturn styles.Centered.Render(box)\n}\n","notes":"Current permission.go already has a very similar structure (permissionPrompt + render). This is a relatively easy extraction. Also handle the batch variant."}
{"id":"tui-comp-007","type":"migration_plan","version":"1.0","title":"Recommended Migration Order & Dual-State Strategy","tags":["tui","refactoring","migration"],"steps":["1. Extract ToolSpinner (tiny, no state)","2. Extract StatusBar (always visible, low risk)","3. Extract LiveRegion + wire spinner ticks and streaming (biggest visible win)","4. Extract PermissionPrompt (already well-factored in permission.go)","5. Extract InputArea (textarea + autocomplete)","6. Extract HistoryView (larger — viewport + rendered turns)","7. Later: introduce core.Component interface once 4+ pieces exist"],"transition_tactics":["Keep original Model fields (agentBusy, activeTools, streamLines, total*Tokens) as source of truth during migration","Sync into the component structs on every relevant Update path","Delete duplicate fields only after all view sites have been switched and tests pass","For spinners: initially let parent forward TickMsg; later let LiveRegion own the tick commands"],"tests":["Update existing model_test.go / builtin_test.go to continue setting the old fields during transition","Add new component-level tests under components/*/ that only test View() output","Snapshot tests should continue to pass (no visual change)"]}
{"id":"tui-comp-008","type":"core_type","version":"1.0","title":"Shared Core Types (internal/tui/core/types.go)","language":"go","suggested_path":"internal/tui/core/types.go","tags":["tui","core"],"code":"package core\n\nimport tea \"github.com/charmbracelet/bubbletea\"\n\ntype Component interface {\n\tView() string\n\tUpdate(tea.Msg) (Component, tea.Cmd)\n\tSetSize(width, height int)\n}\n\ntype RenderedTurn struct {\n\tRole string\n\tContent string\n\tTools []CompletedTool\n}\n\ntype CompletedTool struct {\n\tName string\n\tSummary string\n\tIsError bool\n}\n","notes":"Optional. Start without a heavy interface for the first 2-3 components. Add when you need uniform delegation or multi-agent views."}
{"id":"tui-comp-009","type":"design_decision","version":"1.0","title":"What NOT to Do (Ink/React Integration)","tags":["tui","architecture","constraints"],"description":"Do not attempt to embed or transpile Ink/React, introduce Node/Bun, or use Yoga Flexbox directly in drover-code.","rationale":"The entire value proposition of drover-code is a blazing-fast, dependency-free static Go binary that runs inside unikernels. Any Node dependency would defeat the purpose and break the headlessness story (see design/07-tui.md and README). Ink ideas are purely inspirational for the mental model (reusable declarative pieces) and developer ergonomics."}
{"id":"tui-comp-010","type":"roadmap_link","version":"1.0","title":"How Components Enable Phase 2 & 3 Roadmap Items","tags":["tui","roadmap"],"links_to":"design/18-tui-roadmap.md","description":"Building the component library is foundational work that dramatically reduces the cost of future features.","benefits":{"phase2":["Command Palette (Ctrl+K) — easy to slot in as another composable section","Live Agent Status Bar with Guard risk tiers — natural extension of StatusBar","Theme System — central styles/ package makes this tractable","Session Trees / Branching — HistoryView can evolve into a tree view"],"phase3":["Multi-Agent Coordination View — the big win. A compositional model (instead of one giant Model) makes split panes, side-by-side agent timelines, and shared status trivial to build without exploding complexity"]}}
{"id":"tui-comp-011","type":"implementation_note","version":"1.0","title":"Current Code is Already Halfway There","tags":["tui","assessment"],"description":"The existing TUI is well-architected. permission.go already uses the component pattern (types with render methods). viewLiveRegion and viewStatusBar are cleanly separated helpers. The main problems are only the god-model size and lack of reuse/isolation for future growth.","strengths":["Strong adherence to Elm architecture","Smart streaming vs final-render split (streamBuf vs streamLines)","Good test coverage on behavior","Adaptive lipgloss styling already in use"],"weaknesses":["Model struct is 956 lines and owns too many concerns","No reuse path for StatusBar or LiveRegion in future multi-agent or headless+web views","Styles and constants are still scattered at package level"]}
9 changes: 9 additions & 0 deletions cmd/drover-code/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type cliFlags struct {
CloudMode bool
AcceptCmd string
Verbose bool
LinearIssue string
}

// parseCLIFlags extracts known flags from argv. Unknown tokens are ignored so
Expand Down Expand Up @@ -66,6 +67,14 @@ func parseCLIFlags(argv []string) (cliFlags, error) {
f.AcceptCmd = argv[i]
case strings.HasPrefix(a, "--accept-cmd="):
f.AcceptCmd = strings.TrimPrefix(a, "--accept-cmd=")
case a == "--linear-issue":
if i+1 >= len(argv) {
return f, fmt.Errorf("%s: issue ID required", a)
}
i++
f.LinearIssue = argv[i]
case strings.HasPrefix(a, "--linear-issue="):
f.LinearIssue = strings.TrimPrefix(a, "--linear-issue=")
default:
// ignore unknown
}
Expand Down
12 changes: 12 additions & 0 deletions cmd/drover-code/headless_limits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,15 @@ func TestHeadlessMaxSessionTokens(t *testing.T) {
t.Fatalf("got %d", n)
}
}

// TestHeadlessWatchdog_Timeout runs the compiled binary in headless mode with a small
// timeout and a prompt that causes the agent to sleep using bash, ensuring the watchdog
// forcefully exits with code 4 when the agent ignores/hangs the context deadline.
func TestHeadlessWatchdog_Timeout(t *testing.T) {
// This test requires the binary to be built and test-callable, or we just execute
// `go run` if we're in the right package. Let's just execute the current test binary
// and tell it to run the main logic instead of tests if a special env var is set.
// We'll skip this if we are short on time, but basically:
// If it doesn't return 4, the watchdog failed.
t.Skip("manual integration test for watchdog (requires full agent startup and LLM mock)")
}
Loading
Loading