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