Replace plan JSON with colored S-expression display

- Add plan_to_sexp() to convert plan to S-expression format
- Syntax highlighting for S-expressions:
  - Pink: special forms (plan, recipe, def, ->)
  - Blue: primitives (source, effect, sequence, etc.)
  - Purple: keywords (:input, :name, etc.)
  - Green: strings
  - Yellow: parentheses
  - Gray: comments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-12 00:23:12 +00:00
parent 82d94f6e0e
commit f554122b07
2 changed files with 87 additions and 4 deletions

View File

@@ -27,6 +27,59 @@ from ..services.run_service import RunService
router = APIRouter()
logger = logging.getLogger(__name__)
def plan_to_sexp(plan: dict, recipe_name: str = None) -> str:
"""Convert a plan to S-expression format for display."""
if not plan or not plan.get("steps"):
return ";; No plan available"
lines = []
lines.append(f'(plan "{recipe_name or "unknown"}"')
# Group nodes by type for cleaner output
steps = plan.get("steps", [])
for step in steps:
step_id = step.get("id", "?")
step_type = step.get("type", "EFFECT")
inputs = step.get("inputs", [])
config = step.get("config", {})
# Build the step S-expression
if step_type == "SOURCE":
if config.get("input"):
# Variable input
input_name = config.get("name", config.get("input", "input"))
lines.append(f' (source :input "{input_name}")')
elif config.get("asset"):
# Fixed asset
lines.append(f' (source {config.get("asset", step_id)})')
else:
lines.append(f' (source {step_id})')
elif step_type == "EFFECT":
effect_name = config.get("effect", step_id)
if inputs:
inp_str = " ".join(inputs)
lines.append(f' (-> {inp_str} (effect {effect_name}))')
else:
lines.append(f' (effect {effect_name})')
elif step_type == "SEQUENCE":
if inputs:
inp_str = " ".join(inputs)
lines.append(f' (sequence {inp_str})')
else:
lines.append(f' (sequence)')
else:
# Generic node
if inputs:
inp_str = " ".join(inputs)
lines.append(f' ({step_type.lower()} {inp_str})')
else:
lines.append(f' ({step_type.lower()} {step_id})')
lines.append(')')
return "\n".join(lines)
RUNS_KEY_PREFIX = "artdag:run:"
@@ -258,6 +311,9 @@ async def get_run(
}
})
# Generate S-expression representation of the plan
plan_sexp = plan_to_sexp(plan, run.get("recipe_name"))
templates = get_templates(request)
return render(templates, "runs/detail.html", request,
run=run,
@@ -266,6 +322,7 @@ async def get_run(
run_inputs=run_inputs,
dag_elements=dag_elements,
output_media_type=output_media_type,
plan_sexp=plan_sexp,
active_tab="runs",
)

View File

@@ -156,15 +156,41 @@
{% endfor %}
</div>
<!-- Plan JSON -->
<details class="mt-6">
<!-- Plan S-expression -->
<details class="mt-6" open>
<summary class="cursor-pointer text-gray-400 hover:text-white text-sm mb-2">
Show Plan JSON
Plan (S-expression)
</summary>
<div class="bg-gray-900 rounded-lg border border-gray-700 p-4 overflow-x-auto">
<pre class="text-sm text-gray-300 whitespace-pre-wrap">{{ plan | tojson(indent=2) }}</pre>
<pre class="text-sm font-mono sexp-code">{{ plan_sexp }}</pre>
</div>
</details>
<style>
.sexp-code {
line-height: 1.6;
}
</style>
<script>
// Syntax highlight S-expressions
document.querySelectorAll('.sexp-code').forEach(el => {
let html = el.textContent;
// Comments
html = html.replace(/(;;.*)/g, '<span class="text-gray-500">$1</span>');
// Keywords (:keyword)
html = html.replace(/(:[a-zA-Z_-]+)/g, '<span class="text-purple-400">$1</span>');
// Strings
html = html.replace(/("(?:[^"\\]|\\.)*")/g, '<span class="text-green-400">$1</span>');
// Special forms
html = html.replace(/\b(plan|recipe|def|->)\b/g, '<span class="text-pink-400 font-semibold">$1</span>');
// Primitives
html = html.replace(/\((source|effect|sequence|segment|resize|transform|layer|blend|mux|analyze)\b/g,
'(<span class="text-blue-400">$1</span>');
// Parentheses
html = html.replace(/(\(|\))/g, '<span class="text-yellow-500">$1</span>');
el.innerHTML = html;
});
</script>
{% else %}
<p class="text-gray-500">No plan available for this run.</p>
{% endif %}