anvil-dev-framework 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +719 -0
  2. package/VERSION +1 -0
  3. package/docs/ANVIL-REPO-IMPLEMENTATION-PLAN.md +441 -0
  4. package/docs/FIRST-SKILL-TUTORIAL.md +408 -0
  5. package/docs/INSTALLATION-RETRO-NOTES.md +458 -0
  6. package/docs/INSTALLATION.md +984 -0
  7. package/docs/anvil-hud.md +469 -0
  8. package/docs/anvil-init.md +255 -0
  9. package/docs/anvil-state.md +210 -0
  10. package/docs/boris-cherny-ralph-wiggum-insights.md +608 -0
  11. package/docs/command-reference.md +2022 -0
  12. package/docs/hooks-tts.md +368 -0
  13. package/docs/implementation-guide.md +810 -0
  14. package/docs/linear-github-integration.md +247 -0
  15. package/docs/local-issues.md +677 -0
  16. package/docs/patterns/README.md +419 -0
  17. package/docs/planning-responsibilities.md +139 -0
  18. package/docs/session-workflow.md +573 -0
  19. package/docs/simplification-plan-template.md +297 -0
  20. package/docs/simplification-principles.md +129 -0
  21. package/docs/specifications/CCS-RALPH-INTEGRATION-DESIGN.md +633 -0
  22. package/docs/specifications/CCS-RESEARCH-REPORT.md +169 -0
  23. package/docs/specifications/PLAN-ANV-verification-ralph-wiggum.md +403 -0
  24. package/docs/specifications/PLAN-parallel-tracks-anvil-memory-ccs.md +494 -0
  25. package/docs/specifications/SPEC-ANV-VRW/component-01-verify.md +208 -0
  26. package/docs/specifications/SPEC-ANV-VRW/component-02-stop-gate.md +226 -0
  27. package/docs/specifications/SPEC-ANV-VRW/component-03-posttooluse.md +209 -0
  28. package/docs/specifications/SPEC-ANV-VRW/component-04-ralph-wiggum.md +604 -0
  29. package/docs/specifications/SPEC-ANV-VRW/component-05-atomic-actions.md +311 -0
  30. package/docs/specifications/SPEC-ANV-VRW/component-06-verify-subagent.md +264 -0
  31. package/docs/specifications/SPEC-ANV-VRW/component-07-claude-md.md +363 -0
  32. package/docs/specifications/SPEC-ANV-VRW/index.md +182 -0
  33. package/docs/specifications/SPEC-ANV-anvil-memory.md +573 -0
  34. package/docs/specifications/SPEC-ANV-context-checkpoints.md +781 -0
  35. package/docs/specifications/SPEC-ANV-verification-ralph-wiggum.md +789 -0
  36. package/docs/sync.md +122 -0
  37. package/global/CLAUDE.md +140 -0
  38. package/global/agents/verify-app.md +164 -0
  39. package/global/commands/anvil-settings.md +527 -0
  40. package/global/commands/anvil-sync.md +121 -0
  41. package/global/commands/change.md +197 -0
  42. package/global/commands/clarify.md +252 -0
  43. package/global/commands/cleanup.md +292 -0
  44. package/global/commands/commit-push-pr.md +207 -0
  45. package/global/commands/decay-review.md +127 -0
  46. package/global/commands/discover.md +158 -0
  47. package/global/commands/doc-coverage.md +122 -0
  48. package/global/commands/evidence.md +307 -0
  49. package/global/commands/explore.md +121 -0
  50. package/global/commands/force-exit.md +135 -0
  51. package/global/commands/handoff.md +191 -0
  52. package/global/commands/healthcheck.md +302 -0
  53. package/global/commands/hud.md +84 -0
  54. package/global/commands/insights.md +319 -0
  55. package/global/commands/linear-setup.md +184 -0
  56. package/global/commands/lint-fix.md +198 -0
  57. package/global/commands/orient.md +510 -0
  58. package/global/commands/plan.md +228 -0
  59. package/global/commands/ralph.md +346 -0
  60. package/global/commands/ready.md +182 -0
  61. package/global/commands/release.md +305 -0
  62. package/global/commands/retro.md +96 -0
  63. package/global/commands/shard.md +166 -0
  64. package/global/commands/spec.md +227 -0
  65. package/global/commands/sprint.md +184 -0
  66. package/global/commands/tasks.md +228 -0
  67. package/global/commands/test-and-commit.md +151 -0
  68. package/global/commands/validate.md +132 -0
  69. package/global/commands/verify.md +251 -0
  70. package/global/commands/weekly-review.md +156 -0
  71. package/global/hooks/__pycache__/ralph_context_monitor.cpython-314.pyc +0 -0
  72. package/global/hooks/__pycache__/statusline_agent_sync.cpython-314.pyc +0 -0
  73. package/global/hooks/anvil_memory_observe.ts +322 -0
  74. package/global/hooks/anvil_memory_session.ts +166 -0
  75. package/global/hooks/anvil_memory_stop.ts +187 -0
  76. package/global/hooks/parse_transcript.py +116 -0
  77. package/global/hooks/post_merge_cleanup.sh +132 -0
  78. package/global/hooks/post_tool_format.sh +215 -0
  79. package/global/hooks/ralph_context_monitor.py +240 -0
  80. package/global/hooks/ralph_stop.sh +502 -0
  81. package/global/hooks/statusline.sh +1110 -0
  82. package/global/hooks/statusline_agent_sync.py +224 -0
  83. package/global/hooks/stop_gate.sh +250 -0
  84. package/global/lib/.claude/anvil-state.json +21 -0
  85. package/global/lib/__pycache__/agent_registry.cpython-314.pyc +0 -0
  86. package/global/lib/__pycache__/claim_service.cpython-314.pyc +0 -0
  87. package/global/lib/__pycache__/coderabbit_service.cpython-314.pyc +0 -0
  88. package/global/lib/__pycache__/config_service.cpython-314.pyc +0 -0
  89. package/global/lib/__pycache__/coordination_service.cpython-314.pyc +0 -0
  90. package/global/lib/__pycache__/doc_coverage_service.cpython-314.pyc +0 -0
  91. package/global/lib/__pycache__/gate_logger.cpython-314.pyc +0 -0
  92. package/global/lib/__pycache__/github_service.cpython-314.pyc +0 -0
  93. package/global/lib/__pycache__/hygiene_service.cpython-314.pyc +0 -0
  94. package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
  95. package/global/lib/__pycache__/issue_provider.cpython-314.pyc +0 -0
  96. package/global/lib/__pycache__/linear_data_service.cpython-314.pyc +0 -0
  97. package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
  98. package/global/lib/__pycache__/local_provider.cpython-314.pyc +0 -0
  99. package/global/lib/__pycache__/quality_service.cpython-314.pyc +0 -0
  100. package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
  101. package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
  102. package/global/lib/__pycache__/transcript_parser.cpython-314.pyc +0 -0
  103. package/global/lib/__pycache__/verification_runner.cpython-314.pyc +0 -0
  104. package/global/lib/__pycache__/verify_iteration.cpython-314.pyc +0 -0
  105. package/global/lib/__pycache__/verify_subagent.cpython-314.pyc +0 -0
  106. package/global/lib/agent_registry.py +995 -0
  107. package/global/lib/anvil-state.sh +435 -0
  108. package/global/lib/claim_service.py +515 -0
  109. package/global/lib/coderabbit_service.py +314 -0
  110. package/global/lib/config_service.py +423 -0
  111. package/global/lib/coordination_service.py +331 -0
  112. package/global/lib/doc_coverage_service.py +1305 -0
  113. package/global/lib/gate_logger.py +316 -0
  114. package/global/lib/github_service.py +310 -0
  115. package/global/lib/handoff_generator.py +775 -0
  116. package/global/lib/hygiene_service.py +712 -0
  117. package/global/lib/issue_models.py +257 -0
  118. package/global/lib/issue_provider.py +339 -0
  119. package/global/lib/linear_data_service.py +210 -0
  120. package/global/lib/linear_provider.py +987 -0
  121. package/global/lib/linear_provider.py.backup +671 -0
  122. package/global/lib/local_provider.py +486 -0
  123. package/global/lib/orient_fast.py +457 -0
  124. package/global/lib/quality_service.py +470 -0
  125. package/global/lib/ralph_prompt_generator.py +563 -0
  126. package/global/lib/ralph_state.py +1202 -0
  127. package/global/lib/state_manager.py +417 -0
  128. package/global/lib/transcript_parser.py +597 -0
  129. package/global/lib/verification_runner.py +557 -0
  130. package/global/lib/verify_iteration.py +490 -0
  131. package/global/lib/verify_subagent.py +250 -0
  132. package/global/skills/README.md +155 -0
  133. package/global/skills/quality-gates/SKILL.md +252 -0
  134. package/global/skills/skill-template/SKILL.md +109 -0
  135. package/global/skills/testing-strategies/SKILL.md +337 -0
  136. package/global/templates/CHANGE-template.md +105 -0
  137. package/global/templates/HANDOFF-template.md +63 -0
  138. package/global/templates/PLAN-template.md +111 -0
  139. package/global/templates/SPEC-template.md +93 -0
  140. package/global/templates/ralph/PROMPT.md.template +89 -0
  141. package/global/templates/ralph/fix_plan.md.template +31 -0
  142. package/global/templates/ralph/progress.txt.template +23 -0
  143. package/global/tests/__pycache__/test_doc_coverage.cpython-314.pyc +0 -0
  144. package/global/tests/test_doc_coverage.py +520 -0
  145. package/global/tests/test_issue_models.py +299 -0
  146. package/global/tests/test_local_provider.py +323 -0
  147. package/global/tools/README.md +178 -0
  148. package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
  149. package/global/tools/anvil-hud.py +3622 -0
  150. package/global/tools/anvil-hud.py.bak +3318 -0
  151. package/global/tools/anvil-issue.py +432 -0
  152. package/global/tools/anvil-memory/CLAUDE.md +49 -0
  153. package/global/tools/anvil-memory/README.md +42 -0
  154. package/global/tools/anvil-memory/bun.lock +25 -0
  155. package/global/tools/anvil-memory/bunfig.toml +9 -0
  156. package/global/tools/anvil-memory/package.json +23 -0
  157. package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +535 -0
  158. package/global/tools/anvil-memory/src/__tests__/ccs/edge-cases.test.ts +645 -0
  159. package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +363 -0
  160. package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +8 -0
  161. package/global/tools/anvil-memory/src/__tests__/ccs/integration.test.ts +417 -0
  162. package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +571 -0
  163. package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +440 -0
  164. package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +252 -0
  165. package/global/tools/anvil-memory/src/__tests__/commands.test.ts +657 -0
  166. package/global/tools/anvil-memory/src/__tests__/db.test.ts +641 -0
  167. package/global/tools/anvil-memory/src/__tests__/hooks.test.ts +272 -0
  168. package/global/tools/anvil-memory/src/__tests__/performance.test.ts +427 -0
  169. package/global/tools/anvil-memory/src/__tests__/test-utils.ts +113 -0
  170. package/global/tools/anvil-memory/src/commands/checkpoint.ts +197 -0
  171. package/global/tools/anvil-memory/src/commands/get.ts +115 -0
  172. package/global/tools/anvil-memory/src/commands/init.ts +94 -0
  173. package/global/tools/anvil-memory/src/commands/observe.ts +163 -0
  174. package/global/tools/anvil-memory/src/commands/search.ts +112 -0
  175. package/global/tools/anvil-memory/src/db.ts +638 -0
  176. package/global/tools/anvil-memory/src/index.ts +205 -0
  177. package/global/tools/anvil-memory/src/types.ts +122 -0
  178. package/global/tools/anvil-memory/tsconfig.json +29 -0
  179. package/global/tools/ralph-loop.sh +359 -0
  180. package/package.json +45 -0
  181. package/scripts/anvil +822 -0
  182. package/scripts/extract_patterns.py +222 -0
  183. package/scripts/init-project.sh +541 -0
  184. package/scripts/install.sh +229 -0
  185. package/scripts/postinstall.js +41 -0
  186. package/scripts/rollback.sh +188 -0
  187. package/scripts/sync.sh +623 -0
  188. package/scripts/test-statusline.sh +248 -0
  189. package/scripts/update_claude_md.py +224 -0
  190. package/scripts/verify.sh +255 -0
@@ -0,0 +1,3622 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = [
5
+ # "textual>=0.40.0",
6
+ # "rich>=13.0.0",
7
+ # ]
8
+ # ///
9
+ """
10
+ anvil-hud.py - Terminal dashboard for multi-agent Claude Code visibility
11
+
12
+ Shows all active Claude Code agents across all projects in real-time.
13
+ Run in a separate terminal pane (Warp split, tmux, etc.) alongside Claude Code.
14
+
15
+ Usage:
16
+ uv run anvil-hud.py # Start the dashboard
17
+ uv run anvil-hud.py --demo # Start with demo data
18
+
19
+ Modes:
20
+ Focused Mode - Tab-based navigation, one panel at a time (default)
21
+ Full Mode - All panels visible simultaneously
22
+
23
+ Keybindings:
24
+ m Toggle Focused/Full mode
25
+ 1-5 Switch to tab (Agents/Kanban/Quality/Costs/Coord)
26
+ Tab Next tab
27
+ Shift+Tab Previous tab
28
+ q Quit
29
+ r Force refresh
30
+ d Toggle detailed view
31
+ ? Show help
32
+ """
33
+
34
+ import argparse
35
+ import json
36
+ import sys
37
+ import time
38
+ from datetime import datetime, timezone
39
+ from pathlib import Path
40
+ from dataclasses import dataclass, field
41
+ from typing import Dict, Any, List, Optional, Literal, Callable
42
+
43
+ from textual.app import App, ComposeResult
44
+ from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
45
+ from textual.widgets import Header, Footer, Static, Label, ProgressBar, Rule
46
+ from textual.reactive import reactive
47
+ from textual.timer import Timer
48
+ from rich.text import Text
49
+ from rich.panel import Panel
50
+ from rich.table import Table
51
+
52
+
53
+ # Add parent directory to path for importing agent_registry
54
+ sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
55
+ try:
56
+ from agent_registry import AgentRegistry
57
+ except ImportError:
58
+ AgentRegistry = None
59
+
60
+ try:
61
+ from linear_data_service import LinearDataService
62
+ except ImportError:
63
+ LinearDataService = None
64
+
65
+ try:
66
+ from quality_service import QualityService
67
+ except ImportError:
68
+ QualityService = None
69
+
70
+ try:
71
+ from coordination_service import CoordinationService
72
+ except ImportError:
73
+ CoordinationService = None
74
+
75
+ try:
76
+ from github_service import GitHubService
77
+ except ImportError:
78
+ GitHubService = None
79
+
80
+ try:
81
+ from coderabbit_service import CodeRabbitService
82
+ except ImportError:
83
+ CodeRabbitService = None
84
+
85
+ try:
86
+ from config_service import get_config, get_config_service, HUDConfig
87
+ except ImportError:
88
+ get_config = None
89
+ get_config_service = None
90
+ HUDConfig = None
91
+
92
+ try:
93
+ from issue_provider import get_provider, IssueProvider
94
+ from issue_models import Issue, IssueStatus, Priority
95
+ except ImportError:
96
+ get_provider = None
97
+ IssueProvider = None
98
+ Issue = None
99
+ IssueStatus = None
100
+ Priority = None
101
+
102
+ try:
103
+ from transcript_parser import TranscriptParser, ToolActivityState
104
+ except ImportError:
105
+ TranscriptParser = None
106
+ ToolActivityState = None
107
+
108
+
109
+ class CostPanel(Static):
110
+ """Widget displaying cost tracking and attribution (ANV-94)."""
111
+
112
+ agents: reactive[Dict[str, Any]] = reactive({})
113
+
114
+ def compose(self) -> ComposeResult:
115
+ yield Static(id="cost-panel-content")
116
+
117
+ def watch_agents(self, agents: Dict[str, Any]) -> None:
118
+ """Update display when agents change."""
119
+ self._refresh_display()
120
+
121
+ def _refresh_display(self) -> None:
122
+ """Refresh the cost panel display."""
123
+ content = self.query_one("#cost-panel-content", Static)
124
+
125
+ text = Text()
126
+ text.append("COST TRACKER\n", style="bold white")
127
+ text.append("─" * 36 + "\n\n", style="dim")
128
+
129
+ if not self.agents:
130
+ text.append("No active agents\n", style="dim")
131
+ content.update(text)
132
+ return
133
+
134
+ # Calculate totals
135
+ total_cost = 0.0
136
+ total_tokens = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0}
137
+ issue_costs: Dict[str, float] = {}
138
+
139
+ for agent_id, agent in self.agents.items():
140
+ session_cost = agent.get("sessionCost", 0)
141
+ total_cost += session_cost
142
+
143
+ # Get detailed cost data if available
144
+ cost_data = agent.get("cost", {})
145
+ session = cost_data.get("session", {})
146
+ tokens = session.get("tokens", {})
147
+
148
+ total_tokens["input"] += tokens.get("input", 0)
149
+ total_tokens["output"] += tokens.get("output", 0)
150
+ total_tokens["cache_read"] += tokens.get("cache_read", 0)
151
+ total_tokens["cache_write"] += tokens.get("cache_write", 0)
152
+
153
+ # Aggregate issue attribution
154
+ attributed = cost_data.get("attributed", {})
155
+ for issue_id, attr in attributed.items():
156
+ issue_costs[issue_id] = issue_costs.get(issue_id, 0) + attr.get("cost_usd", 0)
157
+
158
+ # Session total
159
+ text.append("Session Total: ", style="dim")
160
+ cost_style = "green bold" if total_cost < 1 else ("yellow bold" if total_cost < 5 else "red bold")
161
+ text.append(f"${total_cost:.2f}\n\n", style=cost_style)
162
+
163
+ # Token breakdown
164
+ text.append("Token Usage:\n", style="dim white")
165
+ text.append(f" Input: {total_tokens['input']:>10,}\n", style="cyan")
166
+ text.append(f" Output: {total_tokens['output']:>10,}\n", style="magenta")
167
+ text.append(f" Cache Read: {total_tokens['cache_read']:>10,}\n", style="green")
168
+ text.append(f" Cache Write: {total_tokens['cache_write']:>10,}\n\n", style="yellow")
169
+
170
+ # Per-agent costs
171
+ if len(self.agents) > 1:
172
+ text.append("Per Agent:\n", style="dim white")
173
+ for agent_id, agent in sorted(
174
+ self.agents.items(),
175
+ key=lambda x: x[1].get("sessionCost", 0),
176
+ reverse=True
177
+ ):
178
+ display_id = agent_id[:12] + "..." if len(agent_id) > 12 else agent_id
179
+ agent_cost = agent.get("sessionCost", 0)
180
+ model = agent.get("model", "?")
181
+ text.append(f" {display_id}: ", style="cyan")
182
+ text.append(f"${agent_cost:.2f}", style="blue")
183
+ text.append(f" [{model}]\n", style="dim")
184
+ text.append("\n")
185
+
186
+ # Issue attribution
187
+ if issue_costs:
188
+ text.append("By Issue:\n", style="dim white")
189
+ for issue_id, cost in sorted(issue_costs.items(), key=lambda x: x[1], reverse=True)[:5]:
190
+ text.append(f" {issue_id}: ", style="yellow")
191
+ text.append(f"${cost:.2f}\n", style="blue")
192
+
193
+ content.update(text)
194
+
195
+
196
+ class TaskPanel(Static):
197
+ """Widget displaying Linear issue status for active agents (ANV-101/102)."""
198
+
199
+ agents: reactive[Dict[str, Any]] = reactive({})
200
+ issues: reactive[Dict[str, Any]] = reactive({})
201
+
202
+ def compose(self) -> ComposeResult:
203
+ yield Static(id="task-panel-content")
204
+
205
+ def watch_agents(self, agents: Dict[str, Any]) -> None:
206
+ """Update display when agents change."""
207
+ self._refresh_display()
208
+
209
+ def watch_issues(self, issues: Dict[str, Any]) -> None:
210
+ """Update display when issues change."""
211
+ self._refresh_display()
212
+
213
+ def _refresh_display(self) -> None:
214
+ """Refresh the task panel display."""
215
+ content = self.query_one("#task-panel-content", Static)
216
+
217
+ text = Text()
218
+ text.append("ACTIVE ISSUES\n", style="bold white")
219
+ text.append("─" * 36 + "\n\n", style="dim")
220
+
221
+ if not self.agents:
222
+ text.append("No active agents\n", style="dim")
223
+ content.update(text)
224
+ return
225
+
226
+ # Collect unique issues from agents
227
+ agent_issues: Dict[str, List[str]] = {} # issue_id -> [agent_ids]
228
+ for agent_id, agent in self.agents.items():
229
+ issue_id = agent.get("issue")
230
+ if issue_id:
231
+ if issue_id not in agent_issues:
232
+ agent_issues[issue_id] = []
233
+ agent_issues[issue_id].append(agent_id[:10])
234
+
235
+ if not agent_issues:
236
+ text.append("No issues assigned\n", style="dim")
237
+ content.update(text)
238
+ return
239
+
240
+ # Display issues with Linear data if available
241
+ for issue_id, agent_ids in sorted(agent_issues.items()):
242
+ issue_data = self.issues.get(issue_id, {})
243
+
244
+ # Issue identifier
245
+ text.append(f" {issue_id}\n", style="bold yellow")
246
+
247
+ # Title (truncated)
248
+ title = issue_data.get("title", "Loading...")[:45]
249
+ if len(issue_data.get("title", "")) > 45:
250
+ title += "..."
251
+ text.append(f" {title}\n", style="white")
252
+
253
+ # State with color coding
254
+ state = issue_data.get("state", "?")
255
+ state_type = issue_data.get("state_type", "")
256
+ if state_type == "completed":
257
+ state_style = "green"
258
+ elif state_type == "started":
259
+ state_style = "cyan"
260
+ elif state_type == "canceled":
261
+ state_style = "red dim"
262
+ else:
263
+ state_style = "dim"
264
+
265
+ text.append(" State: ", style="dim")
266
+ text.append(f"{state}\n", style=state_style)
267
+
268
+ # Agents working on this
269
+ text.append(" Agents: ", style="dim")
270
+ text.append(f"{', '.join(agent_ids)}\n", style="cyan")
271
+
272
+ # Labels if any
273
+ labels = issue_data.get("labels", [])
274
+ if labels:
275
+ text.append(" Labels: ", style="dim")
276
+ text.append(f"{', '.join(labels[:3])}\n", style="magenta")
277
+
278
+ text.append("\n")
279
+
280
+ # Summary
281
+ text.append("─" * 36 + "\n", style="dim")
282
+ in_progress = sum(1 for i in self.issues.values() if i.get("state_type") == "started")
283
+ completed = sum(1 for i in self.issues.values() if i.get("state_type") == "completed")
284
+ text.append("In Progress: ", style="dim")
285
+ text.append(f"{in_progress}", style="cyan bold")
286
+ text.append(" | Done: ", style="dim")
287
+ text.append(f"{completed}\n", style="green bold")
288
+
289
+ content.update(text)
290
+
291
+
292
+ class QualityPanel(Static):
293
+ """Widget displaying quality gate status (ANV-103/104/105, ANV-111-115)."""
294
+
295
+ agents: reactive[Dict[str, Any]] = reactive({})
296
+ quality_results: reactive[Dict[str, Any]] = reactive({})
297
+ github_status: reactive[Dict[str, Any]] = reactive({}) # ANV-111-113
298
+ coderabbit_status: reactive[Dict[str, Any]] = reactive({}) # ANV-114-115
299
+
300
+ def compose(self) -> ComposeResult:
301
+ yield Static(id="quality-panel-content")
302
+
303
+ def watch_agents(self, agents: Dict[str, Any]) -> None:
304
+ """Update display when agents change."""
305
+ self._refresh_display()
306
+
307
+ def watch_quality_results(self, results: Dict[str, Any]) -> None:
308
+ """Update display when quality results change."""
309
+ self._refresh_display()
310
+
311
+ def watch_github_status(self, status: Dict[str, Any]) -> None:
312
+ """Update display when GitHub status changes."""
313
+ self._refresh_display()
314
+
315
+ def watch_coderabbit_status(self, status: Dict[str, Any]) -> None:
316
+ """Update display when CodeRabbit status changes."""
317
+ self._refresh_display()
318
+
319
+ def _refresh_display(self) -> None:
320
+ """Refresh the quality panel display."""
321
+ content = self.query_one("#quality-panel-content", Static)
322
+
323
+ text = Text()
324
+ text.append("QUALITY GATES\n", style="bold white")
325
+ text.append("─" * 36 + "\n\n", style="dim")
326
+
327
+ if not self.agents:
328
+ text.append("No active agents\n", style="dim")
329
+ content.update(text)
330
+ return
331
+
332
+ # Collect projects from agents
333
+ projects: Dict[str, str] = {} # path -> agent_id
334
+ for agent_id, agent in self.agents.items():
335
+ project = agent.get("project")
336
+ if project:
337
+ projects[project] = agent_id
338
+
339
+ if not projects:
340
+ text.append("No projects detected\n", style="dim")
341
+ content.update(text)
342
+ return
343
+
344
+ # Show results per project
345
+ for project_path, agent_id in projects.items():
346
+ results = self.quality_results.get(project_path, {})
347
+ branch = results.get("branch", "?")
348
+
349
+ # Project header
350
+ display_id = agent_id[:10] + "..." if len(agent_id) > 10 else agent_id
351
+ text.append(f"● {display_id}\n", style="bold cyan")
352
+ text.append(" Branch: ", style="dim")
353
+ text.append(f"{branch[:20]}\n", style="white")
354
+
355
+ # Tests
356
+ tests = results.get("tests", {})
357
+ test_status = tests.get("status", "pending")
358
+ if test_status == "passed":
359
+ text.append(" ✓ Tests: ", style="green")
360
+ text.append(f"{tests.get('passed', 0)} passed\n", style="green")
361
+ elif test_status == "failed":
362
+ text.append(" ✗ Tests: ", style="red")
363
+ text.append(f"{tests.get('failed', 0)} failed\n", style="red")
364
+ elif test_status == "skipped":
365
+ text.append(" ○ Tests: ", style="dim")
366
+ text.append("skipped\n", style="dim")
367
+ else:
368
+ text.append(" ⏳ Tests: ", style="yellow")
369
+ text.append("pending\n", style="yellow")
370
+
371
+ # Lint
372
+ lint = results.get("lint", {})
373
+ lint_status = lint.get("status", "pending")
374
+ errors = lint.get("errors", 0)
375
+ warnings = lint.get("warnings", 0)
376
+ if lint_status == "passed":
377
+ text.append(" ✓ Lint: ", style="green")
378
+ text.append(f"{errors} errors, {warnings} warnings\n", style="green")
379
+ elif lint_status == "failed":
380
+ text.append(" ✗ Lint: ", style="red")
381
+ text.append(f"{errors} errors, {warnings} warnings\n", style="red")
382
+ elif lint_status == "skipped":
383
+ text.append(" ○ Lint: ", style="dim")
384
+ text.append("skipped\n", style="dim")
385
+ else:
386
+ text.append(" ⏳ Lint: ", style="yellow")
387
+ text.append("pending\n", style="yellow")
388
+
389
+ # Types
390
+ types = results.get("types", {})
391
+ types_status = types.get("status", "pending")
392
+ type_errors = types.get("errors", 0)
393
+ if types_status == "passed":
394
+ text.append(" ✓ Types: ", style="green")
395
+ text.append(f"{type_errors} errors\n", style="green")
396
+ elif types_status == "failed":
397
+ text.append(" ✗ Types: ", style="red")
398
+ text.append(f"{type_errors} errors\n", style="red")
399
+ elif types_status == "skipped":
400
+ text.append(" ○ Types: ", style="dim")
401
+ text.append("skipped\n", style="dim")
402
+ else:
403
+ text.append(" ⏳ Types: ", style="yellow")
404
+ text.append("pending\n", style="yellow")
405
+
406
+ # CI Status from GitHub (ANV-111-113)
407
+ gh_status = self.github_status.get(project_path, {})
408
+ ci_status = gh_status.get("ci_status", "none")
409
+ if ci_status == "passed":
410
+ text.append(" ✓ CI: ", style="green")
411
+ text.append("Passed\n", style="green")
412
+ elif ci_status == "failed":
413
+ text.append(" ✗ CI: ", style="red")
414
+ text.append("Failed\n", style="red")
415
+ elif ci_status == "running":
416
+ text.append(" ⏳ CI: ", style="yellow")
417
+ text.append("Running\n", style="yellow")
418
+ elif ci_status == "none":
419
+ text.append(" ○ CI: ", style="dim")
420
+ text.append("None\n", style="dim")
421
+ else:
422
+ text.append(" ○ CI: ", style="dim")
423
+ text.append(f"{ci_status}\n", style="dim")
424
+
425
+ # PR Reviews from GitHub
426
+ reviews_summary = gh_status.get("reviews_summary", "")
427
+ if reviews_summary and reviews_summary != "No reviews":
428
+ if "approved" in reviews_summary.lower():
429
+ text.append(" ✓ Reviews: ", style="green")
430
+ text.append(f"{reviews_summary}\n", style="green")
431
+ elif "changes" in reviews_summary.lower():
432
+ text.append(" ✗ Reviews: ", style="red")
433
+ text.append(f"{reviews_summary}\n", style="red")
434
+ else:
435
+ text.append(" ⏳ Reviews: ", style="yellow")
436
+ text.append(f"{reviews_summary}\n", style="yellow")
437
+
438
+ # CodeRabbit review status (ANV-114-115)
439
+ cr_status = self.coderabbit_status.get(project_path, {})
440
+ cr_review_status = cr_status.get("review_status", "")
441
+ if cr_review_status and cr_review_status != "unavailable":
442
+ issues_count = cr_status.get("issues_count", 0)
443
+ suggestions_count = cr_status.get("suggestions_count", 0)
444
+ pending_count = cr_status.get("pending_count", 0)
445
+
446
+ if cr_review_status == "approved":
447
+ text.append(" ✓ CR: ", style="green")
448
+ text.append("Approved\n", style="green")
449
+ elif cr_review_status == "changes_requested" or issues_count > 0:
450
+ text.append(" ✗ CR: ", style="red")
451
+ text.append(f"{issues_count} issues", style="red")
452
+ if suggestions_count > 0:
453
+ text.append(f", {suggestions_count} suggestions", style="yellow")
454
+ text.append("\n")
455
+ elif cr_review_status == "reviewing" or suggestions_count > 0:
456
+ text.append(" ⏳ CR: ", style="yellow")
457
+ text.append(f"{suggestions_count} suggestions", style="yellow")
458
+ if pending_count > 0:
459
+ text.append(f" ({pending_count} pending)", style="dim")
460
+ text.append("\n")
461
+ elif cr_review_status == "pending":
462
+ text.append(" ○ CR: ", style="dim")
463
+ text.append("Pending\n", style="dim")
464
+
465
+ # Ready to merge status
466
+ text.append(" ─────────────\n", style="dim")
467
+ ready = results.get("ready_to_merge", False)
468
+ blocking = results.get("blocking_issues", 0)
469
+ if ready:
470
+ text.append(" Status: ", style="dim")
471
+ text.append("READY ✓\n", style="green bold")
472
+ elif blocking > 0:
473
+ text.append(" Status: ", style="dim")
474
+ text.append(f"BLOCKED ({blocking})\n", style="red bold")
475
+ else:
476
+ text.append(" Status: ", style="dim")
477
+ text.append("PENDING\n", style="yellow")
478
+
479
+ text.append("\n")
480
+
481
+ # Summary
482
+ text.append("─" * 36 + "\n", style="dim")
483
+ ready_count = sum(1 for r in self.quality_results.values() if r.get("ready_to_merge"))
484
+ blocked_count = sum(1 for r in self.quality_results.values() if r.get("blocking_issues", 0) > 0)
485
+ text.append("Ready: ", style="dim")
486
+ text.append(f"{ready_count}", style="green bold")
487
+ text.append(" | Blocked: ", style="dim")
488
+ text.append(f"{blocked_count}\n", style="red bold" if blocked_count > 0 else "dim")
489
+
490
+ content.update(text)
491
+
492
+
493
+ class CoordinationPanel(Static):
494
+ """Widget displaying agent coordination and conflicts (ANV-106-110)."""
495
+
496
+ agents: reactive[Dict[str, Any]] = reactive({})
497
+ coordination: reactive[Dict[str, Any]] = reactive({})
498
+
499
+ def compose(self) -> ComposeResult:
500
+ yield Static(id="coordination-panel-content")
501
+
502
+ def watch_agents(self, agents: Dict[str, Any]) -> None:
503
+ """Update display when agents change."""
504
+ self._refresh_display()
505
+
506
+ def watch_coordination(self, coordination: Dict[str, Any]) -> None:
507
+ """Update display when coordination data changes."""
508
+ self._refresh_display()
509
+
510
+ def _refresh_display(self) -> None:
511
+ """Refresh the coordination panel display."""
512
+ content = self.query_one("#coordination-panel-content", Static)
513
+
514
+ text = Text()
515
+ text.append("COORDINATION\n", style="bold white")
516
+ text.append("─" * 36 + "\n\n", style="dim")
517
+
518
+ if not self.agents:
519
+ text.append("No active agents\n", style="dim")
520
+ content.update(text)
521
+ return
522
+
523
+ # File locks
524
+ locks = self.coordination.get("file_locks", {})
525
+ if locks:
526
+ text.append("FILE LOCKS\n", style="bold cyan")
527
+ for pattern, lock in list(locks.items())[:5]:
528
+ agent = lock.get("agent_display", "?")
529
+ duration = lock.get("duration_seconds", 0)
530
+ if duration < 60:
531
+ dur_str = f"{duration}s"
532
+ else:
533
+ dur_str = f"{duration // 60}m"
534
+ # Truncate pattern for display
535
+ display_pattern = pattern[:25] + "..." if len(pattern) > 25 else pattern
536
+ text.append(f" {display_pattern}\n", style="white")
537
+ text.append(f" └─ {agent} ({dur_str})\n", style="dim cyan")
538
+ text.append("\n")
539
+ else:
540
+ text.append("FILE LOCKS\n", style="dim")
541
+ text.append(" None\n\n", style="dim")
542
+
543
+ # Conflicts
544
+ conflicts = self.coordination.get("conflicts", [])
545
+ if conflicts:
546
+ text.append("⚠️ CONFLICTS\n", style="bold red")
547
+ for conflict in conflicts[:3]:
548
+ pattern = conflict.get("pattern", "?")
549
+ agents = conflict.get("agents", [])
550
+ suggestion = conflict.get("suggestion", "")
551
+ # Truncate pattern
552
+ display_pattern = pattern[:25] + "..." if len(pattern) > 25 else pattern
553
+ text.append(f" {display_pattern}\n", style="red")
554
+ agent_names = ", ".join(a[:10] for a in agents)
555
+ text.append(f" └─ {agent_names}\n", style="yellow")
556
+ if suggestion:
557
+ text.append(f" └─ {suggestion}\n", style="dim")
558
+ text.append("\n")
559
+
560
+ # Branch age
561
+ branches = self.coordination.get("branches", {})
562
+ if branches:
563
+ text.append("BRANCH AGE\n", style="bold white")
564
+ for agent_id, branch_info in list(branches.items())[:5]:
565
+ agent = agent_id[:12] if len(agent_id) > 12 else agent_id
566
+ age = branch_info.get("age_display", "?")
567
+ is_stale = branch_info.get("is_stale", False)
568
+
569
+ text.append(f" {agent}: ", style="cyan")
570
+ text.append(f"{age}", style="red bold" if is_stale else "green")
571
+ if is_stale:
572
+ text.append(" (stale)", style="red")
573
+ text.append("\n")
574
+ text.append("\n")
575
+
576
+ # Summary
577
+ text.append("─" * 36 + "\n", style="dim")
578
+ lock_count = len(locks)
579
+ conflict_count = len(conflicts)
580
+ stale_count = sum(1 for b in branches.values() if b.get("is_stale", False))
581
+
582
+ text.append("Locks: ", style="dim")
583
+ text.append(f"{lock_count}", style="cyan")
584
+ text.append(" | Conflicts: ", style="dim")
585
+ text.append(f"{conflict_count}", style="red bold" if conflict_count > 0 else "green")
586
+ if stale_count > 0:
587
+ text.append(" | Stale: ", style="dim")
588
+ text.append(f"{stale_count}", style="yellow bold")
589
+ text.append("\n")
590
+
591
+ content.update(text)
592
+
593
+
594
+ class ToolActivityPanel(Static):
595
+ """Widget displaying aggregate tool activity across all agents (ANV-125)."""
596
+
597
+ tool_activity: reactive[Dict[str, Any]] = reactive({})
598
+
599
+ def compose(self) -> ComposeResult:
600
+ yield Static(id="tool-activity-content")
601
+
602
+ def watch_tool_activity(self, activity: Dict[str, Any]) -> None:
603
+ """Update display when tool activity changes."""
604
+ self._refresh_display()
605
+
606
+ def _refresh_display(self) -> None:
607
+ """Refresh the tool activity panel display."""
608
+ content = self.query_one("#tool-activity-content", Static)
609
+
610
+ text = Text()
611
+ text.append("TOOL ACTIVITY\n", style="bold white")
612
+ text.append("─" * 36 + "\n\n", style="dim")
613
+
614
+ running = self.tool_activity.get("running", [])
615
+ completed = self.tool_activity.get("completed", {})
616
+ error_count = self.tool_activity.get("errors", 0)
617
+
618
+ # Running tools section
619
+ text.append("Running:\n", style="bold cyan")
620
+ if running:
621
+ for tool in running[:2]: # Max 2 running
622
+ name = tool.get("name", "Unknown")
623
+ target = tool.get("target", "")
624
+ # Spinner indicator
625
+ text.append(" ◐ ", style="cyan bold")
626
+ text.append(f"{name}", style="white bold")
627
+ if target:
628
+ # Shorten target for display
629
+ if len(target) > 25:
630
+ target = "..." + target[-22:]
631
+ text.append(f" {target}", style="dim")
632
+ text.append("\n")
633
+ else:
634
+ text.append(" (none)\n", style="dim")
635
+
636
+ text.append("\n")
637
+
638
+ # Completed tools section
639
+ text.append("Completed:\n", style="bold green")
640
+ if completed:
641
+ # Sort by count descending
642
+ sorted_tools = sorted(completed.items(), key=lambda x: x[1], reverse=True)
643
+ for name, count in sorted_tools[:4]: # Max 4 completed
644
+ text.append(" ✓ ", style="green")
645
+ text.append(f"{name}", style="white")
646
+ if count > 1:
647
+ text.append(f" ×{count}", style="cyan bold")
648
+ text.append("\n")
649
+ else:
650
+ text.append(" (none)\n", style="dim")
651
+
652
+ # Error summary
653
+ if error_count > 0:
654
+ text.append("\n")
655
+ text.append(f"Errors: {error_count}\n", style="red bold")
656
+
657
+ content.update(text)
658
+
659
+
660
+ class TabBar(Static):
661
+ """Tab bar widget for Focused Mode navigation (HUD v3 - ANV-128)."""
662
+
663
+ TAB_NAMES = ["Agents", "Kanban", "Quality", "Costs", "Coord"]
664
+
665
+ active_tab: reactive[int] = reactive(0)
666
+
667
+ def compose(self) -> ComposeResult:
668
+ yield Static(id="tab-bar-content")
669
+
670
+ def watch_active_tab(self, tab: int) -> None:
671
+ """Update display when active tab changes."""
672
+ self._refresh_display()
673
+
674
+ def _refresh_display(self) -> None:
675
+ """Refresh the tab bar display."""
676
+ content = self.query_one("#tab-bar-content", Static)
677
+
678
+ text = Text()
679
+ for i, name in enumerate(self.TAB_NAMES):
680
+ # Tab number indicator
681
+ num = str(i + 1)
682
+ if i == self.active_tab:
683
+ # Active tab: highlighted
684
+ text.append(f" [{num}]", style="cyan bold")
685
+ text.append(f"{name}", style="cyan bold reverse")
686
+ text.append(" ", style="")
687
+ else:
688
+ # Inactive tab: dimmed
689
+ text.append(f" [{num}]", style="dim")
690
+ text.append(f"{name}", style="white")
691
+ text.append(" ", style="")
692
+
693
+ content.update(text)
694
+
695
+ def set_tab(self, tab: int) -> None:
696
+ """Set active tab (0-4)."""
697
+ if 0 <= tab < len(self.TAB_NAMES):
698
+ self.active_tab = tab
699
+
700
+ def next_tab(self) -> None:
701
+ """Move to next tab (with wrap)."""
702
+ self.active_tab = (self.active_tab + 1) % len(self.TAB_NAMES)
703
+
704
+ def prev_tab(self) -> None:
705
+ """Move to previous tab (with wrap)."""
706
+ self.active_tab = (self.active_tab - 1) % len(self.TAB_NAMES)
707
+
708
+
709
+ class HelpOverlay(Static):
710
+ """Modal help overlay showing all keybindings (HUD v3 - ANV-128/131).
711
+
712
+ Displays comprehensive help in a centered modal with dimmed background.
713
+ Toggle with '?' key, close with '?' or 'Escape'.
714
+ """
715
+
716
+ HELP_TEXT = """
717
+ ╔══════════════════════════════════════════════════════════════════╗
718
+ ║ ANVIL HUD HELP ║
719
+ ╠══════════════════════════════════════════════════════════════════╣
720
+ ║ ║
721
+ ║ NAVIGATION ║
722
+ ║ ────────── ║
723
+ ║ 1-5 Jump to panel (Agents/Kanban/Quality/Costs/Coord) ║
724
+ ║ Tab Next panel ║
725
+ ║ Shift+Tab Previous panel ║
726
+ ║ m Toggle Focused/Full mode ║
727
+ ║ ║
728
+ ║ KANBAN (when active) ║
729
+ ║ ────────── ║
730
+ ║ h / l Move left/right between columns ║
731
+ ║ j / k Move down/up within column ║
732
+ ║ c Claim issue for current agent ║
733
+ ║ n / N Move issue forward/backward ║
734
+ ║ ║
735
+ ║ GENERAL ║
736
+ ║ ────────── ║
737
+ ║ r Force refresh ║
738
+ ║ d Toggle detailed view ║
739
+ ║ s Open settings ║
740
+ ║ ? Toggle this help ║
741
+ ║ Esc Close overlay ║
742
+ ║ q Quit ║
743
+ ║ ║
744
+ ║ [ Press ? or Esc to close ] ║
745
+ ╚══════════════════════════════════════════════════════════════════╝
746
+ """
747
+
748
+ def compose(self) -> ComposeResult:
749
+ yield Static(self.HELP_TEXT, id="help-content")
750
+
751
+ def on_mount(self) -> None:
752
+ """Initially hidden."""
753
+ self.display = False
754
+
755
+
756
+ # =============================================================================
757
+ # Interactive Settings Widgets (ANV-205)
758
+ # =============================================================================
759
+
760
+
761
+ class SelectField(Static):
762
+ """Cycles through enum options with keyboard navigation."""
763
+
764
+ options: reactive[List[str]] = reactive([])
765
+ value: reactive[str] = reactive("")
766
+ focused: reactive[bool] = reactive(False)
767
+
768
+ def __init__(
769
+ self,
770
+ options: List[str],
771
+ value: str = "",
772
+ labels: Optional[Dict[str, str]] = None,
773
+ on_change: Optional[Callable[[str], None]] = None,
774
+ **kwargs
775
+ ):
776
+ super().__init__(**kwargs)
777
+ self._options = list(options)
778
+ self._value = value if value else (options[0] if options else "")
779
+ self._labels = labels or {}
780
+ self._on_change = on_change
781
+ self._last_callback_value: Optional[str] = None
782
+
783
+ def on_mount(self) -> None:
784
+ self.options = self._options
785
+ self.value = self._value
786
+ self._refresh_display()
787
+
788
+ def watch_value(self, value: str) -> None:
789
+ self._refresh_display()
790
+ if self._on_change:
791
+ # Prevent callback loops by checking if value actually changed
792
+ if self._last_callback_value is None or self._last_callback_value != value:
793
+ self._last_callback_value = value
794
+ self._on_change(value)
795
+
796
+ def watch_focused(self, focused: bool) -> None:
797
+ self._refresh_display()
798
+
799
+ def _refresh_display(self) -> None:
800
+ display_label = self._labels.get(self.value, self.value)
801
+ text = Text()
802
+ if self.focused:
803
+ text.append("◀ ", style="cyan")
804
+ text.append(display_label, style="bold white on blue")
805
+ text.append(" ▶", style="cyan")
806
+ else:
807
+ text.append(f" {display_label} ", style="white")
808
+ self.update(text)
809
+
810
+ def cycle_next(self) -> None:
811
+ if not self._options:
812
+ return
813
+ try:
814
+ idx = self._options.index(self.value)
815
+ idx = (idx + 1) % len(self._options)
816
+ except ValueError:
817
+ idx = 0
818
+ self.value = self._options[idx]
819
+
820
+ def cycle_prev(self) -> None:
821
+ if not self._options:
822
+ return
823
+ try:
824
+ idx = self._options.index(self.value)
825
+ idx = (idx - 1) % len(self._options)
826
+ except ValueError:
827
+ idx = 0
828
+ self.value = self._options[idx]
829
+
830
+ def handle_key(self, key: str) -> bool:
831
+ if key in ("right", "space", "enter"):
832
+ self.cycle_next()
833
+ return True
834
+ elif key == "left":
835
+ self.cycle_prev()
836
+ return True
837
+ return False
838
+
839
+
840
+ class NumberField(Static):
841
+ """Numeric input field with validation."""
842
+
843
+ value: reactive[float] = reactive(0.0)
844
+ focused: reactive[bool] = reactive(False)
845
+ error: reactive[str] = reactive("")
846
+
847
+ def __init__(
848
+ self,
849
+ value: float = 0.0,
850
+ min_value: float = 0.0,
851
+ max_value: float = 1000.0,
852
+ decimal_places: int = 2,
853
+ prefix: str = "",
854
+ suffix: str = "",
855
+ on_change: Optional[Callable[[float], None]] = None,
856
+ **kwargs
857
+ ):
858
+ super().__init__(**kwargs)
859
+ self._value = value
860
+ self._min_value = min_value
861
+ self._max_value = max_value
862
+ self._decimal_places = decimal_places
863
+ self._prefix = prefix
864
+ self._suffix = suffix
865
+ self._on_change = on_change
866
+ self._input_buffer = ""
867
+ self._last_callback_value: Optional[float] = None
868
+
869
+ def on_mount(self) -> None:
870
+ self.value = self._value
871
+ self._input_buffer = self._format_value(self._value)
872
+ self._refresh_display()
873
+
874
+ def watch_value(self, value: float) -> None:
875
+ self._refresh_display()
876
+ if self._on_change:
877
+ # Prevent callback loops by checking if value actually changed
878
+ if self._last_callback_value is None or self._last_callback_value != value:
879
+ self._last_callback_value = value
880
+ self._on_change(value)
881
+
882
+ def watch_focused(self, focused: bool) -> None:
883
+ if focused:
884
+ self._input_buffer = self._format_value(self.value)
885
+ else:
886
+ self._commit_value()
887
+ self._refresh_display()
888
+
889
+ def watch_error(self, error: str) -> None:
890
+ self._refresh_display()
891
+
892
+ def _format_value(self, val: float) -> str:
893
+ return f"{val:.{self._decimal_places}f}"
894
+
895
+ def _refresh_display(self) -> None:
896
+ text = Text()
897
+ if self._prefix:
898
+ text.append(self._prefix, style="dim")
899
+ if self.focused:
900
+ text.append(self._input_buffer, style="bold white on blue")
901
+ text.append("▌", style="cyan blink")
902
+ else:
903
+ display = self._format_value(self.value)
904
+ style = "red bold" if self.error else "white"
905
+ text.append(display, style=style)
906
+ if self._suffix:
907
+ text.append(f" {self._suffix}", style="dim")
908
+ self.update(text)
909
+
910
+ def _validate_buffer(self) -> None:
911
+ try:
912
+ val = float(self._input_buffer) if self._input_buffer else 0.0
913
+ if val < self._min_value:
914
+ self.error = f"Min: {self._min_value}"
915
+ elif val > self._max_value:
916
+ self.error = f"Max: {self._max_value}"
917
+ else:
918
+ self.error = ""
919
+ except ValueError:
920
+ self.error = "Invalid number"
921
+
922
+ def _commit_value(self) -> None:
923
+ try:
924
+ val = float(self._input_buffer) if self._input_buffer else 0.0
925
+ val = max(self._min_value, min(self._max_value, val))
926
+ self.value = round(val, self._decimal_places)
927
+ self.error = ""
928
+ except ValueError:
929
+ pass
930
+ self._input_buffer = self._format_value(self.value)
931
+
932
+ def handle_key(self, key: str) -> bool:
933
+ if key in "0123456789":
934
+ self._input_buffer += key
935
+ self._validate_buffer()
936
+ self._refresh_display()
937
+ return True
938
+ elif key == "period" or key == ".":
939
+ if "." not in self._input_buffer:
940
+ self._input_buffer += "."
941
+ self._refresh_display()
942
+ return True
943
+ elif key == "backspace":
944
+ if self._input_buffer:
945
+ self._input_buffer = self._input_buffer[:-1]
946
+ self._validate_buffer()
947
+ self._refresh_display()
948
+ return True
949
+ elif key == "minus" or key == "-":
950
+ if not self._input_buffer and self._min_value < 0:
951
+ self._input_buffer = "-"
952
+ self._refresh_display()
953
+ return True
954
+ elif key == "enter":
955
+ self._commit_value()
956
+ return True
957
+ return False
958
+
959
+
960
+ class ToggleField(Static):
961
+ """Boolean toggle field. Space/Enter to toggle."""
962
+
963
+ value: reactive[bool] = reactive(False)
964
+ focused: reactive[bool] = reactive(False)
965
+
966
+ def __init__(
967
+ self,
968
+ value: bool = False,
969
+ label_on: str = "Enabled",
970
+ label_off: str = "Disabled",
971
+ on_change: Optional[Callable[[bool], None]] = None,
972
+ **kwargs
973
+ ):
974
+ super().__init__(**kwargs)
975
+ self._value = value
976
+ self._label_on = label_on
977
+ self._label_off = label_off
978
+ self._on_change = on_change
979
+ self._last_callback_value: Optional[bool] = None
980
+
981
+ def on_mount(self) -> None:
982
+ self.value = self._value
983
+ self._refresh_display()
984
+
985
+ def watch_value(self, value: bool) -> None:
986
+ self._refresh_display()
987
+ if self._on_change:
988
+ # Prevent callback loops by checking if value actually changed
989
+ if self._last_callback_value is None or self._last_callback_value != value:
990
+ self._last_callback_value = value
991
+ self._on_change(value)
992
+
993
+ def watch_focused(self, focused: bool) -> None:
994
+ self._refresh_display()
995
+
996
+ def _refresh_display(self) -> None:
997
+ text = Text()
998
+ checkbox = "[●]" if self.value else "[ ]"
999
+ label = self._label_on if self.value else self._label_off
1000
+ if self.focused:
1001
+ text.append(checkbox, style="bold cyan")
1002
+ text.append(f" {label}", style="bold white on blue")
1003
+ else:
1004
+ text.append(checkbox, style="green" if self.value else "dim")
1005
+ text.append(f" {label}", style="white" if self.value else "dim")
1006
+ self.update(text)
1007
+
1008
+ def toggle(self) -> None:
1009
+ self.value = not self.value
1010
+
1011
+ def handle_key(self, key: str) -> bool:
1012
+ if key in ("space", "enter"):
1013
+ self.toggle()
1014
+ return True
1015
+ return False
1016
+
1017
+
1018
+ class SettingRow(Horizontal):
1019
+ """Container for a setting: label + field + error indicator."""
1020
+
1021
+ focused: reactive[bool] = reactive(False)
1022
+
1023
+ def __init__(self, label: str, field_widget: Static, key: str, **kwargs):
1024
+ super().__init__(**kwargs)
1025
+ self._label = label
1026
+ self._field = field_widget
1027
+ self._key = key
1028
+
1029
+ @property
1030
+ def key(self) -> str:
1031
+ return self._key
1032
+
1033
+ @property
1034
+ def field(self) -> Static:
1035
+ return self._field
1036
+
1037
+ def compose(self) -> ComposeResult:
1038
+ yield Static(f"{self._label}:", classes="setting-label")
1039
+ self._field.add_class("setting-field")
1040
+ yield self._field
1041
+ yield Static("", classes="setting-error", id=f"error-{self._key}")
1042
+
1043
+ def watch_focused(self, focused: bool) -> None:
1044
+ if hasattr(self._field, 'focused'):
1045
+ self._field.focused = focused
1046
+ if focused:
1047
+ self.add_class("focused-row")
1048
+ else:
1049
+ self.remove_class("focused-row")
1050
+
1051
+ def set_error(self, message: str) -> None:
1052
+ error_widget = self.query_one(f"#error-{self._key}", Static)
1053
+ error_widget.update(message)
1054
+
1055
+ def handle_key(self, key: str) -> bool:
1056
+ if hasattr(self._field, 'handle_key'):
1057
+ return self._field.handle_key(key)
1058
+ return False
1059
+
1060
+
1061
+
1062
+ @dataclass
1063
+ class SettingsState:
1064
+ """Tracks settings state for modification detection and validation (ANV-207).
1065
+
1066
+ Stores original values from config and current modified values,
1067
+ enabling dirty state tracking and save/cancel functionality.
1068
+ """
1069
+ # Original values (from config)
1070
+ original_billing_model: str = "api"
1071
+ original_agent_warn: float = 5.0
1072
+ original_agent_crit: float = 10.0
1073
+ original_context_warn: int = 80
1074
+ original_context_crit: int = 90
1075
+ original_default_mode: str = "focused"
1076
+
1077
+ # Current (possibly modified) values
1078
+ billing_model: str = "api"
1079
+ agent_warn: float = 5.0
1080
+ agent_crit: float = 10.0
1081
+ context_warn: int = 80
1082
+ context_crit: int = 90
1083
+ default_mode: str = "focused"
1084
+
1085
+ # UI state
1086
+ focused_index: int = 0
1087
+ validation_errors: Dict[str, str] = field(default_factory=dict)
1088
+
1089
+ @property
1090
+ def is_dirty(self) -> bool:
1091
+ """Check if any value differs from original."""
1092
+ return (
1093
+ self.billing_model != self.original_billing_model or
1094
+ self.agent_warn != self.original_agent_warn or
1095
+ self.agent_crit != self.original_agent_crit or
1096
+ self.context_warn != self.original_context_warn or
1097
+ self.context_crit != self.original_context_crit or
1098
+ self.default_mode != self.original_default_mode
1099
+ )
1100
+
1101
+ def load_from_config(self, config) -> None:
1102
+ """Load values from HUD config object."""
1103
+ if not config:
1104
+ return
1105
+ self.original_billing_model = getattr(config.budget, "billing_model", "api")
1106
+ self.original_agent_warn = config.budget.agent_warn_threshold
1107
+ self.original_agent_crit = config.budget.agent_crit_threshold
1108
+ self.original_context_warn = config.context.warn_threshold_pct
1109
+ self.original_context_crit = config.context.crit_threshold_pct
1110
+ self.original_default_mode = getattr(config.hud, "default_mode", "focused")
1111
+ # Reset current to original
1112
+ self.reset()
1113
+
1114
+ def reset(self) -> None:
1115
+ """Reset current values to original."""
1116
+ self.billing_model = self.original_billing_model
1117
+ self.agent_warn = self.original_agent_warn
1118
+ self.agent_crit = self.original_agent_crit
1119
+ self.context_warn = self.original_context_warn
1120
+ self.context_crit = self.original_context_crit
1121
+ self.default_mode = self.original_default_mode
1122
+ self.validation_errors.clear()
1123
+
1124
+ def validate(self) -> bool:
1125
+ """Validate all fields. Returns True if valid."""
1126
+ self.validation_errors.clear()
1127
+
1128
+ # Agent thresholds: warn must be < crit
1129
+ if self.agent_warn >= self.agent_crit:
1130
+ self.validation_errors["agent_warn"] = "Must be < critical"
1131
+ self.validation_errors["agent_crit"] = "Must be > warning"
1132
+
1133
+ # Context thresholds: warn must be < crit
1134
+ if self.context_warn >= self.context_crit:
1135
+ self.validation_errors["context_warn"] = "Must be < critical"
1136
+ self.validation_errors["context_crit"] = "Must be > warning"
1137
+
1138
+ return len(self.validation_errors) == 0
1139
+
1140
+ class SettingsOverlay(Container):
1141
+ """Interactive modal settings overlay for HUD configuration (ANV-205/ANV-207).
1142
+
1143
+ Displays editable settings with keyboard navigation.
1144
+ Toggle with 's' key, close with 's' or 'Escape'.
1145
+ Save with Ctrl+S, Cancel with Escape.
1146
+
1147
+ Phase 1 Fields (6 total):
1148
+ - Budget: billing_model, agent_warn_threshold, agent_crit_threshold
1149
+ - Context: warn_threshold_pct, crit_threshold_pct
1150
+ - HUD: default_mode
1151
+ """
1152
+
1153
+ # Field definitions for navigation order
1154
+ FIELD_KEYS = [
1155
+ "billing_model",
1156
+ "agent_warn",
1157
+ "agent_crit",
1158
+ "context_warn",
1159
+ "context_crit",
1160
+ "default_mode",
1161
+ ]
1162
+
1163
+ def __init__(self, config=None, **kwargs):
1164
+ super().__init__(**kwargs)
1165
+ self._config = config
1166
+ self._state = SettingsState()
1167
+ self._rows: Dict[str, SettingRow] = {}
1168
+ self._focused_index = 0
1169
+
1170
+ def compose(self) -> ComposeResult:
1171
+ """Create the interactive settings UI."""
1172
+ yield Static(id="settings-header")
1173
+ yield ScrollableContainer(
1174
+ # Budget group
1175
+ Static("BUDGET", classes="settings-group-header"),
1176
+ SettingRow(
1177
+ "Billing Model",
1178
+ SelectField(
1179
+ options=["api", "subscription"],
1180
+ labels={"api": "API Key", "subscription": "Subscription (Pro/Max)"},
1181
+ value=self._state.billing_model,
1182
+ on_change=self._on_billing_change,
1183
+ ),
1184
+ key="billing_model",
1185
+ ),
1186
+ SettingRow(
1187
+ "Agent Warning ($)",
1188
+ NumberField(
1189
+ value=self._state.agent_warn,
1190
+ min_value=0.01,
1191
+ max_value=1000.0,
1192
+ decimal_places=2,
1193
+ prefix="$",
1194
+ on_change=self._on_agent_warn_change,
1195
+ ),
1196
+ key="agent_warn",
1197
+ ),
1198
+ SettingRow(
1199
+ "Agent Critical ($)",
1200
+ NumberField(
1201
+ value=self._state.agent_crit,
1202
+ min_value=0.01,
1203
+ max_value=1000.0,
1204
+ decimal_places=2,
1205
+ prefix="$",
1206
+ on_change=self._on_agent_crit_change,
1207
+ ),
1208
+ key="agent_crit",
1209
+ ),
1210
+ # Context group
1211
+ Static("CONTEXT", classes="settings-group-header"),
1212
+ SettingRow(
1213
+ "Warning Threshold",
1214
+ NumberField(
1215
+ value=float(self._state.context_warn),
1216
+ min_value=1,
1217
+ max_value=99,
1218
+ decimal_places=0,
1219
+ suffix="%",
1220
+ on_change=self._on_context_warn_change,
1221
+ ),
1222
+ key="context_warn",
1223
+ ),
1224
+ SettingRow(
1225
+ "Critical Threshold",
1226
+ NumberField(
1227
+ value=float(self._state.context_crit),
1228
+ min_value=1,
1229
+ max_value=99,
1230
+ decimal_places=0,
1231
+ suffix="%",
1232
+ on_change=self._on_context_crit_change,
1233
+ ),
1234
+ key="context_crit",
1235
+ ),
1236
+ # HUD group
1237
+ Static("HUD MODE", classes="settings-group-header"),
1238
+ SettingRow(
1239
+ "Default Mode",
1240
+ SelectField(
1241
+ options=["focused", "full"],
1242
+ labels={"focused": "Focused", "full": "Full"},
1243
+ value=self._state.default_mode,
1244
+ on_change=self._on_mode_change,
1245
+ ),
1246
+ key="default_mode",
1247
+ ),
1248
+ id="settings-scroll",
1249
+ )
1250
+ yield Static(id="settings-status")
1251
+ yield Static(
1252
+ "[Ctrl+S] Save [Esc] Cancel [↑/↓] Navigate [←/→] Edit",
1253
+ id="settings-footer",
1254
+ )
1255
+
1256
+ def on_mount(self) -> None:
1257
+ """Initially hidden, load config on mount."""
1258
+ self.display = False
1259
+ self._load_config()
1260
+ self._cache_rows()
1261
+ self._update_header()
1262
+ self._update_focus()
1263
+
1264
+ def _cache_rows(self) -> None:
1265
+ """Cache SettingRow references for navigation."""
1266
+ self._rows.clear()
1267
+ for row in self.query(SettingRow):
1268
+ self._rows[row.key] = row
1269
+
1270
+ def _load_config(self) -> None:
1271
+ """Load values from config into state."""
1272
+ config = self._config
1273
+ try:
1274
+ if hasattr(self.app, 'config') and self.app.config:
1275
+ config = self.app.config
1276
+ except Exception:
1277
+ pass
1278
+ self._state.load_from_config(config)
1279
+
1280
+ def update_config(self, config) -> None:
1281
+ """Update configuration and refresh display."""
1282
+ self._config = config
1283
+ self._load_config()
1284
+ self._sync_widgets_from_state()
1285
+ self._update_header()
1286
+
1287
+ def _sync_widgets_from_state(self) -> None:
1288
+ """Sync widget values from state (after load/reset)."""
1289
+ for key, row in self._rows.items():
1290
+ if key == "billing_model":
1291
+ row.field.value = self._state.billing_model
1292
+ elif key == "agent_warn":
1293
+ row.field.value = self._state.agent_warn
1294
+ elif key == "agent_crit":
1295
+ row.field.value = self._state.agent_crit
1296
+ elif key == "context_warn":
1297
+ row.field.value = float(self._state.context_warn)
1298
+ elif key == "context_crit":
1299
+ row.field.value = float(self._state.context_crit)
1300
+ elif key == "default_mode":
1301
+ row.field.value = self._state.default_mode
1302
+
1303
+ def _update_header(self) -> None:
1304
+ """Update header to show dirty indicator."""
1305
+ try:
1306
+ header = self.query_one("#settings-header", Static)
1307
+ dirty_marker = " *" if self._state.is_dirty else ""
1308
+ header.update(f"ANVIL HUD SETTINGS{dirty_marker}")
1309
+ except Exception:
1310
+ pass
1311
+
1312
+ def _update_status(self, message: str = "") -> None:
1313
+ """Update status bar with message or validation errors."""
1314
+ try:
1315
+ status = self.query_one("#settings-status", Static)
1316
+ if self._state.validation_errors:
1317
+ errors = ", ".join(
1318
+ f"{k}: {v}" for k, v in self._state.validation_errors.items()
1319
+ )
1320
+ status.update(Text(f"⚠ {errors}", style="red"))
1321
+ elif message:
1322
+ status.update(Text(message, style="cyan"))
1323
+ else:
1324
+ status.update("")
1325
+ except Exception:
1326
+ pass
1327
+
1328
+ def _update_focus(self) -> None:
1329
+ """Update visual focus indicator on current field."""
1330
+ for i, key in enumerate(self.FIELD_KEYS):
1331
+ if key in self._rows:
1332
+ self._rows[key].focused = (i == self._focused_index)
1333
+
1334
+ # Value change handlers (update state when widgets change)
1335
+ def _on_billing_change(self, value: str) -> None:
1336
+ self._state.billing_model = value
1337
+ self._update_header()
1338
+
1339
+ def _on_agent_warn_change(self, value: float) -> None:
1340
+ self._state.agent_warn = value
1341
+ self._validate_and_update()
1342
+
1343
+ def _on_agent_crit_change(self, value: float) -> None:
1344
+ self._state.agent_crit = value
1345
+ self._validate_and_update()
1346
+
1347
+ def _on_context_warn_change(self, value: float) -> None:
1348
+ self._state.context_warn = int(value)
1349
+ self._validate_and_update()
1350
+
1351
+ def _on_context_crit_change(self, value: float) -> None:
1352
+ self._state.context_crit = int(value)
1353
+ self._validate_and_update()
1354
+
1355
+ def _on_mode_change(self, value: str) -> None:
1356
+ self._state.default_mode = value
1357
+ self._update_header()
1358
+
1359
+ def _validate_and_update(self) -> None:
1360
+ """Validate state and update UI."""
1361
+ self._state.validate()
1362
+ self._update_header()
1363
+ self._update_status()
1364
+ # Show errors on individual rows
1365
+ for key, row in self._rows.items():
1366
+ error = self._state.validation_errors.get(key, "")
1367
+ row.set_error(error)
1368
+
1369
+ # Navigation
1370
+ def focus_next(self) -> None:
1371
+ """Move focus to next field."""
1372
+ self._focused_index = (self._focused_index + 1) % len(self.FIELD_KEYS)
1373
+ self._update_focus()
1374
+
1375
+ def focus_prev(self) -> None:
1376
+ """Move focus to previous field."""
1377
+ self._focused_index = (self._focused_index - 1) % len(self.FIELD_KEYS)
1378
+ self._update_focus()
1379
+
1380
+ def handle_key(self, key: str) -> bool:
1381
+ """Handle keyboard input for navigation and field editing."""
1382
+ # Navigation
1383
+ if key in ("down", "tab"):
1384
+ self.focus_next()
1385
+ return True
1386
+ elif key in ("up", "shift+tab"):
1387
+ self.focus_prev()
1388
+ return True
1389
+
1390
+ # Delegate to focused field
1391
+ current_key = self.FIELD_KEYS[self._focused_index]
1392
+ if current_key in self._rows:
1393
+ return self._rows[current_key].handle_key(key)
1394
+
1395
+ return False
1396
+
1397
+ @property
1398
+ def is_dirty(self) -> bool:
1399
+ """Check if settings have been modified."""
1400
+ return self._state.is_dirty
1401
+
1402
+ def save(self) -> bool:
1403
+ """Save settings to config file. Returns True if successful (ANV-208)."""
1404
+ if not self._state.validate():
1405
+ self._update_status("Cannot save: validation errors")
1406
+ return False
1407
+
1408
+ try:
1409
+ # Get config and config_service from app
1410
+ config = self._config
1411
+ config_service = None
1412
+ if hasattr(self.app, 'config') and self.app.config:
1413
+ config = self.app.config
1414
+ if hasattr(self.app, '_config_service'):
1415
+ config_service = self.app._config_service
1416
+
1417
+ if config:
1418
+ # Update in-memory config
1419
+ config.budget.billing_model = self._state.billing_model
1420
+ config.budget.agent_warn_threshold = self._state.agent_warn
1421
+ config.budget.agent_crit_threshold = self._state.agent_crit
1422
+ config.context.warn_threshold_pct = self._state.context_warn
1423
+ config.context.crit_threshold_pct = self._state.context_crit
1424
+ config.hud.default_mode = self._state.default_mode
1425
+
1426
+ # Persist to file (ANV-208)
1427
+ if config_service:
1428
+ if not config_service.save_global_config(config):
1429
+ self._update_status("Error: Could not save to file")
1430
+ return False
1431
+ # Hot-reload to ensure changes take effect
1432
+ self.app.config = config_service.reload()
1433
+
1434
+ # Update original values (no longer dirty)
1435
+ self._state.load_from_config(config)
1436
+ self._update_header()
1437
+ self._update_status("Settings saved")
1438
+ return True
1439
+ except Exception as e:
1440
+ self._update_status(f"Save failed: {e}")
1441
+ return False
1442
+
1443
+ return False
1444
+
1445
+ def cancel(self) -> None:
1446
+ """Cancel changes and reset to original values."""
1447
+ self._state.reset()
1448
+ self._sync_widgets_from_state()
1449
+ self._update_header()
1450
+ self._update_status("Changes cancelled")
1451
+
1452
+
1453
+ class KanbanPanel(Static):
1454
+ """Kanban board widget for issue tracking (ANV-76)."""
1455
+
1456
+ COLUMNS = [
1457
+ ("TODO", IssueStatus.TODO if IssueStatus else None),
1458
+ ("IN PROGRESS", IssueStatus.IN_PROGRESS if IssueStatus else None),
1459
+ ("DONE", IssueStatus.DONE if IssueStatus else None),
1460
+ ]
1461
+
1462
+ issues: reactive[list] = reactive([])
1463
+ selected_col: reactive[int] = reactive(0)
1464
+ selected_row: reactive[int] = reactive(0)
1465
+ visible: reactive[bool] = reactive(True)
1466
+
1467
+ def __init__(self, provider=None, agent_id: str = "", **kwargs):
1468
+ super().__init__(**kwargs)
1469
+ self.provider = provider
1470
+ self.agent_id = agent_id
1471
+
1472
+ def compose(self) -> ComposeResult:
1473
+ yield Static(id="kanban-content")
1474
+
1475
+ def watch_issues(self, issues: list) -> None:
1476
+ """Update display when issues change."""
1477
+ self._refresh_display()
1478
+
1479
+ def watch_selected_col(self, col: int) -> None:
1480
+ """Update display when selection changes."""
1481
+ self._refresh_display()
1482
+
1483
+ def watch_selected_row(self, row: int) -> None:
1484
+ """Update display when selection changes."""
1485
+ self._refresh_display()
1486
+
1487
+ def watch_visible(self, visible: bool) -> None:
1488
+ """Update visibility."""
1489
+ self.display = visible
1490
+
1491
+ def _get_issues_by_status(self, status) -> list:
1492
+ """Get issues filtered by status."""
1493
+ if not self.issues or status is None:
1494
+ return []
1495
+ return [i for i in self.issues if i.status == status]
1496
+
1497
+ def _get_selected_issue(self):
1498
+ """Get currently selected issue."""
1499
+ if not IssueStatus:
1500
+ return None
1501
+ _, status = self.COLUMNS[self.selected_col]
1502
+ col_issues = self._get_issues_by_status(status)
1503
+ if 0 <= self.selected_row < len(col_issues):
1504
+ return col_issues[self.selected_row]
1505
+ return None
1506
+
1507
+ def _refresh_display(self) -> None:
1508
+ """Refresh the kanban board display."""
1509
+ content = self.query_one("#kanban-content", Static)
1510
+
1511
+ if not IssueStatus:
1512
+ content.update(Text("Issue tracking not available", style="dim"))
1513
+ return
1514
+
1515
+ text = Text()
1516
+ text.append("KANBAN BOARD", style="bold white")
1517
+ if self.agent_id:
1518
+ text.append(f" [{self.agent_id[:10]}]", style="dim cyan")
1519
+ text.append("\n")
1520
+ text.append("─" * 72 + "\n", style="dim")
1521
+ text.append("Navigation: h/l=columns j/k=issues c=claim m/M=move n=new Enter=details\n\n", style="dim")
1522
+
1523
+ # Get issues per column
1524
+ columns_data = []
1525
+ for name, status in self.COLUMNS:
1526
+ col_issues = self._get_issues_by_status(status)
1527
+ columns_data.append((name, col_issues))
1528
+
1529
+ # Calculate column width
1530
+ col_width = 22
1531
+
1532
+ # Header row
1533
+ for i, (name, col_issues) in enumerate(columns_data):
1534
+ is_selected = (i == self.selected_col)
1535
+ header = f" {name} ({len(col_issues)}) "
1536
+ if is_selected:
1537
+ text.append(f"┌{'─' * col_width}┐ ", style="cyan bold")
1538
+ else:
1539
+ text.append(f"┌{'─' * col_width}┐ ", style="dim")
1540
+ text.append("\n")
1541
+
1542
+ # Column headers
1543
+ for i, (name, col_issues) in enumerate(columns_data):
1544
+ is_selected = (i == self.selected_col)
1545
+ header = f"{name} ({len(col_issues)})"
1546
+ header = header[:col_width].center(col_width)
1547
+ if is_selected:
1548
+ text.append(f"│{header}│ ", style="cyan bold")
1549
+ else:
1550
+ text.append(f"│{header}│ ", style="dim white")
1551
+ text.append("\n")
1552
+
1553
+ # Separator
1554
+ for i, _ in enumerate(columns_data):
1555
+ is_selected = (i == self.selected_col)
1556
+ if is_selected:
1557
+ text.append(f"├{'─' * col_width}┤ ", style="cyan bold")
1558
+ else:
1559
+ text.append(f"├{'─' * col_width}┤ ", style="dim")
1560
+ text.append("\n")
1561
+
1562
+ # Find max rows needed
1563
+ max_rows = max(len(col[1]) for col in columns_data) if columns_data else 0
1564
+ max_rows = max(max_rows, 3) # Show at least 3 rows
1565
+
1566
+ # Issue rows
1567
+ for row in range(min(max_rows, 8)): # Limit to 8 rows
1568
+ for col_idx, (name, col_issues) in enumerate(columns_data):
1569
+ is_col_selected = (col_idx == self.selected_col)
1570
+ is_row_selected = (row == self.selected_row)
1571
+ is_selected = is_col_selected and is_row_selected
1572
+
1573
+ if row < len(col_issues):
1574
+ issue = col_issues[row]
1575
+ # Format issue card
1576
+ identifier = issue.identifier[:10]
1577
+ title = issue.title[:col_width - 4]
1578
+
1579
+ # Priority indicator
1580
+ pri = "!" if issue.priority.value <= 1 else " "
1581
+
1582
+ # Agent indicator
1583
+ agent = "🤖" if issue.assigned_agent else " "
1584
+
1585
+ # Build card content
1586
+ line1 = f"{pri}{identifier} {agent}"
1587
+ line1 = line1[:col_width].ljust(col_width)
1588
+
1589
+ if is_selected:
1590
+ text.append(f"│", style="cyan bold")
1591
+ text.append(f"▸{line1[1:]}", style="cyan bold reverse")
1592
+ text.append(f"│ ", style="cyan bold")
1593
+ elif is_col_selected:
1594
+ text.append(f"│{line1}│ ", style="white")
1595
+ else:
1596
+ text.append(f"│{line1}│ ", style="dim")
1597
+ else:
1598
+ # Empty cell
1599
+ empty = " " * col_width
1600
+ if is_col_selected:
1601
+ text.append(f"│{empty}│ ", style="cyan")
1602
+ else:
1603
+ text.append(f"│{empty}│ ", style="dim")
1604
+ text.append("\n")
1605
+
1606
+ # Bottom border
1607
+ for i, _ in enumerate(columns_data):
1608
+ is_selected = (i == self.selected_col)
1609
+ if is_selected:
1610
+ text.append(f"└{'─' * col_width}┘ ", style="cyan bold")
1611
+ else:
1612
+ text.append(f"└{'─' * col_width}┘ ", style="dim")
1613
+ text.append("\n")
1614
+
1615
+ # Show selected issue details
1616
+ selected = self._get_selected_issue()
1617
+ if selected:
1618
+ text.append("\n")
1619
+ text.append("─" * 72 + "\n", style="dim")
1620
+ text.append(f"Selected: ", style="dim")
1621
+ text.append(f"{selected.identifier}", style="yellow bold")
1622
+ text.append(f" - {selected.title[:50]}\n", style="white")
1623
+ if selected.description:
1624
+ desc = selected.description[:100].replace("\n", " ")
1625
+ text.append(f" {desc}...\n", style="dim")
1626
+ if selected.assigned_agent:
1627
+ text.append(f" Agent: ", style="dim")
1628
+ text.append(f"{selected.assigned_agent}\n", style="cyan")
1629
+ text.append(f" Priority: ", style="dim")
1630
+ text.append(f"{selected.priority.to_display()}\n", style="magenta")
1631
+
1632
+ content.update(text)
1633
+
1634
+ def move_left(self) -> None:
1635
+ """Move selection left."""
1636
+ if self.selected_col > 0:
1637
+ self.selected_col -= 1
1638
+ self.selected_row = 0
1639
+
1640
+ def move_right(self) -> None:
1641
+ """Move selection right."""
1642
+ if self.selected_col < len(self.COLUMNS) - 1:
1643
+ self.selected_col += 1
1644
+ self.selected_row = 0
1645
+
1646
+ def move_up(self) -> None:
1647
+ """Move selection up."""
1648
+ if self.selected_row > 0:
1649
+ self.selected_row -= 1
1650
+
1651
+ def move_down(self) -> None:
1652
+ """Move selection down."""
1653
+ _, status = self.COLUMNS[self.selected_col]
1654
+ col_issues = self._get_issues_by_status(status)
1655
+ if self.selected_row < len(col_issues) - 1:
1656
+ self.selected_row += 1
1657
+
1658
+ def claim_issue(self) -> bool:
1659
+ """Claim selected issue for current agent."""
1660
+ if not self.provider or not self.agent_id:
1661
+ return False
1662
+ issue = self._get_selected_issue()
1663
+ if issue:
1664
+ try:
1665
+ self.provider.assign_to_agent(issue.identifier, self.agent_id)
1666
+ return True
1667
+ except Exception:
1668
+ pass
1669
+ return False
1670
+
1671
+ def move_issue_forward(self) -> bool:
1672
+ """Move selected issue to next status."""
1673
+ if not self.provider:
1674
+ return False
1675
+ issue = self._get_selected_issue()
1676
+ if not issue:
1677
+ return False
1678
+
1679
+ # Status progression
1680
+ next_status = {
1681
+ IssueStatus.BACKLOG: IssueStatus.TODO,
1682
+ IssueStatus.TODO: IssueStatus.IN_PROGRESS,
1683
+ IssueStatus.IN_PROGRESS: IssueStatus.IN_REVIEW,
1684
+ IssueStatus.IN_REVIEW: IssueStatus.DONE,
1685
+ }
1686
+
1687
+ new_status = next_status.get(issue.status)
1688
+ if new_status:
1689
+ try:
1690
+ self.provider.update_issue(issue.identifier, status=new_status)
1691
+ return True
1692
+ except Exception:
1693
+ pass
1694
+ return False
1695
+
1696
+ def move_issue_backward(self) -> bool:
1697
+ """Move selected issue to previous status."""
1698
+ if not self.provider:
1699
+ return False
1700
+ issue = self._get_selected_issue()
1701
+ if not issue:
1702
+ return False
1703
+
1704
+ # Status regression
1705
+ prev_status = {
1706
+ IssueStatus.DONE: IssueStatus.IN_REVIEW,
1707
+ IssueStatus.IN_REVIEW: IssueStatus.IN_PROGRESS,
1708
+ IssueStatus.IN_PROGRESS: IssueStatus.TODO,
1709
+ IssueStatus.TODO: IssueStatus.BACKLOG,
1710
+ }
1711
+
1712
+ new_status = prev_status.get(issue.status)
1713
+ if new_status:
1714
+ try:
1715
+ self.provider.update_issue(issue.identifier, status=new_status)
1716
+ return True
1717
+ except Exception:
1718
+ pass
1719
+ return False
1720
+
1721
+
1722
+ class AgentCard(Static):
1723
+ """Widget displaying a single agent's status."""
1724
+
1725
+ def __init__(self, agent_data: Dict[str, Any], **kwargs):
1726
+ super().__init__(**kwargs)
1727
+ self.agent_data = agent_data
1728
+
1729
+ def compose(self) -> ComposeResult:
1730
+ yield Static(self._render_card())
1731
+
1732
+ def _render_card(self) -> Text:
1733
+ """Render the agent card as Rich Text."""
1734
+ agent = self.agent_data
1735
+ text = Text()
1736
+
1737
+ # Status indicator
1738
+ status = agent.get("status", "active")
1739
+ status_icon = "[green]●[/]" if status == "active" else "[yellow]○[/]"
1740
+
1741
+ # Agent ID and project
1742
+ agent_id = agent.get("id", "unknown")[:20]
1743
+ project_name = agent.get("projectName", "unknown")
1744
+ issue = agent.get("issue", "")
1745
+
1746
+ text.append(f" {status_icon} ", style="bold")
1747
+ text.append(f"{agent_id}\n", style="bold cyan")
1748
+ text.append(f" {project_name}", style="dim")
1749
+ if issue:
1750
+ text.append(f" → {issue}", style="yellow")
1751
+ text.append("\n")
1752
+
1753
+ # Model and context
1754
+ model = agent.get("model", "Claude")
1755
+ context_usage = agent.get("contextUsage", 0)
1756
+ context_limit = agent.get("contextLimit", 200000)
1757
+ context_pct = int((context_usage / context_limit) * 100) if context_limit > 0 else 0
1758
+
1759
+ # Context bar
1760
+ bar_width = 15
1761
+ filled = int(bar_width * context_pct / 100)
1762
+ bar = "█" * filled + "░" * (bar_width - filled)
1763
+
1764
+ # Color based on usage
1765
+ if context_pct < 50:
1766
+ bar_style = "green"
1767
+ elif context_pct < 80:
1768
+ bar_style = "yellow"
1769
+ else:
1770
+ bar_style = "red"
1771
+
1772
+ text.append(f" [{model}] ", style="bold blue")
1773
+ text.append(bar, style=bar_style)
1774
+ text.append(f" {context_pct}%\n", style=bar_style)
1775
+
1776
+ # Phase and cost
1777
+ phase = agent.get("phase", "unknown")
1778
+ cost = agent.get("sessionCost", 0.0)
1779
+
1780
+ text.append(f" Phase: ", style="dim")
1781
+ text.append(f"{phase.upper()}", style="magenta bold")
1782
+ text.append(f" | ${cost:.2f}\n", style="blue")
1783
+
1784
+ # Last activity
1785
+ last_activity = agent.get("lastActivity", "")
1786
+ if last_activity:
1787
+ try:
1788
+ last_dt = datetime.fromisoformat(last_activity.replace('Z', '+00:00'))
1789
+ now = datetime.now(timezone.utc)
1790
+ age_seconds = (now - last_dt).total_seconds()
1791
+
1792
+ if age_seconds < 60:
1793
+ age_str = f"{int(age_seconds)}s ago"
1794
+ elif age_seconds < 3600:
1795
+ age_str = f"{int(age_seconds / 60)}m ago"
1796
+ else:
1797
+ age_str = f"{int(age_seconds / 3600)}h ago"
1798
+
1799
+ text.append(f" Last active: {age_str}\n", style="dim")
1800
+ except (ValueError, TypeError):
1801
+ pass
1802
+
1803
+ return text
1804
+
1805
+
1806
+ class AgentList(ScrollableContainer):
1807
+ """Scrollable list of agent cards."""
1808
+
1809
+ agents: reactive[Dict[str, Any]] = reactive({})
1810
+ show_details: reactive[bool] = reactive(False)
1811
+ detailed_states: reactive[Dict[str, Any]] = reactive({})
1812
+
1813
+ def compose(self) -> ComposeResult:
1814
+ yield Static(id="agent-list-content")
1815
+
1816
+ def watch_agents(self, agents: Dict[str, Any]) -> None:
1817
+ """Update display when agents change."""
1818
+ self._refresh_display()
1819
+
1820
+ def watch_show_details(self, show_details: bool) -> None:
1821
+ """Update display when detail mode changes."""
1822
+ self._refresh_display()
1823
+
1824
+ def _refresh_display(self) -> None:
1825
+ """Refresh the agent list display."""
1826
+ content = self.query_one("#agent-list-content", Static)
1827
+
1828
+ if not self.agents:
1829
+ content.update(Text("No active agents\n\nStart a Claude Code session to see it here.", style="dim"))
1830
+ return
1831
+
1832
+ text = Text()
1833
+ text.append(f"ACTIVE AGENTS ({len(self.agents)})\n", style="bold white")
1834
+ text.append("─" * 36 + "\n\n", style="dim")
1835
+
1836
+ for agent_id, agent in self.agents.items():
1837
+ # Status indicator
1838
+ status = agent.get("status", "active")
1839
+ status_icon = "●" if status == "active" else "○"
1840
+ status_style = "green" if status == "active" else "yellow"
1841
+
1842
+ # Agent ID and project
1843
+ display_id = agent_id[:16] + "..." if len(agent_id) > 16 else agent_id
1844
+ project_name = agent.get("projectName", "unknown")
1845
+ issue = agent.get("issue", "")
1846
+
1847
+ text.append(f" {status_icon} ", style=status_style)
1848
+ text.append(f"{display_id}\n", style="bold cyan")
1849
+ text.append(f" {project_name}", style="dim white")
1850
+ if issue:
1851
+ text.append(f" → {issue}", style="yellow")
1852
+ text.append("\n")
1853
+
1854
+ # Model and context bar
1855
+ model = agent.get("model", "Claude")
1856
+ context_usage = agent.get("contextUsage", 0)
1857
+ context_limit = agent.get("contextLimit", 200000)
1858
+ context_pct = int((context_usage / context_limit) * 100) if context_limit > 0 else 0
1859
+
1860
+ bar_width = 15
1861
+ filled = int(bar_width * context_pct / 100)
1862
+ bar = "█" * filled + "░" * (bar_width - filled)
1863
+
1864
+ if context_pct < 50:
1865
+ bar_style = "green"
1866
+ elif context_pct < 80:
1867
+ bar_style = "yellow"
1868
+ else:
1869
+ bar_style = "red"
1870
+
1871
+ text.append(f" [{model}] ", style="bold blue")
1872
+ text.append(bar, style=bar_style)
1873
+ text.append(f" {context_pct}%", style=bar_style)
1874
+
1875
+ # Show estimated turns until compaction (ANV-98)
1876
+ estimated_turns = agent.get("estimatedTurns")
1877
+ if estimated_turns is not None:
1878
+ if estimated_turns <= 5:
1879
+ text.append(f" (~{estimated_turns}t)", style="red bold")
1880
+ elif estimated_turns <= 15:
1881
+ text.append(f" (~{estimated_turns}t)", style="yellow")
1882
+ else:
1883
+ text.append(f" (~{estimated_turns}t)", style="dim")
1884
+ text.append("\n")
1885
+
1886
+ # Phase and cost
1887
+ phase = agent.get("phase") or "idle"
1888
+ cost = agent.get("sessionCost", 0.0)
1889
+
1890
+ text.append(f" Phase: ", style="dim")
1891
+ text.append(f"{phase.upper()}", style="magenta bold")
1892
+ text.append(f" | ${cost:.2f}\n", style="blue")
1893
+
1894
+ # Last activity
1895
+ last_activity = agent.get("lastActivity", "")
1896
+ if last_activity:
1897
+ try:
1898
+ last_dt = datetime.fromisoformat(last_activity.replace('Z', '+00:00'))
1899
+ now = datetime.now(timezone.utc)
1900
+ age_seconds = (now - last_dt).total_seconds()
1901
+
1902
+ if age_seconds < 60:
1903
+ age_str = f"{int(age_seconds)}s ago"
1904
+ elif age_seconds < 3600:
1905
+ age_str = f"{int(age_seconds / 60)}m ago"
1906
+ else:
1907
+ age_str = f"{int(age_seconds / 3600)}h ago"
1908
+
1909
+ text.append(f" Last: {age_str}\n", style="dim")
1910
+ except (ValueError, TypeError):
1911
+ pass
1912
+
1913
+ # Detailed view: show tool activity from transcript (ANV-126)
1914
+ if self.show_details:
1915
+ self._render_agent_tools(text, agent_id, agent)
1916
+
1917
+ text.append("\n")
1918
+
1919
+ # Aggregate stats
1920
+ text.append("─" * 36 + "\n", style="dim")
1921
+ text.append("AGGREGATE\n", style="bold white")
1922
+
1923
+ total_cost = sum(a.get("sessionCost", 0) for a in self.agents.values())
1924
+ avg_context = (
1925
+ sum(a.get("contextUsage", 0) / a.get("contextLimit", 200000) * 100
1926
+ for a in self.agents.values()) / len(self.agents)
1927
+ if self.agents else 0
1928
+ )
1929
+
1930
+ # Count agents by status
1931
+ active_count = sum(1 for a in self.agents.values() if a.get("status") == "active")
1932
+
1933
+ # Count agents at risk (>80% context)
1934
+ at_risk = sum(1 for a in self.agents.values()
1935
+ if (a.get("contextUsage", 0) / a.get("contextLimit", 200000) * 100) > 80)
1936
+
1937
+ # Phases breakdown
1938
+ phases = {}
1939
+ for a in self.agents.values():
1940
+ phase = a.get("phase") or "idle"
1941
+ phases[phase] = phases.get(phase, 0) + 1
1942
+
1943
+ text.append(f"Active: ", style="dim")
1944
+ text.append(f"{active_count}", style="green bold")
1945
+ if at_risk > 0:
1946
+ text.append(f" | At Risk: ", style="dim")
1947
+ text.append(f"{at_risk}", style="red bold")
1948
+ text.append("\n")
1949
+
1950
+ text.append(f"Total Cost: ", style="dim")
1951
+ text.append(f"${total_cost:.2f}\n", style="blue bold")
1952
+ text.append(f"Avg Context: ", style="dim")
1953
+ text.append(f"{avg_context:.0f}%\n", style="green" if avg_context < 50 else ("yellow" if avg_context < 80 else "red"))
1954
+
1955
+ # Show phases if more than one agent
1956
+ if len(phases) > 0 and len(self.agents) > 1:
1957
+ text.append("Phases: ", style="dim")
1958
+ phase_parts = []
1959
+ for phase, count in sorted(phases.items()):
1960
+ phase_parts.append(f"{phase}:{count}")
1961
+ text.append(" ".join(phase_parts) + "\n", style="magenta")
1962
+
1963
+ content.update(text)
1964
+
1965
+ def _render_agent_tools(self, text: Text, agent_id: str, agent: Dict[str, Any]) -> None:
1966
+ """Render per-agent tool activity from transcript (ANV-126).
1967
+
1968
+ Args:
1969
+ text: Rich Text object to append to
1970
+ agent_id: Agent identifier
1971
+ agent: Agent data dictionary
1972
+ """
1973
+ # Get transcript path from agent data
1974
+ transcript_path = agent.get("transcriptPath", "")
1975
+
1976
+ if not transcript_path or not TranscriptParser:
1977
+ # Fallback to recentTools from detailed_states
1978
+ detailed = self.detailed_states.get(agent_id, {})
1979
+ recent_tools = detailed.get("recentTools", [])
1980
+ if recent_tools:
1981
+ text.append(" ┌─ Recent Tools ─────────\n", style="dim cyan")
1982
+ for tool in recent_tools[:5]:
1983
+ tool_name = tool.get("name", "?")[:12]
1984
+ duration = tool.get("duration")
1985
+ duration_str = f" ({duration}ms)" if duration else ""
1986
+ text.append(f" │ {tool_name}{duration_str}\n", style="dim")
1987
+ text.append(" └─────────────────────────\n", style="dim cyan")
1988
+ return
1989
+
1990
+ try:
1991
+ parser = TranscriptParser(cache_ttl=1.0)
1992
+ activity = parser.parse(transcript_path)
1993
+
1994
+ has_tools = activity.running or activity.tool_counts
1995
+
1996
+ if has_tools:
1997
+ text.append(" ┌─ Tool Activity ────────\n", style="dim cyan")
1998
+
1999
+ # Show running tools
2000
+ for tool in activity.running[:2]:
2001
+ target = tool.target or ""
2002
+ if len(target) > 20:
2003
+ target = "..." + target[-17:]
2004
+ text.append(" │ ", style="dim cyan")
2005
+ text.append("◐ ", style="cyan bold")
2006
+ text.append(f"{tool.name[:10]}", style="white bold")
2007
+ if target:
2008
+ text.append(f" {target}", style="dim")
2009
+ text.append("\n")
2010
+
2011
+ # Show completed tools with counts
2012
+ top_tools = activity.get_top_completed(limit=3)
2013
+ for name, count in top_tools:
2014
+ text.append(" │ ", style="dim cyan")
2015
+ text.append("✓ ", style="green")
2016
+ text.append(f"{name[:10]}", style="white")
2017
+ if count > 1:
2018
+ text.append(f" ×{count}", style="cyan")
2019
+ text.append("\n")
2020
+
2021
+ # Show error count if any
2022
+ if activity.error_count > 0:
2023
+ text.append(" │ ", style="dim cyan")
2024
+ text.append(f"⚠ {activity.error_count} errors\n", style="red")
2025
+
2026
+ text.append(" └─────────────────────────\n", style="dim cyan")
2027
+
2028
+ except Exception:
2029
+ # Graceful degradation - show nothing on error
2030
+ pass
2031
+
2032
+
2033
+ class AnvilHUD(App):
2034
+ """Main TUI application for Anvil HUD."""
2035
+
2036
+ # Track which agents we've already warned about
2037
+ warned_agents: set = set()
2038
+
2039
+ # HUD v3: Mode state (ANV-128)
2040
+ mode: reactive[Literal["focused", "full"]] = reactive("focused")
2041
+ active_tab: reactive[int] = reactive(0)
2042
+ show_help: reactive[bool] = reactive(False) # ANV-131: Help overlay state
2043
+ show_settings: reactive[bool] = reactive(False) # ANV-136: Settings overlay state
2044
+
2045
+ CSS = """
2046
+ Screen {
2047
+ background: $surface;
2048
+ }
2049
+
2050
+ #main-container {
2051
+ width: 100%;
2052
+ height: 100%;
2053
+ padding: 0; /* Removed padding to eliminate empty space at top */
2054
+ }
2055
+
2056
+ #title-row {
2057
+ width: 100%;
2058
+ height: 1;
2059
+ }
2060
+
2061
+ #header-title {
2062
+ text-align: center;
2063
+ text-style: bold;
2064
+ color: $primary;
2065
+ padding: 0 1;
2066
+ }
2067
+
2068
+ #timestamp {
2069
+ text-align: right;
2070
+ color: $text-muted;
2071
+ padding: 0 1;
2072
+ }
2073
+
2074
+ #panels-container {
2075
+ width: 100%;
2076
+ height: 1fr;
2077
+ }
2078
+
2079
+ AgentList {
2080
+ width: 2fr;
2081
+ height: 100%;
2082
+ border: solid $primary;
2083
+ padding: 1;
2084
+ }
2085
+
2086
+ CostPanel {
2087
+ width: 1fr;
2088
+ height: 100%;
2089
+ border: solid $secondary;
2090
+ padding: 1;
2091
+ }
2092
+
2093
+ TaskPanel {
2094
+ width: 1fr;
2095
+ height: 100%;
2096
+ border: solid $warning;
2097
+ padding: 1;
2098
+ }
2099
+
2100
+ QualityPanel {
2101
+ width: 1fr;
2102
+ height: 100%;
2103
+ border: solid $success;
2104
+ padding: 1;
2105
+ }
2106
+
2107
+ CoordinationPanel {
2108
+ width: 1fr;
2109
+ height: 100%;
2110
+ border: solid $error;
2111
+ padding: 1;
2112
+ }
2113
+
2114
+ ToolActivityPanel {
2115
+ width: 1fr;
2116
+ height: 100%;
2117
+ border: solid $accent;
2118
+ padding: 1;
2119
+ }
2120
+
2121
+ KanbanPanel {
2122
+ width: 100%;
2123
+ height: auto;
2124
+ min-height: 20;
2125
+ border: solid $primary;
2126
+ padding: 1;
2127
+ margin-bottom: 1;
2128
+ }
2129
+
2130
+ TabBar {
2131
+ width: 100%;
2132
+ height: 1;
2133
+ background: $primary-background;
2134
+ padding: 0 1;
2135
+ }
2136
+
2137
+ #mode-indicator {
2138
+ text-align: right;
2139
+ color: $text-muted;
2140
+ padding: 0 1;
2141
+ }
2142
+
2143
+ #upper-panel {
2144
+ width: 100%;
2145
+ height: auto;
2146
+ max-height: 50%;
2147
+ }
2148
+
2149
+ /* Upper panel starts hidden - shown when Kanban tab active */
2150
+ #upper-panel.initially-hidden {
2151
+ display: none;
2152
+ height: 0;
2153
+ max-height: 0;
2154
+ }
2155
+
2156
+ /* Also hide with focused-hidden class */
2157
+ #upper-panel.focused-hidden {
2158
+ display: none;
2159
+ height: 0;
2160
+ max-height: 0;
2161
+ }
2162
+
2163
+ #status-bar {
2164
+ dock: bottom;
2165
+ height: 1;
2166
+ background: $primary-background;
2167
+ color: $text;
2168
+ padding: 0 1;
2169
+ }
2170
+
2171
+ /* HUD v3: Focused Mode layout (ANV-128/130) */
2172
+ /* Constraint-based: TitleRow (1) → TabBar (1) → Content (flex) → Footer (1) */
2173
+
2174
+ .focused-mode #panels-container {
2175
+ width: 100%;
2176
+ height: 1fr;
2177
+ }
2178
+
2179
+ .focused-mode #upper-panel {
2180
+ width: 100%;
2181
+ height: 1fr;
2182
+ max-height: 100%;
2183
+ }
2184
+
2185
+ /* Full-width panel styling in Focused Mode */
2186
+ .focused-mode AgentList,
2187
+ .focused-mode CostPanel,
2188
+ .focused-mode QualityPanel,
2189
+ .focused-mode CoordinationPanel,
2190
+ .focused-mode KanbanPanel {
2191
+ width: 100%;
2192
+ height: 100%;
2193
+ }
2194
+
2195
+ /* Active panel gets full viewport in Focused Mode */
2196
+ .focused-panel {
2197
+ width: 100% !important;
2198
+ height: 1fr !important;
2199
+ }
2200
+
2201
+ /* Hidden panels in Focused Mode - use display: none */
2202
+ .focused-hidden {
2203
+ display: none;
2204
+ }
2205
+
2206
+ /* HUD v3: Help Overlay modal styling (ANV-128/131) */
2207
+ HelpOverlay {
2208
+ layer: overlay;
2209
+ width: 100%;
2210
+ height: 100%;
2211
+ background: rgba(0, 0, 0, 0.7);
2212
+ align: center middle;
2213
+ }
2214
+
2215
+ HelpOverlay #help-content {
2216
+ width: auto;
2217
+ height: auto;
2218
+ background: $surface;
2219
+ border: heavy $primary;
2220
+ padding: 1 2;
2221
+ color: $text;
2222
+ }
2223
+
2224
+ /* Settings overlay (ANV-136) */
2225
+ SettingsOverlay {
2226
+ dock: top;
2227
+ layer: overlay;
2228
+ width: 100%;
2229
+ height: 100%;
2230
+ align: center middle;
2231
+ display: none;
2232
+ }
2233
+
2234
+ SettingsOverlay #settings-content {
2235
+ width: auto;
2236
+ height: auto;
2237
+ background: $surface;
2238
+ border: heavy $secondary;
2239
+ padding: 1 2;
2240
+ color: $text;
2241
+ }
2242
+
2243
+ /* Dim main content when help is shown */
2244
+ .help-active #main-container {
2245
+ opacity: 0.3;
2246
+ }
2247
+
2248
+ /* Dim main content when settings is shown */
2249
+ .settings-active #main-container {
2250
+ opacity: 0.3;
2251
+ }
2252
+
2253
+ /* Settings Overlay Layout (ANV-205 Phase 2) */
2254
+ #settings-title {
2255
+ width: 100%;
2256
+ text-align: center;
2257
+ text-style: bold;
2258
+ color: $primary;
2259
+ margin-bottom: 1;
2260
+ }
2261
+
2262
+ #settings-scroll {
2263
+ width: 100%;
2264
+ height: auto;
2265
+ max-height: 20;
2266
+ padding: 0 1;
2267
+ }
2268
+
2269
+ #settings-status {
2270
+ width: 100%;
2271
+ text-align: center;
2272
+ height: 1;
2273
+ margin-top: 1;
2274
+ }
2275
+
2276
+ #settings-footer {
2277
+ width: 100%;
2278
+ text-align: center;
2279
+ color: $text-muted;
2280
+ margin-top: 1;
2281
+ }
2282
+
2283
+ .settings-section-header {
2284
+ width: 100%;
2285
+ text-style: bold;
2286
+ color: $secondary;
2287
+ margin-top: 1;
2288
+ margin-bottom: 0;
2289
+ }
2290
+
2291
+ /* Interactive Settings Widgets (ANV-205) */
2292
+ SettingRow {
2293
+ width: 100%;
2294
+ height: auto;
2295
+ padding: 0 1;
2296
+ margin: 0 0 1 0;
2297
+ }
2298
+
2299
+ SettingRow.focused-row {
2300
+ background: $surface-darken-1;
2301
+ }
2302
+
2303
+ .setting-label {
2304
+ width: 22;
2305
+ color: $text-muted;
2306
+ }
2307
+
2308
+ .setting-field {
2309
+ width: 1fr;
2310
+ min-width: 20;
2311
+ }
2312
+
2313
+ .setting-error {
2314
+ width: auto;
2315
+ color: $error;
2316
+ padding-left: 1;
2317
+ }
2318
+
2319
+ SelectField {
2320
+ width: auto;
2321
+ min-width: 15;
2322
+ }
2323
+
2324
+ NumberField {
2325
+ width: auto;
2326
+ min-width: 10;
2327
+ }
2328
+
2329
+ ToggleField {
2330
+ width: auto;
2331
+ min-width: 12;
2332
+ }
2333
+ """
2334
+
2335
+ BINDINGS = [
2336
+ ("q", "quit", "Quit"),
2337
+ ("r", "refresh", "Refresh"),
2338
+ ("d", "toggle_details", "Details"),
2339
+ ("s", "toggle_settings", "Settings"), # ANV-136: Settings overlay
2340
+ ("?", "toggle_help", "Help"), # ANV-131: Changed to toggle_help
2341
+ ("escape", "close_overlays", ""), # ANV-136: Close any overlay
2342
+ ("ctrl+s", "save_settings", ""), # ANV-205: Save settings
2343
+ # HUD v3: Mode and tab navigation (ANV-128)
2344
+ ("m", "toggle_mode", "Mode"),
2345
+ ("1", "tab_1", ""),
2346
+ ("2", "tab_2", ""),
2347
+ ("3", "tab_3", ""),
2348
+ ("4", "tab_4", ""),
2349
+ ("5", "tab_5", ""),
2350
+ ("tab", "next_tab", ""),
2351
+ ("shift+tab", "prev_tab", ""),
2352
+ # Kanban navigation (when visible)
2353
+ ("h", "kanban_left", ""),
2354
+ ("l", "kanban_right", ""),
2355
+ ("j", "kanban_down", ""),
2356
+ ("ctrl+k", "kanban_up", ""),
2357
+ ("c", "kanban_claim", ""),
2358
+ ("n", "kanban_forward", ""),
2359
+ ("shift+n", "kanban_backward", ""),
2360
+ ]
2361
+
2362
+ def __init__(self, demo_mode: bool = False, project_path: Optional[str] = None, **kwargs):
2363
+ super().__init__(**kwargs)
2364
+
2365
+ # Load configuration (ANV-116-118)
2366
+ # Store config service for hot-reload support (ANV-136)
2367
+ self._config_service = get_config_service(project_path) if get_config_service else None
2368
+ self.config = self._config_service.config if self._config_service else None
2369
+ self._config_reload_counter = 0 # Reload config every N refresh cycles
2370
+ self._pending_settings_close = False # ANV-208: Track if user confirmed discard
2371
+ self.demo_mode = demo_mode or (self.config.demo_mode if self.config else False)
2372
+ self.project_path = project_path
2373
+
2374
+ self.registry: Optional[AgentRegistry] = None
2375
+ self.linear_service: Optional["LinearDataService"] = None
2376
+ self.quality_services: Dict[str, "QualityService"] = {} # project_path -> service
2377
+ self.coordination_service: Optional["CoordinationService"] = None
2378
+ self.github_service: Optional["GitHubService"] = None # ANV-111-113
2379
+ self.coderabbit_service: Optional["CodeRabbitService"] = None # ANV-114-115
2380
+ self.issue_provider = None # ANV-76: Issue provider for Kanban
2381
+ self.refresh_timer: Optional[Timer] = None
2382
+
2383
+ if not self.demo_mode and AgentRegistry:
2384
+ self.registry = AgentRegistry()
2385
+ if not self.demo_mode and LinearDataService:
2386
+ if not self.config or self.config.integrations.enable_linear:
2387
+ self.linear_service = LinearDataService()
2388
+ if not self.demo_mode and CoordinationService:
2389
+ self.coordination_service = CoordinationService()
2390
+ if not self.demo_mode and GitHubService:
2391
+ if not self.config or self.config.integrations.enable_github:
2392
+ self.github_service = GitHubService()
2393
+ if not self.demo_mode and CodeRabbitService:
2394
+ if not self.config or self.config.integrations.enable_coderabbit:
2395
+ self.coderabbit_service = CodeRabbitService()
2396
+ # ANV-76: Initialize issue provider
2397
+ if not self.demo_mode and get_provider:
2398
+ try:
2399
+ self.issue_provider = get_provider(Path(project_path) if project_path else None)
2400
+ except Exception:
2401
+ self.issue_provider = None
2402
+
2403
+ def compose(self) -> ComposeResult:
2404
+ # Removed Header() widget - redundant with title-row and wastes 1-2 lines of vertical space
2405
+ with Container(id="main-container"):
2406
+ with Horizontal(id="title-row"):
2407
+ yield Static("🔧 Anvil HUD", id="header-title")
2408
+ yield Static("", id="timestamp")
2409
+ yield Static("Mode:Focused", id="mode-indicator")
2410
+ # HUD v3: Tab bar for Focused Mode (ANV-128)
2411
+ yield TabBar(id="tab-bar")
2412
+ # ANV-76: Kanban panel (toggled with 'k' in Full Mode, or tab 2 in Focused)
2413
+ with Container(id="upper-panel", classes="initially-hidden"):
2414
+ yield KanbanPanel(
2415
+ provider=self.issue_provider,
2416
+ agent_id=self._get_current_agent_id(),
2417
+ id="kanban-panel"
2418
+ )
2419
+ with Horizontal(id="panels-container"):
2420
+ yield AgentList(id="agent-list")
2421
+ yield CostPanel(id="cost-panel")
2422
+ yield TaskPanel(id="task-panel")
2423
+ yield QualityPanel(id="quality-panel")
2424
+ yield CoordinationPanel(id="coordination-panel")
2425
+ yield ToolActivityPanel(id="tool-activity-panel")
2426
+ # HUD v3: Overlays (ANV-131, ANV-136) - must be last for z-order
2427
+ yield HelpOverlay(id="help-overlay")
2428
+ yield SettingsOverlay(config=self.config, id="settings-overlay")
2429
+ yield Footer()
2430
+
2431
+ def on_mount(self) -> None:
2432
+ """Called when app is mounted."""
2433
+ self.title = "Anvil HUD"
2434
+ self.sub_title = "Multi-Agent Dashboard"
2435
+
2436
+ # HUD v3: Load default mode from config (ANV-128)
2437
+ if self.config and hasattr(self.config, 'hud') and hasattr(self.config.hud, 'default_mode'):
2438
+ self.mode = self.config.hud.default_mode
2439
+ else:
2440
+ self.mode = "focused" # Default to focused mode
2441
+
2442
+ # Initial UI state
2443
+ self._update_mode_ui()
2444
+
2445
+ # Initial refresh
2446
+ self.action_refresh()
2447
+
2448
+ # Start auto-refresh timer (configurable via ANV-116-118)
2449
+ refresh_seconds = 2.0
2450
+ if self.config:
2451
+ refresh_seconds = self.config.refresh.agent_refresh_seconds
2452
+ self.refresh_timer = self.set_interval(refresh_seconds, self.action_refresh)
2453
+
2454
+ def on_key(self, event) -> None:
2455
+ """Forward keys to settings overlay when active (ANV-205)."""
2456
+ if self.show_settings:
2457
+ try:
2458
+ settings = self.query_one("#settings-overlay", SettingsOverlay)
2459
+ if settings.handle_key(event.key):
2460
+ event.stop()
2461
+ event.prevent_default()
2462
+ except Exception:
2463
+ pass
2464
+
2465
+ def watch_mode(self, mode: str) -> None:
2466
+ """Update UI when mode changes (HUD v3 - ANV-128)."""
2467
+ self._update_mode_ui()
2468
+
2469
+ def watch_active_tab(self, tab: int) -> None:
2470
+ """Update UI when active tab changes (HUD v3 - ANV-128)."""
2471
+ if self.mode == "focused":
2472
+ self._update_focused_panels()
2473
+ # Sync TabBar widget
2474
+ try:
2475
+ tab_bar = self.query_one("#tab-bar", TabBar)
2476
+ tab_bar.active_tab = tab
2477
+ except Exception:
2478
+ pass
2479
+
2480
+ def watch_show_help(self, show: bool) -> None:
2481
+ """Update UI when help overlay state changes (HUD v3 - ANV-131)."""
2482
+ try:
2483
+ help_overlay = self.query_one("#help-overlay", HelpOverlay)
2484
+ help_overlay.display = show
2485
+
2486
+ # Add/remove help-active class for dimming effect
2487
+ if show:
2488
+ self.add_class("help-active")
2489
+ else:
2490
+ self.remove_class("help-active")
2491
+ except Exception:
2492
+ pass
2493
+
2494
+ def watch_show_settings(self, show: bool) -> None:
2495
+ """Update UI when settings overlay state changes (ANV-136)."""
2496
+ try:
2497
+ settings_overlay = self.query_one("#settings-overlay", SettingsOverlay)
2498
+ settings_overlay.display = show
2499
+
2500
+ # Refresh config when opening
2501
+ if show:
2502
+ settings_overlay.update_config(self.config)
2503
+
2504
+ # Add/remove settings-active class for dimming effect
2505
+ if show:
2506
+ self.add_class("settings-active")
2507
+ else:
2508
+ self.remove_class("settings-active")
2509
+ except Exception:
2510
+ pass
2511
+
2512
+ def _update_mode_ui(self) -> None:
2513
+ """Update UI elements based on current mode (HUD v3 - ANV-128/130)."""
2514
+ try:
2515
+ # Update mode indicator
2516
+ indicator = self.query_one("#mode-indicator", Static)
2517
+ if self.mode == "focused":
2518
+ indicator.update("Mode:Focused")
2519
+ else:
2520
+ indicator.update("[FULL]")
2521
+
2522
+ # Show/hide TabBar based on mode
2523
+ tab_bar = self.query_one("#tab-bar", TabBar)
2524
+ if self.mode == "focused":
2525
+ tab_bar.display = True
2526
+ tab_bar.styles.height = "1"
2527
+ else:
2528
+ tab_bar.display = False
2529
+ tab_bar.styles.height = "0"
2530
+
2531
+ # Add/remove focused-mode class on Screen for CSS targeting (ANV-130)
2532
+ main_container = self.query_one("#main-container", Container)
2533
+ if self.mode == "focused":
2534
+ main_container.add_class("focused-mode")
2535
+ else:
2536
+ main_container.remove_class("focused-mode")
2537
+
2538
+ # Update panel visibility
2539
+ if self.mode == "focused":
2540
+ self._update_focused_panels()
2541
+ else:
2542
+ self._show_all_panels()
2543
+
2544
+ except Exception:
2545
+ pass # Graceful degradation during initial mount
2546
+
2547
+ def _update_focused_panels(self) -> None:
2548
+ """Show only the active panel in Focused Mode (HUD v3 - ANV-128/130).
2549
+
2550
+ Uses CSS classes for smooth transitions:
2551
+ - .focused-panel: Applied to the active panel for full-width styling
2552
+ - .focused-hidden: Applied to inactive panels to hide them
2553
+
2554
+ Panel mapping:
2555
+ - Tab 0: Agents (AgentList)
2556
+ - Tab 1: Kanban (KanbanPanel)
2557
+ - Tab 2: Quality (QualityPanel)
2558
+ - Tab 3: Costs (CostPanel)
2559
+ - Tab 4: Coordination (CoordinationPanel)
2560
+ """
2561
+ try:
2562
+ # Panel configuration: (id, widget_class, container)
2563
+ # container: "panels" = in panels-container, "upper" = in upper-panel
2564
+ panel_config = [
2565
+ ("agent-list", AgentList, "panels"),
2566
+ ("kanban-panel", KanbanPanel, "upper"),
2567
+ ("quality-panel", QualityPanel, "panels"),
2568
+ ("cost-panel", CostPanel, "panels"),
2569
+ ("coordination-panel", CoordinationPanel, "panels"),
2570
+ ]
2571
+
2572
+ # Update each panel's visibility and CSS classes
2573
+ for i, (panel_id, widget_class, container) in enumerate(panel_config):
2574
+ try:
2575
+ panel = self.query_one(f"#{panel_id}", widget_class)
2576
+ is_active = (i == self.active_tab)
2577
+
2578
+ # Set display and CSS classes
2579
+ panel.display = is_active
2580
+ if is_active:
2581
+ panel.add_class("focused-panel")
2582
+ panel.remove_class("focused-hidden")
2583
+ else:
2584
+ panel.remove_class("focused-panel")
2585
+ panel.add_class("focused-hidden")
2586
+ except Exception:
2587
+ pass
2588
+
2589
+ # Show the appropriate container based on active tab
2590
+ panels_container = self.query_one("#panels-container", Horizontal)
2591
+ upper_panel = self.query_one("#upper-panel", Container)
2592
+
2593
+ if self.active_tab == 1: # Kanban uses upper-panel
2594
+ panels_container.display = False
2595
+ panels_container.add_class("focused-hidden")
2596
+ upper_panel.display = True
2597
+ upper_panel.styles.height = "1fr"
2598
+ upper_panel.styles.max_height = "100%"
2599
+ upper_panel.remove_class("focused-hidden")
2600
+ upper_panel.remove_class("initially-hidden")
2601
+ else: # All other panels use panels-container
2602
+ panels_container.display = True
2603
+ panels_container.remove_class("focused-hidden")
2604
+ upper_panel.display = False
2605
+ upper_panel.styles.height = "0"
2606
+ upper_panel.styles.max_height = "0"
2607
+ upper_panel.add_class("focused-hidden")
2608
+
2609
+ # Always hide auxiliary panels in Focused Mode (they're not in tab list)
2610
+ try:
2611
+ task_panel = self.query_one("#task-panel", TaskPanel)
2612
+ task_panel.display = False
2613
+ task_panel.add_class("focused-hidden")
2614
+ except Exception:
2615
+ pass
2616
+
2617
+ try:
2618
+ tool_panel = self.query_one("#tool-activity-panel", ToolActivityPanel)
2619
+ tool_panel.display = False
2620
+ tool_panel.add_class("focused-hidden")
2621
+ except Exception:
2622
+ pass
2623
+
2624
+ except Exception:
2625
+ pass
2626
+
2627
+ def _show_all_panels(self) -> None:
2628
+ """Show all panels in Full Mode (HUD v3 - ANV-128/130).
2629
+
2630
+ Removes focused-mode CSS classes and restores normal panel visibility.
2631
+ """
2632
+ try:
2633
+ # All panels in the horizontal container
2634
+ panels = [
2635
+ ("#agent-list", AgentList),
2636
+ ("#cost-panel", CostPanel),
2637
+ ("#task-panel", TaskPanel),
2638
+ ("#quality-panel", QualityPanel),
2639
+ ("#coordination-panel", CoordinationPanel),
2640
+ ("#tool-activity-panel", ToolActivityPanel),
2641
+ ]
2642
+
2643
+ for panel_id, widget_class in panels:
2644
+ try:
2645
+ panel = self.query_one(panel_id, widget_class)
2646
+ panel.display = True
2647
+ # Remove focused mode classes
2648
+ panel.remove_class("focused-panel")
2649
+ panel.remove_class("focused-hidden")
2650
+ except Exception:
2651
+ pass
2652
+
2653
+ # Show containers
2654
+ self.query_one("#panels-container", Horizontal).display = True
2655
+
2656
+ # Completely remove upper-panel from DOM in Full Mode
2657
+ # This is the only reliable way to prevent it from taking space
2658
+ try:
2659
+ upper_panel = self.query_one("#upper-panel", Container)
2660
+ upper_panel.remove()
2661
+ except Exception:
2662
+ pass # Already removed
2663
+ except Exception:
2664
+ pass
2665
+
2666
+ def action_refresh(self) -> None:
2667
+ """Refresh agent data."""
2668
+ # Hot-reload config every 5 refresh cycles (~10 seconds) for billing_model changes (ANV-136)
2669
+ self._config_reload_counter += 1
2670
+ if self._config_reload_counter >= 5 and self._config_service:
2671
+ self._config_reload_counter = 0
2672
+ self.config = self._config_service.reload()
2673
+
2674
+ # Update timestamp
2675
+ timestamp = self.query_one("#timestamp", Static)
2676
+ timestamp.update(datetime.now().strftime("%H:%M:%S"))
2677
+
2678
+ # Get agents
2679
+ if self.demo_mode:
2680
+ agents = self._get_demo_agents()
2681
+ elif self.registry:
2682
+ agents = self.registry.get_all()
2683
+ else:
2684
+ agents = {}
2685
+
2686
+ # Update agent list
2687
+ agent_list = self.query_one("#agent-list", AgentList)
2688
+ agent_list.agents = agents
2689
+
2690
+ # Update cost panel (ANV-94)
2691
+ cost_panel = self.query_one("#cost-panel", CostPanel)
2692
+ cost_panel.agents = agents
2693
+
2694
+ # Update task panel with Linear issue data (ANV-100/101/102)
2695
+ task_panel = self.query_one("#task-panel", TaskPanel)
2696
+ task_panel.agents = agents
2697
+ if self.demo_mode:
2698
+ task_panel.issues = self._get_demo_issues()
2699
+ elif self.linear_service:
2700
+ task_panel.issues = self.linear_service.get_issues_for_agents(agents)
2701
+
2702
+ # Update quality panel (ANV-103/104/105)
2703
+ quality_panel = self.query_one("#quality-panel", QualityPanel)
2704
+ quality_panel.agents = agents
2705
+ if self.demo_mode:
2706
+ quality_panel.quality_results = self._get_demo_quality()
2707
+ elif QualityService:
2708
+ quality_results = {}
2709
+ for agent_id, agent in agents.items():
2710
+ project = agent.get("project")
2711
+ if project and project not in quality_results:
2712
+ # Get or create quality service for this project
2713
+ if project not in self.quality_services:
2714
+ self.quality_services[project] = QualityService(project)
2715
+ # Get cached results (runs checks if cache expired)
2716
+ quality_results[project] = self.quality_services[project].get_results()
2717
+ quality_panel.quality_results = quality_results
2718
+
2719
+ # Update GitHub/CI status (ANV-111-113)
2720
+ if self.demo_mode:
2721
+ quality_panel.github_status = self._get_demo_github_status()
2722
+ elif self.github_service:
2723
+ quality_panel.github_status = self.github_service.get_status_for_agents(agents)
2724
+
2725
+ # Update CodeRabbit status (ANV-114-115)
2726
+ if self.demo_mode:
2727
+ quality_panel.coderabbit_status = self._get_demo_coderabbit_status()
2728
+ elif self.coderabbit_service and quality_panel.github_status:
2729
+ # Get CodeRabbit status for PRs (uses GitHub PR numbers)
2730
+ quality_panel.coderabbit_status = self.coderabbit_service.get_status_for_prs(
2731
+ quality_panel.github_status
2732
+ )
2733
+
2734
+ # Update coordination panel (ANV-106-110)
2735
+ coordination_panel = self.query_one("#coordination-panel", CoordinationPanel)
2736
+ coordination_panel.agents = agents
2737
+ if self.demo_mode:
2738
+ coordination_panel.coordination = self._get_demo_coordination()
2739
+ elif self.coordination_service:
2740
+ # Update coordination service with agent data including detailed states
2741
+ agents_with_details = {}
2742
+ for agent_id, agent in agents.items():
2743
+ agent_copy = dict(agent)
2744
+ if self.registry:
2745
+ detailed = self.registry.get_detailed_state(agent_id)
2746
+ if detailed:
2747
+ agent_copy["detailed"] = detailed
2748
+ agents_with_details[agent_id] = agent_copy
2749
+ coordination_panel.coordination = self.coordination_service.update_from_registry(agents_with_details)
2750
+
2751
+ # Also refresh detailed states if in detailed view
2752
+ if agent_list.show_details and self.registry:
2753
+ detailed_states = {}
2754
+ for agent_id in agents.keys():
2755
+ state = self.registry.get_detailed_state(agent_id)
2756
+ if state:
2757
+ detailed_states[agent_id] = state
2758
+ agent_list.detailed_states = detailed_states
2759
+
2760
+ # Update tool activity panel (ANV-125)
2761
+ tool_panel = self.query_one("#tool-activity-panel", ToolActivityPanel)
2762
+ if self.demo_mode:
2763
+ tool_panel.tool_activity = self._get_demo_tool_activity()
2764
+ elif TranscriptParser:
2765
+ # Aggregate tool activity from all agents' transcripts
2766
+ tool_activity = self._aggregate_tool_activity(agents)
2767
+ tool_panel.tool_activity = tool_activity
2768
+
2769
+ # ANV-76: Refresh Kanban panel if visible
2770
+ try:
2771
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
2772
+ if kanban.visible:
2773
+ self._refresh_kanban()
2774
+ except Exception:
2775
+ pass
2776
+
2777
+ # Check for context warnings (only notify once per agent)
2778
+ self._check_context_warnings(agents)
2779
+
2780
+ # Check for cost/budget alerts (ANV-95)
2781
+ self._check_budget_alerts(agents)
2782
+
2783
+ def _cleanup_warned_agents(self, agents: Dict[str, Any]) -> None:
2784
+ """Clean up warnings for agents no longer present.
2785
+
2786
+ Handles all warning suffixes:
2787
+ - Context warnings: agent_id_warn, agent_id_crit
2788
+ - Cost warnings: agent_id_cost_warn, agent_id_cost_crit
2789
+ - Total warnings: total_cost_warn, total_cost_crit
2790
+ """
2791
+ current_ids = set(agents.keys())
2792
+
2793
+ # Known suffixes to strip (order matters - longest first)
2794
+ suffixes = ['_cost_crit', '_cost_warn', '_crit', '_warn']
2795
+
2796
+ def extract_agent_id(warning_key: str) -> str:
2797
+ """Extract agent ID from warning key by stripping known suffixes."""
2798
+ for suffix in suffixes:
2799
+ if warning_key.endswith(suffix):
2800
+ return warning_key[:-len(suffix)]
2801
+ return warning_key
2802
+
2803
+ # Keep warnings if:
2804
+ # 1. It's a "total" warning (applies to all agents collectively)
2805
+ # 2. The extracted agent_id is still in current_ids
2806
+ self.warned_agents = {
2807
+ w for w in self.warned_agents
2808
+ if w.startswith('total_') or extract_agent_id(w) in current_ids
2809
+ }
2810
+
2811
+ def _check_context_warnings(self, agents: Dict[str, Any]) -> None:
2812
+ """Check for high context agents and notify (configurable via ANV-116-118)."""
2813
+ # Context thresholds from config or defaults
2814
+ if self.config:
2815
+ CONTEXT_WARN_PCT = self.config.context.warn_threshold_pct
2816
+ CONTEXT_CRIT_PCT = self.config.context.crit_threshold_pct
2817
+ else:
2818
+ CONTEXT_WARN_PCT = 80
2819
+ CONTEXT_CRIT_PCT = 90
2820
+
2821
+ for agent_id, agent in agents.items():
2822
+ context_usage = agent.get("contextUsage", 0)
2823
+ context_limit = agent.get("contextLimit", 200000)
2824
+ context_pct = (context_usage / context_limit * 100) if context_limit > 0 else 0
2825
+
2826
+ # Warn at configurable thresholds
2827
+ if context_pct >= CONTEXT_CRIT_PCT and f"{agent_id}_crit" not in self.warned_agents:
2828
+ project = agent.get("projectName", "unknown")
2829
+ self.notify(
2830
+ f"⚠️ {project} at {context_pct:.0f}% context!",
2831
+ title="Critical Context",
2832
+ severity="error",
2833
+ timeout=10
2834
+ )
2835
+ self.warned_agents.add(f"{agent_id}_crit")
2836
+ elif context_pct >= CONTEXT_WARN_PCT and f"{agent_id}_warn" not in self.warned_agents:
2837
+ project = agent.get("projectName", "unknown")
2838
+ self.notify(
2839
+ f"{project} at {context_pct:.0f}% context",
2840
+ title="High Context",
2841
+ severity="warning",
2842
+ timeout=5
2843
+ )
2844
+ self.warned_agents.add(f"{agent_id}_warn")
2845
+
2846
+ # Clean up warnings for agents no longer present
2847
+ # Note: This also cleans up cost warnings which use double suffixes
2848
+ # (e.g., agent123_cost_warn, agent123_cost_crit)
2849
+ self._cleanup_warned_agents(agents)
2850
+
2851
+ def _check_budget_alerts(self, agents: Dict[str, Any]) -> None:
2852
+ """Check for cost threshold warnings (ANV-95, configurable via ANV-116-118).
2853
+
2854
+ Respects billing_model config:
2855
+ - "api": Show cost alerts (pay-per-use API billing)
2856
+ - "subscription": Skip cost alerts (Pro/Max flat-rate subscription)
2857
+ """
2858
+ # Skip cost alerts for subscription users (Pro/Max flat rate)
2859
+ # Costs are still tracked and displayed, just no alerts
2860
+ billing_model = "api"
2861
+ if self.config:
2862
+ billing_model = getattr(self.config.budget, "billing_model", "api")
2863
+
2864
+ if billing_model == "subscription":
2865
+ return # No cost alerts for subscription users
2866
+
2867
+ # Budget thresholds from config or defaults
2868
+ if self.config:
2869
+ AGENT_WARN_THRESHOLD = self.config.budget.agent_warn_threshold
2870
+ AGENT_CRIT_THRESHOLD = self.config.budget.agent_crit_threshold
2871
+ TOTAL_WARN_THRESHOLD = self.config.budget.total_warn_threshold
2872
+ TOTAL_CRIT_THRESHOLD = self.config.budget.total_crit_threshold
2873
+ else:
2874
+ AGENT_WARN_THRESHOLD = 5.0 # Warn when single agent exceeds $5
2875
+ AGENT_CRIT_THRESHOLD = 10.0 # Critical when single agent exceeds $10
2876
+ TOTAL_WARN_THRESHOLD = 15.0 # Warn when total exceeds $15
2877
+ TOTAL_CRIT_THRESHOLD = 25.0 # Critical when total exceeds $25
2878
+
2879
+ # Check individual agent costs
2880
+ for agent_id, agent in agents.items():
2881
+ cost = agent.get("sessionCost", 0)
2882
+ project = agent.get("projectName", "unknown")
2883
+
2884
+ if cost >= AGENT_CRIT_THRESHOLD and f"{agent_id}_cost_crit" not in self.warned_agents:
2885
+ self.notify(
2886
+ f"💰 {project}: ${cost:.2f} spent!",
2887
+ title="High Cost Alert",
2888
+ severity="error",
2889
+ timeout=10
2890
+ )
2891
+ self.warned_agents.add(f"{agent_id}_cost_crit")
2892
+ elif cost >= AGENT_WARN_THRESHOLD and f"{agent_id}_cost_warn" not in self.warned_agents:
2893
+ self.notify(
2894
+ f"{project}: ${cost:.2f} session cost",
2895
+ title="Cost Warning",
2896
+ severity="warning",
2897
+ timeout=5
2898
+ )
2899
+ self.warned_agents.add(f"{agent_id}_cost_warn")
2900
+
2901
+ # Check total cost across all agents
2902
+ total_cost = sum(a.get("sessionCost", 0) for a in agents.values())
2903
+
2904
+ if total_cost >= TOTAL_CRIT_THRESHOLD and "total_cost_crit" not in self.warned_agents:
2905
+ self.notify(
2906
+ f"💰 Total spend: ${total_cost:.2f}!",
2907
+ title="Budget Critical",
2908
+ severity="error",
2909
+ timeout=15
2910
+ )
2911
+ self.warned_agents.add("total_cost_crit")
2912
+ elif total_cost >= TOTAL_WARN_THRESHOLD and "total_cost_warn" not in self.warned_agents:
2913
+ self.notify(
2914
+ f"Total spend: ${total_cost:.2f}",
2915
+ title="Budget Warning",
2916
+ severity="warning",
2917
+ timeout=10
2918
+ )
2919
+ self.warned_agents.add("total_cost_warn")
2920
+
2921
+ def action_toggle_details(self) -> None:
2922
+ """Toggle detailed view - shows tool activity per agent."""
2923
+ try:
2924
+ agent_list = self.query_one("#agent-list", AgentList)
2925
+ agent_list.show_details = not agent_list.show_details
2926
+
2927
+ if agent_list.show_details:
2928
+ # Load detailed states for all agents
2929
+ if self.registry:
2930
+ detailed_states = {}
2931
+ for agent_id in agent_list.agents.keys():
2932
+ state = self.registry.get_detailed_state(agent_id)
2933
+ if state:
2934
+ detailed_states[agent_id] = state
2935
+ agent_list.detailed_states = detailed_states
2936
+ self.notify("Details ON - tool activity shown", title="Details", severity="information", timeout=3)
2937
+ else:
2938
+ agent_list.detailed_states = {}
2939
+ self.notify("Details OFF", title="Details", timeout=2)
2940
+ except Exception as e:
2941
+ self.notify(f"Details error: {e}", title="Error", severity="error")
2942
+
2943
+ def action_toggle_help(self) -> None:
2944
+ """Toggle help overlay (HUD v3 - ANV-131)."""
2945
+ # Close settings if open
2946
+ if self.show_settings:
2947
+ self.show_settings = False
2948
+ self.show_help = not self.show_help
2949
+
2950
+ def action_toggle_settings(self) -> None:
2951
+ """Toggle settings overlay (ANV-136)."""
2952
+ # Close help if open
2953
+ if self.show_help:
2954
+ self.show_help = False
2955
+ self.show_settings = not self.show_settings
2956
+
2957
+ def action_close_overlays(self) -> None:
2958
+ """Close any open overlay (ANV-136, ANV-208: confirm discard on dirty settings)."""
2959
+ if self.show_help:
2960
+ self.show_help = False
2961
+ return
2962
+ if self.show_settings:
2963
+ try:
2964
+ settings = self.query_one("#settings-overlay", SettingsOverlay)
2965
+ if settings.is_dirty:
2966
+ # ANV-208: Require confirmation before discarding changes
2967
+ if not self._pending_settings_close:
2968
+ # First Escape: warn user, set pending flag
2969
+ self._pending_settings_close = True
2970
+ self.notify(
2971
+ "Press Escape again to discard changes, or Ctrl+S to save",
2972
+ title="Unsaved Changes",
2973
+ severity="warning",
2974
+ )
2975
+ return # Don't close yet
2976
+ else:
2977
+ # Second Escape: actually discard
2978
+ settings.cancel()
2979
+ self.notify("Changes discarded", title="Settings")
2980
+ self._pending_settings_close = False
2981
+ except Exception:
2982
+ pass
2983
+ self._pending_settings_close = False
2984
+ self.show_settings = False
2985
+
2986
+ def action_save_settings(self) -> None:
2987
+ """Save settings from overlay (ANV-208)."""
2988
+ if self.show_settings:
2989
+ try:
2990
+ settings = self.query_one("#settings-overlay", SettingsOverlay)
2991
+ if settings.save():
2992
+ self._pending_settings_close = False # Reset confirmation state
2993
+ self.notify("Settings saved!", title="Settings")
2994
+ except Exception as e:
2995
+ self.notify(f"Save failed: {e}", title="Error", severity="error")
2996
+
2997
+ # HUD v3: Mode and tab navigation actions (ANV-128)
2998
+ def action_toggle_mode(self) -> None:
2999
+ """Toggle between Focused and Full mode."""
3000
+ if self.mode == "focused":
3001
+ self.mode = "full"
3002
+ self.notify("Full Mode - all panels visible", title="Mode")
3003
+ else:
3004
+ self.mode = "focused"
3005
+ self.notify("Focused Mode - use 1-5 or Tab to switch", title="Mode")
3006
+
3007
+ def action_tab_1(self) -> None:
3008
+ """Switch to tab 1 (Agents)."""
3009
+ self._switch_to_tab(0)
3010
+
3011
+ def action_tab_2(self) -> None:
3012
+ """Switch to tab 2 (Kanban)."""
3013
+ self._switch_to_tab(1)
3014
+
3015
+ def action_tab_3(self) -> None:
3016
+ """Switch to tab 3 (Quality)."""
3017
+ self._switch_to_tab(2)
3018
+
3019
+ def action_tab_4(self) -> None:
3020
+ """Switch to tab 4 (Costs)."""
3021
+ self._switch_to_tab(3)
3022
+
3023
+ def action_tab_5(self) -> None:
3024
+ """Switch to tab 5 (Coordination)."""
3025
+ self._switch_to_tab(4)
3026
+
3027
+ def action_next_tab(self) -> None:
3028
+ """Move to next tab."""
3029
+ if self.mode == "focused":
3030
+ self.active_tab = (self.active_tab + 1) % 5
3031
+ else:
3032
+ # In Full Mode, pressing Tab switches to Focused Mode on next tab
3033
+ self.active_tab = (self.active_tab + 1) % 5
3034
+ self.mode = "focused"
3035
+
3036
+ def action_prev_tab(self) -> None:
3037
+ """Move to previous tab."""
3038
+ if self.mode == "focused":
3039
+ self.active_tab = (self.active_tab - 1) % 5
3040
+ else:
3041
+ # In Full Mode, pressing Shift+Tab switches to Focused Mode on prev tab
3042
+ self.active_tab = (self.active_tab - 1) % 5
3043
+ self.mode = "focused"
3044
+
3045
+ def _switch_to_tab(self, tab: int) -> None:
3046
+ """Switch to a specific tab (0-4)."""
3047
+ if self.mode == "full":
3048
+ # In Full Mode, pressing 1-5 switches to Focused Mode on that tab
3049
+ self.mode = "focused"
3050
+ self.active_tab = tab
3051
+
3052
+ # Refresh Kanban data if switching to Kanban tab
3053
+ if tab == 1:
3054
+ self._refresh_kanban()
3055
+
3056
+ def _get_current_agent_id(self) -> str:
3057
+ """Get current agent ID from registry or generate one."""
3058
+ if self.registry:
3059
+ agents = self.registry.get_all()
3060
+ if agents:
3061
+ # Return first active agent
3062
+ return next(iter(agents.keys()), "")
3063
+ return ""
3064
+
3065
+ def action_toggle_kanban(self) -> None:
3066
+ """Toggle Kanban panel visibility."""
3067
+ try:
3068
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
3069
+ kanban.visible = not kanban.visible
3070
+ if kanban.visible:
3071
+ self.notify("Kanban ON (h/l/j/k to navigate)", title="Kanban")
3072
+ # Refresh issues when showing
3073
+ self._refresh_kanban()
3074
+ else:
3075
+ self.notify("Kanban OFF", title="Kanban")
3076
+ except Exception:
3077
+ pass
3078
+
3079
+ def _refresh_kanban(self) -> None:
3080
+ """Refresh Kanban panel issues."""
3081
+ try:
3082
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
3083
+ if self.demo_mode:
3084
+ kanban.issues = self._get_demo_kanban_issues()
3085
+ elif self.issue_provider:
3086
+ try:
3087
+ kanban.issues = self.issue_provider.list_issues(limit=50)
3088
+ except Exception:
3089
+ kanban.issues = []
3090
+ except Exception:
3091
+ pass
3092
+
3093
+ def _get_demo_kanban_issues(self) -> list:
3094
+ """Generate demo issues for Kanban board."""
3095
+ if not Issue or not IssueStatus or not Priority:
3096
+ return []
3097
+
3098
+ from datetime import datetime, timezone
3099
+ now = datetime.now(timezone.utc)
3100
+
3101
+ return [
3102
+ Issue(
3103
+ id="demo-1", identifier="ANV-42", title="Implement OAuth flow",
3104
+ description="Add OAuth2 authentication", status=IssueStatus.IN_PROGRESS,
3105
+ priority=Priority.HIGH, created_at=now, updated_at=now,
3106
+ provider="demo", assigned_agent="agent-alpha"
3107
+ ),
3108
+ Issue(
3109
+ id="demo-2", identifier="ANV-43", title="Add unit tests",
3110
+ description="Test payment module", status=IssueStatus.TODO,
3111
+ priority=Priority.MEDIUM, created_at=now, updated_at=now,
3112
+ provider="demo"
3113
+ ),
3114
+ Issue(
3115
+ id="demo-3", identifier="ANV-44", title="Fix login bug",
3116
+ description="Users can't log in", status=IssueStatus.TODO,
3117
+ priority=Priority.URGENT, created_at=now, updated_at=now,
3118
+ provider="demo"
3119
+ ),
3120
+ Issue(
3121
+ id="demo-4", identifier="ANV-41", title="Update docs",
3122
+ description="API documentation", status=IssueStatus.DONE,
3123
+ priority=Priority.LOW, created_at=now, updated_at=now,
3124
+ provider="demo"
3125
+ ),
3126
+ Issue(
3127
+ id="demo-5", identifier="ANV-40", title="Refactor utils",
3128
+ description="Clean up utility functions", status=IssueStatus.IN_PROGRESS,
3129
+ priority=Priority.MEDIUM, created_at=now, updated_at=now,
3130
+ provider="demo", assigned_agent="agent-beta"
3131
+ ),
3132
+ ]
3133
+
3134
+ def action_kanban_left(self) -> None:
3135
+ """Move Kanban selection left."""
3136
+ try:
3137
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
3138
+ if kanban.visible:
3139
+ kanban.move_left()
3140
+ except Exception:
3141
+ pass
3142
+
3143
+ def action_kanban_right(self) -> None:
3144
+ """Move Kanban selection right."""
3145
+ try:
3146
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
3147
+ if kanban.visible:
3148
+ kanban.move_right()
3149
+ except Exception:
3150
+ pass
3151
+
3152
+ def action_kanban_up(self) -> None:
3153
+ """Move Kanban selection up."""
3154
+ try:
3155
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
3156
+ if kanban.visible:
3157
+ kanban.move_up()
3158
+ except Exception:
3159
+ pass
3160
+
3161
+ def action_kanban_down(self) -> None:
3162
+ """Move Kanban selection down."""
3163
+ try:
3164
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
3165
+ if kanban.visible:
3166
+ kanban.move_down()
3167
+ except Exception:
3168
+ pass
3169
+
3170
+ def action_kanban_claim(self) -> None:
3171
+ """Claim selected issue."""
3172
+ try:
3173
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
3174
+ if kanban.visible:
3175
+ if kanban.claim_issue():
3176
+ self.notify("Issue claimed!", title="Kanban")
3177
+ self._refresh_kanban()
3178
+ else:
3179
+ self.notify("Could not claim issue", title="Kanban", severity="warning")
3180
+ except Exception:
3181
+ pass
3182
+
3183
+ def action_kanban_forward(self) -> None:
3184
+ """Move selected issue forward."""
3185
+ try:
3186
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
3187
+ if kanban.visible:
3188
+ if kanban.move_issue_forward():
3189
+ self.notify("Issue moved forward", title="Kanban")
3190
+ self._refresh_kanban()
3191
+ else:
3192
+ self.notify("Could not move issue", title="Kanban", severity="warning")
3193
+ except Exception:
3194
+ pass
3195
+
3196
+ def action_kanban_backward(self) -> None:
3197
+ """Move selected issue backward."""
3198
+ try:
3199
+ kanban = self.query_one("#kanban-panel", KanbanPanel)
3200
+ if kanban.visible:
3201
+ if kanban.move_issue_backward():
3202
+ self.notify("Issue moved back", title="Kanban")
3203
+ self._refresh_kanban()
3204
+ else:
3205
+ self.notify("Could not move issue", title="Kanban", severity="warning")
3206
+ except Exception:
3207
+ pass
3208
+
3209
+ def _get_demo_issues(self) -> Dict[str, Any]:
3210
+ """Generate demo issue data for testing."""
3211
+ return {
3212
+ "ANV-42": {
3213
+ "id": "demo-42",
3214
+ "identifier": "ANV-42",
3215
+ "title": "Implement user authentication with OAuth",
3216
+ "state": "In Progress",
3217
+ "state_type": "started",
3218
+ "assignee": "Demo User",
3219
+ "priority": 1,
3220
+ "labels": ["feature", "auth"],
3221
+ },
3222
+ "ANV-43": {
3223
+ "id": "demo-43",
3224
+ "identifier": "ANV-43",
3225
+ "title": "Add unit tests for payment module",
3226
+ "state": "In Progress",
3227
+ "state_type": "started",
3228
+ "assignee": None,
3229
+ "priority": 2,
3230
+ "labels": ["testing"],
3231
+ },
3232
+ }
3233
+
3234
+ def _get_demo_quality(self) -> Dict[str, Any]:
3235
+ """Generate demo quality gate data for testing."""
3236
+ return {
3237
+ "/Users/demo/projects/auth-service": {
3238
+ "project_path": "/Users/demo/projects/auth-service",
3239
+ "branch": "feature/oauth-flow",
3240
+ "tests": {
3241
+ "status": "passed",
3242
+ "passed": 156,
3243
+ "failed": 0,
3244
+ "warnings": 0,
3245
+ "errors": 0,
3246
+ "message": "156 passed",
3247
+ },
3248
+ "lint": {
3249
+ "status": "passed",
3250
+ "errors": 0,
3251
+ "warnings": 2,
3252
+ "message": "0 errors, 2 warnings",
3253
+ },
3254
+ "types": {
3255
+ "status": "passed",
3256
+ "errors": 0,
3257
+ "message": "No errors",
3258
+ },
3259
+ "ready_to_merge": True,
3260
+ "blocking_issues": 0,
3261
+ },
3262
+ "/Users/demo/projects/payment-api": {
3263
+ "project_path": "/Users/demo/projects/payment-api",
3264
+ "branch": "feature/unit-tests",
3265
+ "tests": {
3266
+ "status": "failed",
3267
+ "passed": 47,
3268
+ "failed": 3,
3269
+ "warnings": 0,
3270
+ "errors": 3,
3271
+ "message": "47 passed, 3 failed",
3272
+ },
3273
+ "lint": {
3274
+ "status": "passed",
3275
+ "errors": 0,
3276
+ "warnings": 0,
3277
+ "message": "0 errors, 0 warnings",
3278
+ },
3279
+ "types": {
3280
+ "status": "failed",
3281
+ "errors": 2,
3282
+ "message": "2 errors",
3283
+ },
3284
+ "ready_to_merge": False,
3285
+ "blocking_issues": 5,
3286
+ },
3287
+ }
3288
+
3289
+ def _get_demo_coordination(self) -> Dict[str, Any]:
3290
+ """Generate demo coordination data for testing."""
3291
+ now = datetime.now(timezone.utc).isoformat()
3292
+ return {
3293
+ "file_locks": {
3294
+ "src/auth/*": {
3295
+ "agent_id": "agent-alpha-demo",
3296
+ "agent_display": "agent-alpha",
3297
+ "file_path": "/Users/demo/projects/auth-service/src/auth/oauth.ts",
3298
+ "pattern": "src/auth/*",
3299
+ "since": now,
3300
+ "operation": "Edit",
3301
+ "duration_seconds": 900, # 15 minutes
3302
+ },
3303
+ "src/payments/*": {
3304
+ "agent_id": "agent-beta-demo",
3305
+ "agent_display": "agent-beta",
3306
+ "file_path": "/Users/demo/projects/payment-api/src/payments/stripe.ts",
3307
+ "pattern": "src/payments/*",
3308
+ "since": now,
3309
+ "operation": "Edit",
3310
+ "duration_seconds": 480, # 8 minutes
3311
+ },
3312
+ },
3313
+ "conflicts": [
3314
+ {
3315
+ "file_path": "/Users/demo/projects/shared/src/utils.ts",
3316
+ "pattern": "src/utils/*",
3317
+ "agents": ["agent-alpha-demo", "agent-beta-demo"],
3318
+ "detected": now,
3319
+ "suggestion": "Coordinate via /handoff",
3320
+ },
3321
+ ],
3322
+ "branches": {
3323
+ "agent-alpha-demo": {
3324
+ "agent_id": "agent-alpha-demo",
3325
+ "branch": "feature/oauth-flow",
3326
+ "age_seconds": 8100, # 2h 15m
3327
+ "age_display": "2h 15m",
3328
+ "is_stale": False,
3329
+ },
3330
+ "agent-beta-demo": {
3331
+ "agent_id": "agent-beta-demo",
3332
+ "branch": "feature/unit-tests",
3333
+ "age_seconds": 2700, # 45m
3334
+ "age_display": "45m",
3335
+ "is_stale": False,
3336
+ },
3337
+ "agent-gamma-demo": {
3338
+ "agent_id": "agent-gamma-demo",
3339
+ "branch": "feature/stale-branch",
3340
+ "age_seconds": 16200, # 4h 30m
3341
+ "age_display": "4h 30m",
3342
+ "is_stale": True,
3343
+ },
3344
+ },
3345
+ "last_update": time.time(),
3346
+ }
3347
+
3348
+ def _get_demo_github_status(self) -> Dict[str, Any]:
3349
+ """Generate demo GitHub/CI status data for testing (ANV-111-113)."""
3350
+ return {
3351
+ "/Users/demo/projects/auth-service": {
3352
+ "project_path": "/Users/demo/projects/auth-service",
3353
+ "branch": "feature/oauth-flow",
3354
+ "pr_number": 45,
3355
+ "pr_url": "https://github.com/demo/auth-service/pull/45",
3356
+ "pr_state": "OPEN",
3357
+ "pr_title": "Implement OAuth2 authentication flow",
3358
+ "ci_status": "passed",
3359
+ "ci_conclusion": "success",
3360
+ "reviews": [
3361
+ {"state": "APPROVED", "author": "reviewer1"},
3362
+ {"state": "APPROVED", "author": "reviewer2"},
3363
+ ],
3364
+ "reviews_summary": "2 approved",
3365
+ "check_runs": [
3366
+ {"name": "test", "status": "completed", "conclusion": "success"},
3367
+ {"name": "lint", "status": "completed", "conclusion": "success"},
3368
+ {"name": "build", "status": "completed", "conclusion": "success"},
3369
+ ],
3370
+ "merge_queue": {"position": 2, "estimated_time_to_merge": 300},
3371
+ },
3372
+ "/Users/demo/projects/payment-api": {
3373
+ "project_path": "/Users/demo/projects/payment-api",
3374
+ "branch": "feature/unit-tests",
3375
+ "pr_number": 44,
3376
+ "pr_url": "https://github.com/demo/payment-api/pull/44",
3377
+ "pr_state": "OPEN",
3378
+ "pr_title": "Add unit tests for payment module",
3379
+ "ci_status": "failed",
3380
+ "ci_conclusion": "failure",
3381
+ "reviews": [
3382
+ {"state": "CHANGES_REQUESTED", "author": "reviewer1"},
3383
+ ],
3384
+ "reviews_summary": "1 changes requested",
3385
+ "check_runs": [
3386
+ {"name": "test", "status": "completed", "conclusion": "failure"},
3387
+ {"name": "lint", "status": "completed", "conclusion": "success"},
3388
+ ],
3389
+ "merge_queue": None,
3390
+ },
3391
+ }
3392
+
3393
+ def _get_demo_coderabbit_status(self) -> Dict[str, Any]:
3394
+ """Generate demo CodeRabbit status data for testing (ANV-114-115)."""
3395
+ now = time.time()
3396
+ return {
3397
+ "/Users/demo/projects/auth-service": {
3398
+ "pr_number": 45,
3399
+ "review_status": "approved",
3400
+ "issues_count": 0,
3401
+ "suggestions_count": 2,
3402
+ "resolved_count": 2,
3403
+ "pending_count": 0,
3404
+ "review_url": "https://coderabbit.ai/review/45",
3405
+ "issues": [],
3406
+ "last_updated": now,
3407
+ },
3408
+ "/Users/demo/projects/payment-api": {
3409
+ "pr_number": 44,
3410
+ "review_status": "changes_requested",
3411
+ "issues_count": 3,
3412
+ "suggestions_count": 5,
3413
+ "resolved_count": 2,
3414
+ "pending_count": 6,
3415
+ "review_url": "https://coderabbit.ai/review/44",
3416
+ "issues": [
3417
+ {"severity": "error", "file": "src/payment.ts", "line": 42, "message": "Potential SQL injection", "resolved": False},
3418
+ {"severity": "warning", "file": "src/utils.ts", "line": 15, "message": "Unused import", "resolved": False},
3419
+ {"severity": "suggestion", "file": "src/api.ts", "line": 88, "message": "Consider error handling", "resolved": True},
3420
+ ],
3421
+ "last_updated": now,
3422
+ },
3423
+ }
3424
+
3425
+ def _get_demo_agents(self) -> Dict[str, Any]:
3426
+ """Generate demo agent data for testing."""
3427
+ now = datetime.now(timezone.utc).isoformat()
3428
+
3429
+ return {
3430
+ "agent-alpha-demo": {
3431
+ "id": "agent-alpha-demo",
3432
+ "project": "/Users/demo/projects/auth-service",
3433
+ "projectName": "auth-service",
3434
+ "issue": "ANV-42",
3435
+ "phase": "implement",
3436
+ "model": "Opus",
3437
+ "contextUsage": 64000,
3438
+ "contextLimit": 200000,
3439
+ "estimatedTurns": 23, # ANV-98
3440
+ "sessionCost": 2.45,
3441
+ "cost": {
3442
+ "session": {
3443
+ "tokens": {"input": 80000, "output": 25000, "cache_read": 120000, "cache_write": 8000},
3444
+ "cost_usd": 2.45,
3445
+ "last_updated": now,
3446
+ },
3447
+ "attributed": {
3448
+ "ANV-42": {"issue_id": "ANV-42", "cost_usd": 1.85, "tokens": 180000, "started_at": now},
3449
+ "ANV-41": {"issue_id": "ANV-41", "cost_usd": 0.60, "tokens": 53000, "started_at": now},
3450
+ },
3451
+ "daily_total": 2.45,
3452
+ "weekly_total": 12.30,
3453
+ "last_issue": "ANV-42",
3454
+ },
3455
+ "lastActivity": now,
3456
+ "startedAt": now,
3457
+ "status": "active"
3458
+ },
3459
+ "agent-beta-demo": {
3460
+ "id": "agent-beta-demo",
3461
+ "project": "/Users/demo/projects/payment-api",
3462
+ "projectName": "payment-api",
3463
+ "issue": "ANV-43",
3464
+ "phase": "spec",
3465
+ "model": "Sonnet",
3466
+ "contextUsage": 36000,
3467
+ "contextLimit": 200000,
3468
+ "estimatedTurns": 42, # ANV-98
3469
+ "sessionCost": 0.42,
3470
+ "cost": {
3471
+ "session": {
3472
+ "tokens": {"input": 45000, "output": 18000, "cache_read": 80000, "cache_write": 5000},
3473
+ "cost_usd": 0.42,
3474
+ "last_updated": now,
3475
+ },
3476
+ "attributed": {
3477
+ "ANV-43": {"issue_id": "ANV-43", "cost_usd": 0.42, "tokens": 148000, "started_at": now},
3478
+ },
3479
+ "daily_total": 0.42,
3480
+ "weekly_total": 3.15,
3481
+ "last_issue": "ANV-43",
3482
+ },
3483
+ "lastActivity": now,
3484
+ "startedAt": now,
3485
+ "status": "active"
3486
+ },
3487
+ "agent-gamma-demo": {
3488
+ "id": "agent-gamma-demo",
3489
+ "project": "/Users/demo/Projects/api-service",
3490
+ "projectName": "api-service",
3491
+ "issue": None,
3492
+ "phase": "explore",
3493
+ "model": "Haiku",
3494
+ "contextUsage": 8000,
3495
+ "contextLimit": 200000,
3496
+ "estimatedTurns": None, # ANV-98: New session, calculating...
3497
+ "sessionCost": 0.02,
3498
+ "cost": {
3499
+ "session": {
3500
+ "tokens": {"input": 12000, "output": 4000, "cache_read": 0, "cache_write": 2000},
3501
+ "cost_usd": 0.02,
3502
+ "last_updated": now,
3503
+ },
3504
+ "attributed": {},
3505
+ "daily_total": 0.02,
3506
+ "weekly_total": 0.08,
3507
+ "last_issue": None,
3508
+ },
3509
+ "lastActivity": now,
3510
+ "startedAt": now,
3511
+ "status": "active"
3512
+ }
3513
+ }
3514
+
3515
+ def _get_demo_tool_activity(self) -> Dict[str, Any]:
3516
+ """Generate demo tool activity data for testing (ANV-125)."""
3517
+ return {
3518
+ "running": [
3519
+ {"name": "Edit", "target": ".../components/Auth.tsx"},
3520
+ {"name": "Bash", "target": "npm run test"},
3521
+ ],
3522
+ "completed": {
3523
+ "Read": 12,
3524
+ "Edit": 8,
3525
+ "Grep": 5,
3526
+ "Glob": 4,
3527
+ },
3528
+ "errors": 1,
3529
+ }
3530
+
3531
+ def _aggregate_tool_activity(self, agents: Dict[str, Any]) -> Dict[str, Any]:
3532
+ """Aggregate tool activity from all agent transcripts (ANV-125).
3533
+
3534
+ Args:
3535
+ agents: Dictionary of agent data keyed by agent ID
3536
+
3537
+ Returns:
3538
+ Aggregated tool activity with running, completed, and error counts
3539
+ """
3540
+ if not TranscriptParser:
3541
+ return {"running": [], "completed": {}, "errors": 0}
3542
+
3543
+ parser = TranscriptParser(cache_ttl=1.0)
3544
+
3545
+ all_running = []
3546
+ all_completed: Dict[str, int] = {}
3547
+ total_errors = 0
3548
+
3549
+ for agent_id, agent in agents.items():
3550
+ # Get transcript path from agent data
3551
+ transcript_path = agent.get("transcriptPath", "")
3552
+ if not transcript_path:
3553
+ continue
3554
+
3555
+ try:
3556
+ activity = parser.parse(transcript_path)
3557
+
3558
+ # Collect running tools
3559
+ for tool in activity.running:
3560
+ all_running.append({
3561
+ "name": tool.name,
3562
+ "target": tool.target or "",
3563
+ "agent": agent_id,
3564
+ })
3565
+
3566
+ # Aggregate completed tool counts
3567
+ for name, count in activity.tool_counts.items():
3568
+ all_completed[name] = all_completed.get(name, 0) + count
3569
+
3570
+ # Sum errors
3571
+ total_errors += activity.error_count
3572
+
3573
+ except Exception:
3574
+ # Graceful degradation - continue with other agents
3575
+ pass
3576
+
3577
+ # Limit running tools to 2 (most recent)
3578
+ running_limited = all_running[:2]
3579
+
3580
+ return {
3581
+ "running": running_limited,
3582
+ "completed": all_completed,
3583
+ "errors": total_errors,
3584
+ }
3585
+
3586
+
3587
+ def main():
3588
+ parser = argparse.ArgumentParser(
3589
+ description="Anvil HUD - Multi-agent Claude Code dashboard"
3590
+ )
3591
+ parser.add_argument(
3592
+ "--demo",
3593
+ action="store_true",
3594
+ help="Run with demo data"
3595
+ )
3596
+ parser.add_argument(
3597
+ "--project",
3598
+ type=str,
3599
+ help="Project path for project-specific config"
3600
+ )
3601
+ parser.add_argument(
3602
+ "--generate-config",
3603
+ action="store_true",
3604
+ help="Generate default config file and exit"
3605
+ )
3606
+ args = parser.parse_args()
3607
+
3608
+ # Handle config generation
3609
+ if args.generate_config:
3610
+ try:
3611
+ from config_service import generate_default_config
3612
+ print(generate_default_config())
3613
+ except ImportError:
3614
+ print("# Config service not available. Install pyyaml.")
3615
+ return
3616
+
3617
+ app = AnvilHUD(demo_mode=args.demo, project_path=args.project)
3618
+ app.run()
3619
+
3620
+
3621
+ if __name__ == "__main__":
3622
+ main()