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