Merge pull request #25 from DEGAorg/conductor

promote: conductor → canon (include right-pane v2 + fixes)
This commit is contained in:
CerratoA 2026-04-19 16:38:35 -06:00 committed by GitHub
commit 8c5ba279ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 5390 additions and 101 deletions

View file

@ -0,0 +1,152 @@
---
name: canon-panel-routing
description: How the Canon TUI right-pane is opened, closed, and filtered from agent messages. Use when adding a new right-pane panel, wiring a `show me X` chat command, extending the filter schema, or debugging why an `open_panel` message didn't land. Triggers on ACP `open_panel`, `close_panel`, `PANEL_ROUTES`, `ProjectStatePane`, or "how do I make the agent open the Board tab?".
---
# Canon Panel Routing
This skill explains the single source of truth that maps agent-facing
panel names (used in chat / ACP messages) to concrete right-pane tabs in
the Canon TUI, and how filters flow from a chat command to the UI.
## The data flow
```
Agent reply (JSON-RPC sessionUpdate)
{"sessionUpdate": "open_panel",
"panelId": "board",
"context": {"filters": {"priority": "P1", "status": "in_progress"}}}
toad.acp.agent.rpc_session_update
messages.OpenPanel(panel_id="board", context={"filters": {...}})
MainScreen.on_acp_open_panel
├─ look up "board" in PANEL_ROUTES → ("section-planning", "tab-tasks")
├─ _show_section_tab(...) — opens pane, shows section, activates tab
└─ if panel supports filters, pane.apply_task_filters(filters)
```
The registry lives in `src/toad/widgets/project_state_pane.py`:
- `PANEL_ROUTES: dict[str, tuple[str, str]]` — panel ID → (section, tab)
- `PANEL_FILTERS: dict[str, tuple[str, ...]]` — panel ID → supported filter keys
Both are imported in `src/toad/screens/main.py`.
## Adding a new panel — 4 steps
Say you want a "Deployments" panel reachable via `open_panel` with
ID `"deployments"`.
### 1. Mount the widget in `ProjectStatePane.compose`
```python
# inside an existing TabbedContent, e.g. section-planning
with TabPane("Deployments", id="tab-deployments"):
yield DeploymentsWidget(id="deployments-view")
```
### 2. Register the route
Edit `PANEL_ROUTES` in `project_state_pane.py`:
```python
PANEL_ROUTES: dict[str, tuple[str, str]] = {
# ...
"deployments": (SECTION_PLANNING, "tab-deployments"),
"deploys": (SECTION_PLANNING, "tab-deployments"), # alias
}
```
### 3. (Optional) Declare filter schema
If the panel accepts filters via chat:
```python
PANEL_FILTERS: dict[str, tuple[str, ...]] = {
# ...
"deployments": ("environment", "status", "since"),
}
```
Then expose an `apply_deployment_filters(filters: dict)` method on
`ProjectStatePane` and call it from `MainScreen.on_acp_open_panel`
(follow the pattern used for `"board"`).
### 4. Teach the agent
Mention the panel (and any filters) in the agent prompt so the model
knows it can emit:
```json
{"sessionUpdate": "open_panel",
"panelId": "deployments",
"context": {"filters": {"environment": "prod"}}}
```
That's it. Closing works automatically (the `close_panel` handler uses
the same registry to find the section to collapse).
## Filter conventions
- **Keys** are lowercase, snake-case strings.
- **Values** are strings, numbers, or booleans — not nested objects.
- **Unknown keys / invalid values** are ignored, never raised. The
agent may ship a filter that an older client doesn't understand; the
UI must not crash.
- The filter-apply method on the pane is responsible for validation;
log unknowns at `debug` level, not `warning`.
## Chat phrasings the agent supports
Map these to `open_panel` calls in the agent prompt:
| User phrase | panelId | filters |
|-------------------------------|----------------|-----------------------------------------|
| "show me the board" | `board` | — |
| "show me P1 tasks" | `board` | `{"priority": "P1"}` |
| "show me done tasks" | `board` | `{"status": "done"}` |
| "open the plan" | `plan` | — |
| "show me the files" | `files` | — |
| "open the timeline" | `timeline` | — |
| "hide the right panel" | — | send `close_panel` with `project_state` |
## Debugging
Common failures and where to look:
| Symptom | Where to check |
|-------------------------------------------------|---------------------------------------------|
| Panel never opens | Is the ID in `PANEL_ROUTES`? Typo? |
| Panel opens but filters ignored | Is the panel in `PANEL_FILTERS`? Does `MainScreen.on_acp_open_panel` route to `apply_*_filters`? |
| Filters applied but wrong behaviour | The pane's `apply_*_filters` method — unit-test it with the same dict |
| Agent sends `close_panel` and nothing happens | `close_panel` handler uses `PANEL_ROUTES[id][0]` as the section to hide — check the ID |
Runtime tracing: set `log.setLevel(logging.DEBUG)` on the pane logger
and watch `"unknown status filter: %s"` / `"unknown priority filter"`
entries.
## Why a registry, not a dict per screen
Keeping `PANEL_ROUTES` in `project_state_pane.py` (not `main.py`) makes
adding a panel a **single-file change**. The pane owns its tabs; it
should also own the agent-facing names for those tabs. `MainScreen`
just dispatches.
This mirrors how chat-based UIs work outside Canon — the surface area
(panel names the agent sees) is tightly coupled to the widgets, and
both move together.
## What NOT to do
- Don't add a panel ID to `PANEL_ROUTES` that points at a tab that
doesn't exist yet — the open will silently fail with no user-visible
error.
- Don't invent a new filter key the panel doesn't read. Declare in
`PANEL_FILTERS` first; unrecognised keys are logged and dropped.
- Don't re-implement routing inline in a widget. If something in the
right pane needs to open a different panel, send an ACP message
through the agent, not a direct call. Keeps the routing auditable
from the agent's perspective.

View file

@ -0,0 +1,148 @@
---
name: audit
description: Run technical quality checks across accessibility, performance, theming, responsive design, and anti-patterns. Generates a scored report with P0-P3 severity ratings and actionable plan. Use when the user wants an accessibility check, performance audit, or technical quality review.
version: 2.1.1
user-invocable: true
argument-hint: "[area (feature, page, component...)]"
---
## MANDATORY PREPARATION
Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first.
---
Run systematic **technical** quality checks and generate a comprehensive report. Don't fix issues — document them for other commands to address.
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
## Diagnostic Scan
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
### 1. Accessibility (A11y)
**Check for**:
- **Contrast issues**: Text contrast ratios < 4.5:1 (or 7:1 for AAA)
- **Missing ARIA**: Interactive elements without proper roles, labels, or states
- **Keyboard navigation**: Missing focus indicators, illogical tab order, keyboard traps
- **Semantic HTML**: Improper heading hierarchy, missing landmarks, divs instead of buttons
- **Alt text**: Missing or poor image descriptions
- **Form issues**: Inputs without labels, poor error messaging, missing required indicators
**Score 0-4**: 0=Inaccessible (fails WCAG A), 1=Major gaps (few ARIA labels, no keyboard nav), 2=Partial (some a11y effort, significant gaps), 3=Good (WCAG AA mostly met, minor gaps), 4=Excellent (WCAG AA fully met, approaches AAA)
### 2. Performance
**Check for**:
- **Layout thrashing**: Reading/writing layout properties in loops
- **Expensive animations**: Animating layout properties (width, height, top, left) instead of transform/opacity
- **Missing optimization**: Images without lazy loading, unoptimized assets, missing will-change
- **Bundle size**: Unnecessary imports, unused dependencies
- **Render performance**: Unnecessary re-renders, missing memoization
**Score 0-4**: 0=Severe issues (layout thrash, unoptimized everything), 1=Major problems (no lazy loading, expensive animations), 2=Partial (some optimization, gaps remain), 3=Good (mostly optimized, minor improvements possible), 4=Excellent (fast, lean, well-optimized)
### 3. Theming
**Check for**:
- **Hard-coded colors**: Colors not using design tokens
- **Broken dark mode**: Missing dark mode variants, poor contrast in dark theme
- **Inconsistent tokens**: Using wrong tokens, mixing token types
- **Theme switching issues**: Values that don't update on theme change
**Score 0-4**: 0=No theming (hard-coded everything), 1=Minimal tokens (mostly hard-coded), 2=Partial (tokens exist but inconsistently used), 3=Good (tokens used, minor hard-coded values), 4=Excellent (full token system, dark mode works perfectly)
### 4. Responsive Design
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
### 5. Anti-Patterns (CRITICAL)
Check against ALL the **DON'T** guidelines in the impeccable skill. Look for AI slop tells (AI color palette, gradient text, glassmorphism, hero metrics, card grids, generic fonts) and general design anti-patterns (gray on color, nested cards, bounce easing, redundant copy).
**Score 0-4**: 0=AI slop gallery (5+ tells), 1=Heavy AI aesthetic (3-4 tells), 2=Some tells (1-2 noticeable), 3=Mostly clean (subtle issues only), 4=No AI tells (distinctive, intentional design)
## Generate Report
### Audit Health Score
| # | Dimension | Score | Key Finding |
|---|-----------|-------|-------------|
| 1 | Accessibility | ? | [most critical a11y issue or "--"] |
| 2 | Performance | ? | |
| 3 | Responsive Design | ? | |
| 4 | Theming | ? | |
| 5 | Anti-Patterns | ? | |
| **Total** | | **??/20** | **[Rating band]** |
**Rating bands**: 18-20 Excellent (minor polish), 14-17 Good (address weak dimensions), 10-13 Acceptable (significant work needed), 6-9 Poor (major overhaul), 0-5 Critical (fundamental issues)
### Anti-Patterns Verdict
**Start here.** Pass/fail: Does this look AI-generated? List specific tells. Be brutally honest.
### Executive Summary
- Audit Health Score: **??/20** ([rating band])
- Total issues found (count by severity: P0/P1/P2/P3)
- Top 3-5 critical issues
- Recommended next steps
### Detailed Findings by Severity
Tag every issue with **P0-P3 severity**:
- **P0 Blocking**: Prevents task completion — fix immediately
- **P1 Major**: Significant difficulty or WCAG AA violation — fix before release
- **P2 Minor**: Annoyance, workaround exists — fix in next pass
- **P3 Polish**: Nice-to-fix, no real user impact — fix if time permits
For each issue, document:
- **[P?] Issue name**
- **Location**: Component, file, line
- **Category**: Accessibility / Performance / Theming / Responsive / Anti-Pattern
- **Impact**: How it affects users
- **WCAG/Standard**: Which standard it violates (if applicable)
- **Recommendation**: How to fix it
- **Suggested command**: Which command to use (prefer: /animate, /quieter, /shape, /optimize, /adapt, /clarify, /layout, /distill, /delight, /audit, /harden, /polish, /bolder, /typeset, /critique, /colorize, /overdrive)
### Patterns & Systemic Issues
Identify recurring problems that indicate systemic gaps rather than one-off mistakes:
- "Hard-coded colors appear in 15+ components, should use design tokens"
- "Touch targets consistently too small (<44px) throughout mobile experience"
### Positive Findings
Note what's working well — good practices to maintain and replicate.
## Recommended Actions
List recommended commands in priority order (P0 first, then P1, then P2):
1. **[P?] `/command-name`** — Brief description (specific context from audit findings)
2. **[P?] `/command-name`** — Brief description (specific context)
**Rules**: Only recommend commands from: /animate, /quieter, /shape, /optimize, /adapt, /clarify, /layout, /distill, /delight, /audit, /harden, /polish, /bolder, /typeset, /critique, /colorize, /overdrive. Map findings to the most appropriate command. End with `/polish` as the final step if any fixes were recommended.
After presenting the summary, tell the user:
> You can ask me to run these one at a time, all at once, or in any order you prefer.
>
> Re-run `/audit` after fixes to see your score improve.
**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters.
**NEVER**:
- Report issues without explaining impact (why does this matter?)
- Provide generic recommendations (be specific and actionable)
- Skip positive findings (celebrate what works)
- Forget to prioritize (everything can't be P0)
- Report false positives without verification
Remember: You're a technical quality auditor. Document systematically, prioritize ruthlessly, cite specific code locations, and provide clear paths to improvement.

View file

@ -0,0 +1,183 @@
---
name: clarify
description: Improve unclear UX copy, error messages, microcopy, labels, and instructions to make interfaces easier to understand. Use when the user mentions confusing text, unclear labels, bad error messages, hard-to-follow instructions, or wanting better UX writing.
version: 2.1.1
user-invocable: true
argument-hint: "[target]"
---
Identify and improve unclear, confusing, or poorly written interface text to make the product easier to understand and use.
## MANDATORY PREPARATION
Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first. Additionally gather: audience technical level and users' mental state in context.
---
## Assess Current Copy
Identify what makes the text unclear or ineffective:
1. **Find clarity problems**:
- **Jargon**: Technical terms users won't understand
- **Ambiguity**: Multiple interpretations possible
- **Passive voice**: "Your file has been uploaded" vs "We uploaded your file"
- **Length**: Too wordy or too terse
- **Assumptions**: Assuming user knowledge they don't have
- **Missing context**: Users don't know what to do or why
- **Tone mismatch**: Too formal, too casual, or inappropriate for situation
2. **Understand the context**:
- Who's the audience? (Technical? General? First-time users?)
- What's the user's mental state? (Stressed during error? Confident during success?)
- What's the action? (What do we want users to do?)
- What's the constraint? (Character limits? Space limitations?)
**CRITICAL**: Clear copy helps users succeed. Unclear copy creates frustration, errors, and support tickets.
## Plan Copy Improvements
Create a strategy for clearer communication:
- **Primary message**: What's the ONE thing users need to know?
- **Action needed**: What should users do next (if anything)?
- **Tone**: How should this feel? (Helpful? Apologetic? Encouraging?)
- **Constraints**: Length limits, brand voice, localization considerations
**IMPORTANT**: Good UX writing is invisible. Users should understand immediately without noticing the words.
## Improve Copy Systematically
Refine text across these common areas:
### Error Messages
**Bad**: "Error 403: Forbidden"
**Good**: "You don't have permission to view this page. Contact your admin for access."
**Bad**: "Invalid input"
**Good**: "Email addresses need an @ symbol. Try: name@example.com"
**Principles**:
- Explain what went wrong in plain language
- Suggest how to fix it
- Don't blame the user
- Include examples when helpful
- Link to help/support if applicable
### Form Labels & Instructions
**Bad**: "DOB (MM/DD/YYYY)"
**Good**: "Date of birth" (with placeholder showing format)
**Bad**: "Enter value here"
**Good**: "Your email address" or "Company name"
**Principles**:
- Use clear, specific labels (not generic placeholders)
- Show format expectations with examples
- Explain why you're asking (when not obvious)
- Put instructions before the field, not after
- Keep required field indicators clear
### Button & CTA Text
**Bad**: "Click here" | "Submit" | "OK"
**Good**: "Create account" | "Save changes" | "Got it, thanks"
**Principles**:
- Describe the action specifically
- Use active voice (verb + noun)
- Match user's mental model
- Be specific ("Save" is better than "OK")
### Help Text & Tooltips
**Bad**: "This is the username field"
**Good**: "Choose a username. You can change this later in Settings."
**Principles**:
- Add value (don't just repeat the label)
- Answer the implicit question ("What is this?" or "Why do you need this?")
- Keep it brief but complete
- Link to detailed docs if needed
### Empty States
**Bad**: "No items"
**Good**: "No projects yet. Create your first project to get started."
**Principles**:
- Explain why it's empty (if not obvious)
- Show next action clearly
- Make it welcoming, not dead-end
### Success Messages
**Bad**: "Success"
**Good**: "Settings saved! Your changes will take effect immediately."
**Principles**:
- Confirm what happened
- Explain what happens next (if relevant)
- Be brief but complete
- Match the user's emotional moment (celebrate big wins)
### Loading States
**Bad**: "Loading..." (for 30+ seconds)
**Good**: "Analyzing your data... this usually takes 30-60 seconds"
**Principles**:
- Set expectations (how long?)
- Explain what's happening (when it's not obvious)
- Show progress when possible
- Offer escape hatch if appropriate ("Cancel")
### Confirmation Dialogs
**Bad**: "Are you sure?"
**Good**: "Delete 'Project Alpha'? This can't be undone."
**Principles**:
- State the specific action
- Explain consequences (especially for destructive actions)
- Use clear button labels ("Delete project" not "Yes")
- Don't overuse confirmations (only for risky actions)
### Navigation & Wayfinding
**Bad**: Generic labels like "Items" | "Things" | "Stuff"
**Good**: Specific labels like "Your projects" | "Team members" | "Settings"
**Principles**:
- Be specific and descriptive
- Use language users understand (not internal jargon)
- Make hierarchy clear
- Consider information scent (breadcrumbs, current location)
## Apply Clarity Principles
Every piece of copy should follow these rules:
1. **Be specific**: "Enter email" not "Enter value"
2. **Be concise**: Cut unnecessary words (but don't sacrifice clarity)
3. **Be active**: "Save changes" not "Changes will be saved"
4. **Be human**: "Oops, something went wrong" not "System error encountered"
5. **Be helpful**: Tell users what to do, not just what happened
6. **Be consistent**: Use same terms throughout (don't vary for variety)
**NEVER**:
- Use jargon without explanation
- Blame users ("You made an error" → "This field is required")
- Be vague ("Something went wrong" without explanation)
- Use passive voice unnecessarily
- Write overly long explanations (be concise)
- Use humor for errors (be empathetic instead)
- Assume technical knowledge
- Vary terminology (pick one term and stick with it)
- Repeat information (headers restating intros, redundant explanations)
- Use placeholders as the only labels (they disappear when users type)
## Verify Improvements
Test that copy improvements work:
- **Comprehension**: Can users understand without context?
- **Actionability**: Do users know what to do next?
- **Brevity**: Is it as short as possible while remaining clear?
- **Consistency**: Does it match terminology elsewhere?
- **Tone**: Is it appropriate for the situation?
Remember: You're a clarity expert with excellent communication skills. Write like you're explaining to a smart friend who's unfamiliar with the product. Be clear, be helpful, be human.

View file

@ -0,0 +1,227 @@
---
name: critique
description: Evaluate design from a UX perspective, assessing visual hierarchy, information architecture, emotional resonance, cognitive load, and overall quality with quantitative scoring, persona-based testing, automated anti-pattern detection, and actionable feedback. Use when the user asks to review, critique, evaluate, or give feedback on a design or component.
version: 2.1.1
user-invocable: true
argument-hint: "[area (feature, page, component...)]"
allowed-tools:
- Bash(npx impeccable *)
---
## STEPS
### Step 1: Preparation
Invoke /impeccable, which contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding. If no design context exists yet, you MUST run /impeccable teach first. Additionally gather: what the interface is trying to accomplish.
### Step 2: Gather Assessments
Launch two independent assessments. **Neither must see the other's output** to avoid bias.
You SHOULD delegate each assessment to a separate sub-agent for independence. Use your environment's agent spawning mechanism (e.g., Claude Code's `Agent` tool, or Codex's subagent spawning). Sub-agents should return their findings as structured text. Do NOT output findings to the user yet.
If sub-agents are not available in the current environment, complete each assessment sequentially, writing findings to internal notes before proceeding.
**Tab isolation**: When browser automation is available, each assessment MUST create its own new tab. Never reuse an existing tab, even if one is already open at the correct URL. This prevents the two assessments from interfering with each other's page state.
#### Assessment A: LLM Design Review
Read the relevant source files (HTML, CSS, JS/TS) and, if browser automation is available, visually inspect the live page. **Create a new tab** for this; do not reuse existing tabs. After navigation, label the tab by setting the document title:
```javascript
document.title = '[LLM] ' + document.title;
```
Think like a design director. Evaluate:
**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines in the impeccable skill. Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
**Holistic Design Review**: visual hierarchy (eye flow, primary action clarity), information architecture (structure, grouping, cognitive load), emotional resonance (does it match brand and audience?), discoverability (are interactive elements obvious?), composition (balance, whitespace, rhythm), typography (hierarchy, readability, font choices), color (purposeful use, cohesion, accessibility), states & edge cases (empty, loading, error, success), microcopy (clarity, tone, helpfulness).
**Cognitive Load** (consult [cognitive-load](reference/cognitive-load.md)):
- Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical.
- Count visible options at each decision point. If >4, flag it.
- Check for progressive disclosure: is complexity revealed only when needed?
**Emotional Journey**:
- What emotion does this interface evoke? Is that intentional?
- **Peak-end rule**: Is the most intense moment positive? Does the experience end well?
- **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)?
**Nielsen's Heuristics** (consult [heuristics-scoring](reference/heuristics-scoring.md)):
Score each of the 10 heuristics 0-4. This scoring will be presented in the report.
Return structured findings covering: AI slop verdict, heuristic scores, cognitive load assessment, what's working (2-3 items), priority issues (3-5 with what/why/fix), minor observations, and provocative questions.
#### Assessment B: Automated Detection
Run the bundled deterministic detector, which flags 25 specific patterns (AI slop tells + general design quality).
**CLI scan**:
```bash
npx impeccable --json [--fast] [target]
```
- Pass HTML/JSX/TSX/Vue/Svelte files or directories as `[target]` (anything with markup). Do not pass CSS-only files.
- For URLs, skip the CLI scan (it requires Puppeteer). Use browser visualization instead.
- For large directories (200+ scannable files), use `--fast` (regex-only, skips jsdom)
- For 500+ files, narrow scope or ask the user
- Exit code 0 = clean, 2 = findings
**Browser visualization** (when browser automation tools are available AND the target is a viewable page):
The overlay is a **visual aid for the user**. It highlights issues directly in their browser. Do NOT scroll through the page to screenshot overlays. Instead, read the console output to get the results programmatically.
1. **Start the live detection server**:
```bash
npx impeccable live &
```
Note the port printed to stdout (auto-assigned). Use `--port=PORT` to fix it.
2. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs.
3. **Label the tab** via `javascript_tool` so the user can distinguish it:
```javascript
document.title = '[Human] ' + document.title;
```
4. **Scroll to top** to ensure the page is scrolled to the very top before injection
5. **Inject** via `javascript_tool` (replace PORT with the port from step 1):
```javascript
const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s);
```
6. Wait 2-3 seconds for the detector to render overlays
7. **Read results from console** using `read_console_messages` with pattern `impeccable`. The detector logs all findings with the `[impeccable]` prefix. Do NOT scroll through the page to take screenshots of the overlays.
8. **Cleanup**: Stop the live server when done:
```bash
npx impeccable live stop
```
For multi-view targets, inject on 3-5 representative pages. If injection fails, continue with CLI results only.
Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted.
### Step 3: Generate Combined Critique Report
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
Structure your feedback as a design director would:
#### Design Health Score
> *Consult [heuristics-scoring](reference/heuristics-scoring.md)*
Present the Nielsen's 10 heuristics scores as a table:
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | ? | [specific finding or "n/a" if solid] |
| 2 | Match System / Real World | ? | |
| 3 | User Control and Freedom | ? | |
| 4 | Consistency and Standards | ? | |
| 5 | Error Prevention | ? | |
| 6 | Recognition Rather Than Recall | ? | |
| 7 | Flexibility and Efficiency | ? | |
| 8 | Aesthetic and Minimalist Design | ? | |
| 9 | Error Recovery | ? | |
| 10 | Help and Documentation | ? | |
| **Total** | | **??/40** | **[Rating band]** |
Be honest with scores. A 4 means genuinely excellent. Most real interfaces score 20-32.
#### Anti-Patterns Verdict
**Start here.** Does this look AI-generated?
**LLM assessment**: Your own evaluation of AI slop tells. Cover overall aesthetic feel, layout sameness, generic composition, missed opportunities for personality.
**Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives.
**Visual overlays** (if browser was used): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported.
#### Overall Impression
A brief gut reaction: what works, what doesn't, and the single biggest opportunity.
#### What's Working
Highlight 2-3 things done well. Be specific about why they work.
#### Priority Issues
The 3-5 most impactful design problems, ordered by importance.
For each issue, tag with **P0-P3 severity** (consult [heuristics-scoring](reference/heuristics-scoring.md) for severity definitions):
- **[P?] What**: Name the problem clearly
- **Why it matters**: How this hurts users or undermines goals
- **Fix**: What to do about it (be concrete)
- **Suggested command**: Which command could address this (from: /animate, /quieter, /shape, /optimize, /adapt, /clarify, /layout, /distill, /delight, /audit, /harden, /polish, /bolder, /typeset, /critique, /colorize, /overdrive)
#### Persona Red Flags
> *Consult [personas](reference/personas.md)*
Auto-select 2-3 personas most relevant to this interface type (use the selection table in the reference). If `CLAUDE.md` contains a `## Design Context` section from `impeccable teach`, also generate 1-2 project-specific personas from the audience/brand info.
For each selected persona, walk through the primary user action and list specific red flags found:
**Alex (Power User)**: No keyboard shortcuts detected. Form requires 8 clicks for primary action. Forced modal onboarding. High abandonment risk.
**Jordan (First-Timer)**: Icon-only nav in sidebar. Technical jargon in error messages ("404 Not Found"). No visible help. Will abandon at step 2.
Be specific. Name the exact elements and interactions that fail each persona. Don't write generic persona descriptions; write what broke for them.
#### Minor Observations
Quick notes on smaller issues worth addressing.
#### Questions to Consider
Provocative questions that might unlock better solutions:
- "What if the primary action were more prominent?"
- "Does this need to feel this complex?"
- "What would a confident version of this look like?"
**Remember**:
- Be direct. Vague feedback wastes everyone's time.
- Be specific. "The submit button," not "some elements."
- Say what's wrong AND why it matters to users.
- Give concrete suggestions, not just "consider exploring..."
- Prioritize ruthlessly. If everything is important, nothing is.
- Don't soften criticism. Developers need honest feedback to ship great design.
### Step 4: Ask the User
**After presenting findings**, use targeted questions based on what was actually found. STOP and call the AskUserQuestion tool to clarify. These answers will shape the action plan.
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
1. **Priority direction**: Based on the issues found, ask which category matters most to the user right now. For example: "I found problems with visual hierarchy, color usage, and information overload. Which area should we tackle first?" Offer the top 2-3 issue categories as options.
2. **Design intent**: If the critique found a tonal mismatch, ask whether it was intentional. For example: "The interface feels clinical and corporate. Is that the intended tone, or should it feel warmer/bolder/more playful?" Offer 2-3 tonal directions as options based on what would fix the issues found.
3. **Scope**: Ask how much the user wants to take on. For example: "I found N issues. Want to address everything, or focus on the top 3?" Offer scope options like "Top 3 only", "All issues", "Critical issues only".
4. **Constraints** (optional; only ask if relevant): If the findings touch many areas, ask if anything is off-limits. For example: "Should any sections stay as-is?" This prevents the plan from touching things the user considers done.
**Rules for questions**:
- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
- Keep it to 2-4 questions maximum. Respect the user's time.
- Offer concrete options, not open-ended prompts.
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Step 5.
### Step 5: Recommended Actions
**After receiving the user's answers**, present a prioritized action summary reflecting the user's priorities and scope from Step 4.
#### Action Summary
List recommended commands in priority order, based on the user's answers:
1. **`/command-name`**: Brief description of what to fix (specific context from critique findings)
2. **`/command-name`**: Brief description (specific context)
...
**Rules for recommendations**:
- Only recommend commands from: /animate, /quieter, /shape, /optimize, /adapt, /clarify, /layout, /distill, /delight, /audit, /harden, /polish, /bolder, /typeset, /critique, /colorize, /overdrive
- Order by the user's stated priorities first, then by impact
- Each item's description should carry enough context that the command knows what to focus on
- Map each Priority Issue to the appropriate command
- Skip commands that would address zero issues
- If the user chose a limited scope, only include items within that scope
- If the user marked areas as off-limits, exclude commands that would touch those areas
- End with `/polish` as the final step if any fixes were recommended
After presenting the summary, tell the user:
> You can ask me to run these one at a time, all at once, or in any order you prefer.
>
> Re-run `/critique` after fixes to see your score improve.

View file

@ -0,0 +1,106 @@
# Cognitive Load Assessment
Cognitive load is the total mental effort required to use an interface. Overloaded users make mistakes, get frustrated, and leave. This reference helps identify and fix cognitive overload.
---
## Three Types of Cognitive Load
### Intrinsic Load — The Task Itself
Complexity inherent to what the user is trying to do. You can't eliminate this, but you can structure it.
**Manage it by**:
- Breaking complex tasks into discrete steps
- Providing scaffolding (templates, defaults, examples)
- Progressive disclosure — show what's needed now, hide the rest
- Grouping related decisions together
### Extraneous Load — Bad Design
Mental effort caused by poor design choices. **Eliminate this ruthlessly** — it's pure waste.
**Common sources**:
- Confusing navigation that requires mental mapping
- Unclear labels that force users to guess meaning
- Visual clutter competing for attention
- Inconsistent patterns that prevent learning
- Unnecessary steps between user intent and result
### Germane Load — Learning Effort
Mental effort spent building understanding. This is *good* cognitive load — it leads to mastery.
**Support it by**:
- Progressive disclosure that reveals complexity gradually
- Consistent patterns that reward learning
- Feedback that confirms correct understanding
- Onboarding that teaches through action, not walls of text
---
## Cognitive Load Checklist
Evaluate the interface against these 8 items:
- [ ] **Single focus**: Can the user complete their primary task without distraction from competing elements?
- [ ] **Chunking**: Is information presented in digestible groups (≤4 items per group)?
- [ ] **Grouping**: Are related items visually grouped together (proximity, borders, shared background)?
- [ ] **Visual hierarchy**: Is it immediately clear what's most important on the screen?
- [ ] **One thing at a time**: Can the user focus on a single decision before moving to the next?
- [ ] **Minimal choices**: Are decisions simplified (≤4 visible options at any decision point)?
- [ ] **Working memory**: Does the user need to remember information from a previous screen to act on the current one?
- [ ] **Progressive disclosure**: Is complexity revealed only when the user needs it?
**Scoring**: Count the failed items. 01 failures = low cognitive load (good). 23 = moderate (address soon). 4+ = high cognitive load (critical fix needed).
---
## The Working Memory Rule
**Humans can hold ≤4 items in working memory at once** (Miller's Law revised by Cowan, 2001).
At any decision point, count the number of distinct options, actions, or pieces of information a user must simultaneously consider:
- **≤4 items**: Within working memory limits — manageable
- **57 items**: Pushing the boundary — consider grouping or progressive disclosure
- **8+ items**: Overloaded — users will skip, misclick, or abandon
**Practical applications**:
- Navigation menus: ≤5 top-level items (group the rest under clear categories)
- Form sections: ≤4 fields visible per group before a visual break
- Action buttons: 1 primary, 12 secondary, group the rest in a menu
- Dashboard widgets: ≤4 key metrics visible without scrolling
- Pricing tiers: ≤3 options (more causes analysis paralysis)
---
## Common Cognitive Load Violations
### 1. The Wall of Options
**Problem**: Presenting 10+ choices at once with no hierarchy.
**Fix**: Group into categories, highlight recommended, use progressive disclosure.
### 2. The Memory Bridge
**Problem**: User must remember info from step 1 to complete step 3.
**Fix**: Keep relevant context visible, or repeat it where it's needed.
### 3. The Hidden Navigation
**Problem**: User must build a mental map of where things are.
**Fix**: Always show current location (breadcrumbs, active states, progress indicators).
### 4. The Jargon Barrier
**Problem**: Technical or domain language forces translation effort.
**Fix**: Use plain language. If domain terms are unavoidable, define them inline.
### 5. The Visual Noise Floor
**Problem**: Every element has the same visual weight — nothing stands out.
**Fix**: Establish clear hierarchy: one primary element, 23 secondary, everything else muted.
### 6. The Inconsistent Pattern
**Problem**: Similar actions work differently in different places.
**Fix**: Standardize interaction patterns. Same type of action = same type of UI.
### 7. The Multi-Task Demand
**Problem**: Interface requires processing multiple simultaneous inputs (reading + deciding + navigating).
**Fix**: Sequence the steps. Let the user do one thing at a time.
### 8. The Context Switch
**Problem**: User must jump between screens/tabs/modals to gather info for a single decision.
**Fix**: Co-locate the information needed for each decision. Reduce back-and-forth.

View file

@ -0,0 +1,234 @@
# Heuristics Scoring Guide
Score each of Nielsen's 10 Usability Heuristics on a 04 scale. Be honest — a 4 means genuinely excellent, not "good enough."
## Nielsen's 10 Heuristics
### 1. Visibility of System Status
Keep users informed about what's happening through timely, appropriate feedback.
**Check for**:
- Loading indicators during async operations
- Confirmation of user actions (save, submit, delete)
- Progress indicators for multi-step processes
- Current location in navigation (breadcrumbs, active states)
- Form validation feedback (inline, not just on submit)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | No feedback — user is guessing what happened |
| 1 | Rare feedback — most actions produce no visible response |
| 2 | Partial — some states communicated, major gaps remain |
| 3 | Good — most operations give clear feedback, minor gaps |
| 4 | Excellent — every action confirms, progress is always visible |
### 2. Match Between System and Real World
Speak the user's language. Follow real-world conventions. Information appears in natural, logical order.
**Check for**:
- Familiar terminology (no unexplained jargon)
- Logical information order matching user expectations
- Recognizable icons and metaphors
- Domain-appropriate language for the target audience
- Natural reading flow (left-to-right, top-to-bottom priority)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Pure tech jargon, alien to users |
| 1 | Mostly confusing — requires domain expertise to navigate |
| 2 | Mixed — some plain language, some jargon leaks through |
| 3 | Mostly natural — occasional term needs context |
| 4 | Speaks the user's language fluently throughout |
### 3. User Control and Freedom
Users need a clear "emergency exit" from unwanted states without extended dialogue.
**Check for**:
- Undo/redo functionality
- Cancel buttons on forms and modals
- Clear navigation back to safety (home, previous)
- Easy way to clear filters, search, selections
- Escape from long or multi-step processes
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Users get trapped — no way out without refreshing |
| 1 | Difficult exits — must find obscure paths to escape |
| 2 | Some exits — main flows have escape, edge cases don't |
| 3 | Good control — users can exit and undo most actions |
| 4 | Full control — undo, cancel, back, and escape everywhere |
### 4. Consistency and Standards
Users shouldn't wonder whether different words, situations, or actions mean the same thing.
**Check for**:
- Consistent terminology throughout the interface
- Same actions produce same results everywhere
- Platform conventions followed (standard UI patterns)
- Visual consistency (colors, typography, spacing, components)
- Consistent interaction patterns (same gesture = same behavior)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Inconsistent everywhere — feels like different products stitched together |
| 1 | Many inconsistencies — similar things look/behave differently |
| 2 | Partially consistent — main flows match, details diverge |
| 3 | Mostly consistent — occasional deviation, nothing confusing |
| 4 | Fully consistent — cohesive system, predictable behavior |
### 5. Error Prevention
Better than good error messages is a design that prevents problems in the first place.
**Check for**:
- Confirmation before destructive actions (delete, overwrite)
- Constraints preventing invalid input (date pickers, dropdowns)
- Smart defaults that reduce errors
- Clear labels that prevent misunderstanding
- Autosave and draft recovery
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Errors easy to make — no guardrails anywhere |
| 1 | Few safeguards — some inputs validated, most aren't |
| 2 | Partial prevention — common errors caught, edge cases slip |
| 3 | Good prevention — most error paths blocked proactively |
| 4 | Excellent — errors nearly impossible through smart constraints |
### 6. Recognition Rather Than Recall
Minimize memory load. Make objects, actions, and options visible or easily retrievable.
**Check for**:
- Visible options (not buried in hidden menus)
- Contextual help when needed (tooltips, inline hints)
- Recent items and history
- Autocomplete and suggestions
- Labels on icons (not icon-only navigation)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Heavy memorization — users must remember paths and commands |
| 1 | Mostly recall — many hidden features, few visible cues |
| 2 | Some aids — main actions visible, secondary features hidden |
| 3 | Good recognition — most things discoverable, few memory demands |
| 4 | Everything discoverable — users never need to memorize |
### 7. Flexibility and Efficiency of Use
Accelerators — invisible to novices — speed up expert interaction.
**Check for**:
- Keyboard shortcuts for common actions
- Customizable interface elements
- Recent items and favorites
- Bulk/batch actions
- Power user features that don't complicate the basics
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | One rigid path — no shortcuts or alternatives |
| 1 | Limited flexibility — few alternatives to the main path |
| 2 | Some shortcuts — basic keyboard support, limited bulk actions |
| 3 | Good accelerators — keyboard nav, some customization |
| 4 | Highly flexible — multiple paths, power features, customizable |
### 8. Aesthetic and Minimalist Design
Interfaces should not contain irrelevant or rarely needed information. Every element should serve a purpose.
**Check for**:
- Only necessary information visible at each step
- Clear visual hierarchy directing attention
- Purposeful use of color and emphasis
- No decorative clutter competing for attention
- Focused, uncluttered layouts
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Overwhelming — everything competes for attention equally |
| 1 | Cluttered — too much noise, hard to find what matters |
| 2 | Some clutter — main content clear, periphery noisy |
| 3 | Mostly clean — focused design, minor visual noise |
| 4 | Perfectly minimal — every element earns its pixel |
### 9. Help Users Recognize, Diagnose, and Recover from Errors
Error messages should use plain language, precisely indicate the problem, and constructively suggest a solution.
**Check for**:
- Plain language error messages (no error codes for users)
- Specific problem identification ("Email is missing @" not "Invalid input")
- Actionable recovery suggestions
- Errors displayed near the source of the problem
- Non-blocking error handling (don't wipe the form)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Cryptic errors — codes, jargon, or no message at all |
| 1 | Vague errors — "Something went wrong" with no guidance |
| 2 | Clear but unhelpful — names the problem but not the fix |
| 3 | Clear with suggestions — identifies problem and offers next steps |
| 4 | Perfect recovery — pinpoints issue, suggests fix, preserves user work |
### 10. Help and Documentation
Even if the system is usable without docs, help should be easy to find, task-focused, and concise.
**Check for**:
- Searchable help or documentation
- Contextual help (tooltips, inline hints, guided tours)
- Task-focused organization (not feature-organized)
- Concise, scannable content
- Easy access without leaving current context
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | No help available anywhere |
| 1 | Help exists but hard to find or irrelevant |
| 2 | Basic help — FAQ or docs exist, not contextual |
| 3 | Good documentation — searchable, mostly task-focused |
| 4 | Excellent contextual help — right info at the right moment |
---
## Score Summary
**Total possible**: 40 points (10 heuristics × 4 max)
| Score Range | Rating | What It Means |
|-------------|--------|---------------|
| 3640 | Excellent | Minor polish only — ship it |
| 2835 | Good | Address weak areas, solid foundation |
| 2027 | Acceptable | Significant improvements needed before users are happy |
| 1219 | Poor | Major UX overhaul required — core experience broken |
| 011 | Critical | Redesign needed — unusable in current state |
---
## Issue Severity (P0P3)
Tag each individual issue found during scoring with a priority level:
| Priority | Name | Description | Action |
|----------|------|-------------|--------|
| **P0** | Blocking | Prevents task completion entirely | Fix immediately — this is a showstopper |
| **P1** | Major | Causes significant difficulty or confusion | Fix before release |
| **P2** | Minor | Annoyance, but workaround exists | Fix in next pass |
| **P3** | Polish | Nice-to-fix, no real user impact | Fix if time permits |
**Tip**: If you're unsure between two levels, ask: "Would a user contact support about this?" If yes, it's at least P1.

View file

@ -0,0 +1,178 @@
# Persona-Based Design Testing
Test the interface through the eyes of 5 distinct user archetypes. Each persona exposes different failure modes that a single "design director" perspective would miss.
**How to use**: Select 23 personas most relevant to the interface being critiqued. Walk through the primary user action as each persona. Report specific red flags — not generic concerns.
---
## 1. Impatient Power User — "Alex"
**Profile**: Expert with similar products. Expects efficiency, hates hand-holding. Will find shortcuts or leave.
**Behaviors**:
- Skips all onboarding and instructions
- Looks for keyboard shortcuts immediately
- Tries to bulk-select, batch-edit, and automate
- Gets frustrated by required steps that feel unnecessary
- Abandons if anything feels slow or patronizing
**Test Questions**:
- Can Alex complete the core task in under 60 seconds?
- Are there keyboard shortcuts for common actions?
- Can onboarding be skipped entirely?
- Do modals have keyboard dismiss (Esc)?
- Is there a "power user" path (shortcuts, bulk actions)?
**Red Flags** (report these specifically):
- Forced tutorials or unskippable onboarding
- No keyboard navigation for primary actions
- Slow animations that can't be skipped
- One-item-at-a-time workflows where batch would be natural
- Redundant confirmation steps for low-risk actions
---
## 2. Confused First-Timer — "Jordan"
**Profile**: Never used this type of product. Needs guidance at every step. Will abandon rather than figure it out.
**Behaviors**:
- Reads all instructions carefully
- Hesitates before clicking anything unfamiliar
- Looks for help or support constantly
- Misunderstands jargon and abbreviations
- Takes the most literal interpretation of any label
**Test Questions**:
- Is the first action obviously clear within 5 seconds?
- Are all icons labeled with text?
- Is there contextual help at decision points?
- Does terminology assume prior knowledge?
- Is there a clear "back" or "undo" at every step?
**Red Flags** (report these specifically):
- Icon-only navigation with no labels
- Technical jargon without explanation
- No visible help option or guidance
- Ambiguous next steps after completing an action
- No confirmation that an action succeeded
---
## 3. Accessibility-Dependent User — "Sam"
**Profile**: Uses screen reader (VoiceOver/NVDA), keyboard-only navigation. May have low vision, motor impairment, or cognitive differences.
**Behaviors**:
- Tabs through the interface linearly
- Relies on ARIA labels and heading structure
- Cannot see hover states or visual-only indicators
- Needs adequate color contrast (4.5:1 minimum)
- May use browser zoom up to 200%
**Test Questions**:
- Can the entire primary flow be completed keyboard-only?
- Are all interactive elements focusable with visible focus indicators?
- Do images have meaningful alt text?
- Is color contrast WCAG AA compliant (4.5:1 for text)?
- Does the screen reader announce state changes (loading, success, errors)?
**Red Flags** (report these specifically):
- Click-only interactions with no keyboard alternative
- Missing or invisible focus indicators
- Meaning conveyed by color alone (red = error, green = success)
- Unlabeled form fields or buttons
- Time-limited actions without extension option
- Custom components that break screen reader flow
---
## 4. Deliberate Stress Tester — "Riley"
**Profile**: Methodical user who pushes interfaces beyond the happy path. Tests edge cases, tries unexpected inputs, and probes for gaps in the experience.
**Behaviors**:
- Tests edge cases intentionally (empty states, long strings, special characters)
- Submits forms with unexpected data (emoji, RTL text, very long values)
- Tries to break workflows by navigating backwards, refreshing mid-flow, or opening in multiple tabs
- Looks for inconsistencies between what the UI promises and what actually happens
- Documents problems methodically
**Test Questions**:
- What happens at the edges (0 items, 1000 items, very long text)?
- Do error states recover gracefully or leave the UI in a broken state?
- What happens on refresh mid-workflow? Is state preserved?
- Are there features that appear to work but produce broken results?
- How does the UI handle unexpected input (emoji, special chars, paste from Excel)?
**Red Flags** (report these specifically):
- Features that appear to work but silently fail or produce wrong results
- Error handling that exposes technical details or leaves UI in a broken state
- Empty states that show nothing useful ("No results" with no guidance)
- Workflows that lose user data on refresh or navigation
- Inconsistent behavior between similar interactions in different parts of the UI
---
## 5. Distracted Mobile User — "Casey"
**Profile**: Using phone one-handed on the go. Frequently interrupted. Possibly on a slow connection.
**Behaviors**:
- Uses thumb only — prefers bottom-of-screen actions
- Gets interrupted mid-flow and returns later
- Switches between apps frequently
- Has limited attention span and low patience
- Types as little as possible, prefers taps and selections
**Test Questions**:
- Are primary actions in the thumb zone (bottom half of screen)?
- Is state preserved if the user leaves and returns?
- Does it work on slow connections (3G)?
- Can forms leverage autocomplete and smart defaults?
- Are touch targets at least 44×44pt?
**Red Flags** (report these specifically):
- Important actions positioned at the top of the screen (unreachable by thumb)
- No state persistence — progress lost on tab switch or interruption
- Large text inputs required where selection would work
- Heavy assets loading on every page (no lazy loading)
- Tiny tap targets or targets too close together
---
## Selecting Personas
Choose personas based on the interface type:
| Interface Type | Primary Personas | Why |
|---------------|-----------------|-----|
| Landing page / marketing | Jordan, Riley, Casey | First impressions, trust, mobile |
| Dashboard / admin | Alex, Sam | Power users, accessibility |
| E-commerce / checkout | Casey, Riley, Jordan | Mobile, edge cases, clarity |
| Onboarding flow | Jordan, Casey | Confusion, interruption |
| Data-heavy / analytics | Alex, Sam | Efficiency, keyboard nav |
| Form-heavy / wizard | Jordan, Sam, Casey | Clarity, accessibility, mobile |
---
## Project-Specific Personas
If `CLAUDE.md` contains a `## Design Context` section (generated by `impeccable teach`), derive 12 additional personas from the audience and brand information:
1. Read the target audience description
2. Identify the primary user archetype not covered by the 5 predefined personas
3. Create a persona following this template:
```
### [Role] — "[Name]"
**Profile**: [2-3 key characteristics derived from Design Context]
**Behaviors**: [3-4 specific behaviors based on the described audience]
**Red Flags**: [3-4 things that would alienate this specific user type]
```
Only generate project-specific personas when real Design Context data is available. Don't invent audience details — use the 5 predefined personas when no context exists.

View file

@ -0,0 +1,122 @@
---
name: distill
description: Strip designs to their essence by removing unnecessary complexity. Great design is simple, powerful, and clean. Use when the user asks to simplify, declutter, reduce noise, remove elements, or make a UI cleaner and more focused.
version: 2.1.1
user-invocable: true
argument-hint: "[target]"
---
Remove unnecessary complexity from designs, revealing the essential elements and creating clarity through ruthless simplification.
## MANDATORY PREPARATION
Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first.
---
## Assess Current State
Analyze what makes the design feel complex or cluttered:
1. **Identify complexity sources**:
- **Too many elements**: Competing buttons, redundant information, visual clutter
- **Excessive variation**: Too many colors, fonts, sizes, styles without purpose
- **Information overload**: Everything visible at once, no progressive disclosure
- **Visual noise**: Unnecessary borders, shadows, backgrounds, decorations
- **Confusing hierarchy**: Unclear what matters most
- **Feature creep**: Too many options, actions, or paths forward
2. **Find the essence**:
- What's the primary user goal? (There should be ONE)
- What's actually necessary vs nice-to-have?
- What can be removed, hidden, or combined?
- What's the 20% that delivers 80% of value?
If any of these are unclear from the codebase, STOP and call the AskUserQuestion tool to clarify.
**CRITICAL**: Simplicity is not about removing features - it's about removing obstacles between users and their goals. Every element should justify its existence.
## Plan Simplification
Create a ruthless editing strategy:
- **Core purpose**: What's the ONE thing this should accomplish?
- **Essential elements**: What's truly necessary to achieve that purpose?
- **Progressive disclosure**: What can be hidden until needed?
- **Consolidation opportunities**: What can be combined or integrated?
**IMPORTANT**: Simplification is hard. It requires saying no to good ideas to make room for great execution. Be ruthless.
## Simplify the Design
Systematically remove complexity across these dimensions:
### Information Architecture
- **Reduce scope**: Remove secondary actions, optional features, redundant information
- **Progressive disclosure**: Hide complexity behind clear entry points (accordions, modals, step-through flows)
- **Combine related actions**: Merge similar buttons, consolidate forms, group related content
- **Clear hierarchy**: ONE primary action, few secondary actions, everything else tertiary or hidden
- **Remove redundancy**: If it's said elsewhere, don't repeat it here
### Visual Simplification
- **Reduce color palette**: Use 1-2 colors plus neutrals, not 5-7 colors
- **Limit typography**: One font family, 3-4 sizes maximum, 2-3 weights
- **Remove decorations**: Eliminate borders, shadows, backgrounds that don't serve hierarchy or function
- **Flatten structure**: Reduce nesting, remove unnecessary containers—never nest cards inside cards
- **Remove unnecessary cards**: Cards aren't needed for basic layout; use spacing and alignment instead
- **Consistent spacing**: Use one spacing scale, remove arbitrary gaps
### Layout Simplification
- **Linear flow**: Replace complex grids with simple vertical flow where possible
- **Remove sidebars**: Move secondary content inline or hide it
- **Full-width**: Use available space generously instead of complex multi-column layouts
- **Consistent alignment**: Pick left or center, stick with it
- **Generous white space**: Let content breathe, don't pack everything tight
### Interaction Simplification
- **Reduce choices**: Fewer buttons, fewer options, clearer path forward (paradox of choice is real)
- **Smart defaults**: Make common choices automatic, only ask when necessary
- **Inline actions**: Replace modal flows with inline editing where possible
- **Remove steps**: Can signup be one step instead of three? Can checkout be simplified?
- **Clear CTAs**: ONE obvious next step, not five competing actions
### Content Simplification
- **Shorter copy**: Cut every sentence in half, then do it again
- **Active voice**: "Save changes" not "Changes will be saved"
- **Remove jargon**: Plain language always wins
- **Scannable structure**: Short paragraphs, bullet points, clear headings
- **Essential information only**: Remove marketing fluff, legalese, hedging
- **Remove redundant copy**: No headers restating intros, no repeated explanations, say it once
### Code Simplification
- **Remove unused code**: Dead CSS, unused components, orphaned files
- **Flatten component trees**: Reduce nesting depth
- **Consolidate styles**: Merge similar styles, use utilities consistently
- **Reduce variants**: Does that component need 12 variations, or can 3 cover 90% of cases?
**NEVER**:
- Remove necessary functionality (simplicity ≠ feature-less)
- Sacrifice accessibility for simplicity (clear labels and ARIA still required)
- Make things so simple they're unclear (mystery ≠ minimalism)
- Remove information users need to make decisions
- Eliminate hierarchy completely (some things should stand out)
- Oversimplify complex domains (match complexity to actual task complexity)
## Verify Simplification
Ensure simplification improves usability:
- **Faster task completion**: Can users accomplish goals more quickly?
- **Reduced cognitive load**: Is it easier to understand what to do?
- **Still complete**: Are all necessary features still accessible?
- **Clearer hierarchy**: Is it obvious what matters most?
- **Better performance**: Does simpler design load faster?
## Document Removed Complexity
If you removed features or options:
- Document why they were removed
- Consider if they need alternative access points
- Note any user feedback to monitor
Remember: You have great taste and judgment. Simplification is an act of confidence - knowing what to keep and courage to remove the rest. As Antoine de Saint-Exupéry said: "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away."

View file

@ -0,0 +1,125 @@
---
name: layout
description: Improve layout, spacing, and visual rhythm. Fixes monotonous grids, inconsistent spacing, and weak visual hierarchy. Use when the user mentions layout feeling off, spacing issues, visual hierarchy, crowded UI, alignment problems, or wanting better composition.
version: 2.1.1
user-invocable: true
argument-hint: "[target]"
---
Assess and improve layout and spacing that feels monotonous, crowded, or structurally weak — turning generic arrangements into intentional, rhythmic compositions.
## MANDATORY PREPARATION
Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first.
---
## Assess Current Layout
Analyze what's weak about the current spatial design:
1. **Spacing**:
- Is spacing consistent or arbitrary? (Random padding/margin values)
- Is all spacing the same? (Equal padding everywhere = no rhythm)
- Are related elements grouped tightly, with generous space between groups?
2. **Visual hierarchy**:
- Apply the squint test: blur your (metaphorical) eyes — can you still identify the most important element, second most important, and clear groupings?
- Is hierarchy achieved effectively? (Space and weight alone can be enough — but is the current approach working?)
- Does whitespace guide the eye to what matters?
3. **Grid & structure**:
- Is there a clear underlying structure, or does the layout feel random?
- Are identical card grids used everywhere? (Icon + heading + text, repeated endlessly)
- Is everything centered? (Left-aligned with asymmetric layouts feels more designed, but not a hard and fast rule)
4. **Rhythm & variety**:
- Does the layout have visual rhythm? (Alternating tight/generous spacing)
- Is every section structured the same way? (Monotonous repetition)
- Are there intentional moments of surprise or emphasis?
5. **Density**:
- Is the layout too cramped? (Not enough breathing room)
- Is the layout too sparse? (Excessive whitespace without purpose)
- Does density match the content type? (Data-dense UIs need tighter spacing; marketing pages need more air)
**CRITICAL**: Layout problems are often the root cause of interfaces feeling "off" even when colors and fonts are fine. Space is a design material — use it with intention.
## Plan Layout Improvements
Consult the [spatial design reference](reference/spatial-design.md) from the impeccable skill for detailed guidance on grids, rhythm, and container queries.
Create a systematic plan:
- **Spacing system**: Use a consistent scale — whether that's a framework's built-in scale (e.g., Tailwind), rem-based tokens, or a custom system. The specific values matter less than consistency.
- **Hierarchy strategy**: How will space communicate importance?
- **Layout approach**: What structure fits the content? Flex for 1D, Grid for 2D, named areas for complex page layouts.
- **Rhythm**: Where should spacing be tight vs generous?
## Improve Layout Systematically
### Establish a Spacing System
- Use a consistent spacing scale — framework scales (Tailwind, etc.), rem-based tokens, or a custom scale all work. What matters is that values come from a defined set, not arbitrary numbers.
- Name tokens semantically if using custom properties: `--space-xs` through `--space-xl`, not `--spacing-8`
- Use `gap` for sibling spacing instead of margins — eliminates margin collapse hacks
- Apply `clamp()` for fluid spacing that breathes on larger screens
### Create Visual Rhythm
- **Tight grouping** for related elements (8-12px between siblings)
- **Generous separation** between distinct sections (48-96px)
- **Varied spacing** within sections — not every row needs the same gap
- **Asymmetric compositions** — break the predictable centered-content pattern when it makes sense
### Choose the Right Layout Tool
- **Use Flexbox for 1D layouts**: Rows of items, nav bars, button groups, card contents, most component internals. Flex is simpler and more appropriate for the majority of layout tasks.
- **Use Grid for 2D layouts**: Page-level structure, dashboards, data-dense interfaces, anything where rows AND columns need coordinated control.
- **Don't default to Grid** when Flexbox with `flex-wrap` would be simpler and more flexible.
- Use `repeat(auto-fit, minmax(280px, 1fr))` for responsive grids without breakpoints.
- Use named grid areas (`grid-template-areas`) for complex page layouts — redefine at breakpoints.
### Break Card Grid Monotony
- Don't default to card grids for everything — spacing and alignment create visual grouping naturally
- Use cards only when content is truly distinct and actionable — never nest cards inside cards
- Vary card sizes, span columns, or mix cards with non-card content to break repetition
### Strengthen Visual Hierarchy
- Use the fewest dimensions needed for clear hierarchy. Space alone can be enough — generous whitespace around an element draws the eye. Some of the most sophisticated designs achieve rhythm with just space and weight. Add color or size contrast only when simpler means aren't sufficient.
- Be aware of reading flow — in LTR languages, the eye naturally scans top-left to bottom-right, but primary action placement depends on context (e.g., bottom-right in dialogs, top in navigation).
- Create clear content groupings through proximity and separation.
### Manage Depth & Elevation
- Create a semantic z-index scale (dropdown → sticky → modal-backdrop → modal → toast → tooltip)
- Build a consistent shadow scale (sm → md → lg → xl) — shadows should be subtle
- Use elevation to reinforce hierarchy, not as decoration
### Optical Adjustments
- If an icon looks visually off-center despite being geometrically centered, nudge it — but only if you're confident it actually looks wrong. Don't adjust speculatively.
**NEVER**:
- Use arbitrary spacing values outside your scale
- Make all spacing equal — variety creates hierarchy
- Wrap everything in cards — not everything needs a container
- Nest cards inside cards — use spacing and dividers for hierarchy within
- Use identical card grids everywhere (icon + heading + text, repeated)
- Center everything — left-aligned with asymmetry feels more designed
- Default to the hero metric layout (big number, small label, stats, gradient) as a template. If showing real user data, a prominent metric can work — but it should display actual data, not decorative numbers.
- Default to CSS Grid when Flexbox would be simpler — use the simplest tool for the job
- Use arbitrary z-index values (999, 9999) — build a semantic scale
## Verify Layout Improvements
- **Squint test**: Can you identify primary, secondary, and groupings with blurred vision?
- **Rhythm**: Does the page have a satisfying beat of tight and generous spacing?
- **Hierarchy**: Is the most important content obvious within 2 seconds?
- **Breathing room**: Does the layout feel comfortable, not cramped or wasteful?
- **Consistency**: Is the spacing system applied uniformly?
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.

View file

@ -0,0 +1,224 @@
---
name: polish
description: Performs a final quality pass fixing alignment, spacing, consistency, and micro-detail issues before shipping. Use when the user mentions polish, finishing touches, pre-launch review, something looks off, or wants to go from good to great.
version: 2.1.1
user-invocable: true
argument-hint: "[target]"
---
## MANDATORY PREPARATION
Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first. Additionally gather: quality bar (MVP vs flagship).
---
Perform a meticulous final pass to catch all the small details that separate good work from great work. The difference between shipped and polished.
## Design System Discovery
Before polishing, understand the system you are polishing toward:
1. **Find the design system**: Search for design system documentation, component libraries, style guides, or token definitions. Study the core patterns: color tokens, spacing scale, typography styles, component API.
2. **Note the conventions**: How are shared components imported? What spacing scale is used? Which colors come from tokens vs hard-coded values? What motion and interaction patterns are established?
3. **Identify drift**: Where does the target feature deviate from the system? Hard-coded values that should be tokens, custom components that duplicate shared ones, spacing that doesn't match the scale.
If a design system exists, polish should align the feature with it. If none exists, polish against the conventions visible in the codebase.
## Pre-Polish Assessment
Understand the current state and goals:
1. **Review completeness**:
- Is it functionally complete?
- Are there known issues to preserve (mark with TODOs)?
- What's the quality bar? (MVP vs flagship feature?)
- When does it ship? (How much time for polish?)
2. **Identify polish areas**:
- Visual inconsistencies
- Spacing and alignment issues
- Interaction state gaps
- Copy inconsistencies
- Edge cases and error states
- Loading and transition smoothness
**CRITICAL**: Polish is the last step, not the first. Don't polish work that's not functionally complete.
## Polish Systematically
Work through these dimensions methodically:
### Visual Alignment & Spacing
- **Pixel-perfect alignment**: Everything lines up to grid
- **Consistent spacing**: All gaps use spacing scale (no random 13px gaps)
- **Optical alignment**: Adjust for visual weight (icons may need offset for optical centering)
- **Responsive consistency**: Spacing and alignment work at all breakpoints
- **Grid adherence**: Elements snap to baseline grid
**Check**:
- Enable grid overlay and verify alignment
- Check spacing with browser inspector
- Test at multiple viewport sizes
- Look for elements that "feel" off
### Typography Refinement
- **Hierarchy consistency**: Same elements use same sizes/weights throughout
- **Line length**: 45-75 characters for body text
- **Line height**: Appropriate for font size and context
- **Widows & orphans**: No single words on last line
- **Hyphenation**: Appropriate for language and column width
- **Kerning**: Adjust letter spacing where needed (especially headlines)
- **Font loading**: No FOUT/FOIT flashes
### Color & Contrast
- **Contrast ratios**: All text meets WCAG standards
- **Consistent token usage**: No hard-coded colors, all use design tokens
- **Theme consistency**: Works in all theme variants
- **Color meaning**: Same colors mean same things throughout
- **Accessible focus**: Focus indicators visible with sufficient contrast
- **Tinted neutrals**: No pure gray or pure black—add subtle color tint (0.01 chroma)
- **Gray on color**: Never put gray text on colored backgrounds—use a shade of that color or transparency
### Interaction States
Every interactive element needs all states:
- **Default**: Resting state
- **Hover**: Subtle feedback (color, scale, shadow)
- **Focus**: Keyboard focus indicator (never remove without replacement)
- **Active**: Click/tap feedback
- **Disabled**: Clearly non-interactive
- **Loading**: Async action feedback
- **Error**: Validation or error state
- **Success**: Successful completion
**Missing states create confusion and broken experiences**.
### Micro-interactions & Transitions
- **Smooth transitions**: All state changes animated appropriately (150-300ms)
- **Consistent easing**: Use ease-out-quart/quint/expo for natural deceleration. Never bounce or elastic—they feel dated.
- **No jank**: 60fps animations, only animate transform and opacity
- **Appropriate motion**: Motion serves purpose, not decoration
- **Reduced motion**: Respects `prefers-reduced-motion`
### Content & Copy
- **Consistent terminology**: Same things called same names throughout
- **Consistent capitalization**: Title Case vs Sentence case applied consistently
- **Grammar & spelling**: No typos
- **Appropriate length**: Not too wordy, not too terse
- **Punctuation consistency**: Periods on sentences, not on labels (unless all labels have them)
### Icons & Images
- **Consistent style**: All icons from same family or matching style
- **Appropriate sizing**: Icons sized consistently for context
- **Proper alignment**: Icons align with adjacent text optically
- **Alt text**: All images have descriptive alt text
- **Loading states**: Images don't cause layout shift, proper aspect ratios
- **Retina support**: 2x assets for high-DPI screens
### Forms & Inputs
- **Label consistency**: All inputs properly labeled
- **Required indicators**: Clear and consistent
- **Error messages**: Helpful and consistent
- **Tab order**: Logical keyboard navigation
- **Auto-focus**: Appropriate (don't overuse)
- **Validation timing**: Consistent (on blur vs on submit)
### Edge Cases & Error States
- **Loading states**: All async actions have loading feedback
- **Empty states**: Helpful empty states, not just blank space
- **Error states**: Clear error messages with recovery paths
- **Success states**: Confirmation of successful actions
- **Long content**: Handles very long names, descriptions, etc.
- **No content**: Handles missing data gracefully
- **Offline**: Appropriate offline handling (if applicable)
### Responsiveness
- **All breakpoints**: Test mobile, tablet, desktop
- **Touch targets**: 44x44px minimum on touch devices
- **Readable text**: No text smaller than 14px on mobile
- **No horizontal scroll**: Content fits viewport
- **Appropriate reflow**: Content adapts logically
### Performance
- **Fast initial load**: Optimize critical path
- **No layout shift**: Elements don't jump after load (CLS)
- **Smooth interactions**: No lag or jank
- **Optimized images**: Appropriate formats and sizes
- **Lazy loading**: Off-screen content loads lazily
### Code Quality
- **Remove console logs**: No debug logging in production
- **Remove commented code**: Clean up dead code
- **Remove unused imports**: Clean up unused dependencies
- **Consistent naming**: Variables and functions follow conventions
- **Type safety**: No TypeScript `any` or ignored errors
- **Accessibility**: Proper ARIA labels and semantic HTML
## Polish Checklist
Go through systematically:
- [ ] Visual alignment perfect at all breakpoints
- [ ] Spacing uses design tokens consistently
- [ ] Typography hierarchy consistent
- [ ] All interactive states implemented
- [ ] All transitions smooth (60fps)
- [ ] Copy is consistent and polished
- [ ] Icons are consistent and properly sized
- [ ] All forms properly labeled and validated
- [ ] Error states are helpful
- [ ] Loading states are clear
- [ ] Empty states are welcoming
- [ ] Touch targets are 44x44px minimum
- [ ] Contrast ratios meet WCAG AA
- [ ] Keyboard navigation works
- [ ] Focus indicators visible
- [ ] No console errors or warnings
- [ ] No layout shift on load
- [ ] Works in all supported browsers
- [ ] Respects reduced motion preference
- [ ] Code is clean (no TODOs, console.logs, commented code)
**IMPORTANT**: Polish is about details. Zoom in. Squint at it. Use it yourself. The little things add up.
**NEVER**:
- Polish before it's functionally complete
- Spend hours on polish if it ships in 30 minutes (triage)
- Introduce bugs while polishing (test thoroughly)
- Ignore systematic issues (if spacing is off everywhere, fix the system)
- Perfect one thing while leaving others rough (consistent quality level)
- Create new one-off components when design system equivalents exist
- Hard-code values that should use design tokens
## Final Verification
Before marking as done:
- **Use it yourself**: Actually interact with the feature
- **Test on real devices**: Not just browser DevTools
- **Ask someone else to review**: Fresh eyes catch things
- **Compare to design**: Match intended design
- **Check all states**: Don't just test happy path
## Clean Up
After polishing, ensure code quality:
- **Replace custom implementations**: If the design system provides a component you reimplemented, switch to the shared version.
- **Remove orphaned code**: Delete unused styles, components, or files made obsolete by polish.
- **Consolidate tokens**: If you introduced new values, check whether they should be tokens.
- **Verify DRYness**: Look for duplication introduced during polishing and consolidate.
Remember: You have impeccable attention to detail and exquisite taste. Polish until it feels effortless, looks intentional, and works flawlessly. Sweat the details - they matter.

View file

@ -1,5 +1,6 @@
# DEGA Core config — edit to match your project
version: 1
provider: github
max_iterations: 20
budget:

View file

@ -0,0 +1,48 @@
# Canon crash on nba-strategy: DuplicateIds in PipelineView
## Error
Running `canon .` in `/Users/cerratoa/dega/nba-strategy` crashes with:
```
DuplicateIds: Tried to insert a widget with ID 'pipeline-placeholder',
but a widget already exists with that ID
```
## Root cause
`PipelineView.render_flow()` calls `row.remove_children()` followed immediately by `row.mount(Static(..., id="pipeline-placeholder"))`. In Textual, `remove_children()` is **async** — it schedules removal but doesn't complete it synchronously. When `mount()` runs on the next line, the old `pipeline-placeholder` widget is still in the DOM, causing a duplicate ID collision.
The same pattern existed in `BuilderView._render_state()` for the log scroll container.
### Call chain
```
MainScreen._on_canon_updated()
→ _forward_canon_state(state)
→ BuilderView._render_state(state)
→ PipelineView.render_flow(state.flow) # flow=None for nba-strategy
→ row.remove_children() # async, not awaited!
→ row.mount(Static(id="pipeline-placeholder")) # BOOM: old one still exists
```
## Why nba-strategy triggers it
The nba-strategy canon state has `flow=None` (no pipeline flow data defined), so `render_flow` always hits the "no flow" placeholder branch. Other projects that define a flow in their `canon.yaml` might not hit this path.
## Fix applied
Made the entire render chain async:
| File | Change |
|------|--------|
| `src/toad/widgets/pipeline_view.py` | `render_flow``async`, `await remove_children()` and `await mount()` |
| `src/toad/widgets/builder_view.py` | `_render_state` and its event handler → `async`, `await` all child mutations |
| `src/toad/screens/main.py` | `_forward_canon_state` and `_on_canon_updated``async` |
Also batched individual `mount()` calls into `mount_all()` to reduce layout passes.
## Verification
- `uv run python tools/verify-tui.py --widget imports` passes
- `canon .` in nba-strategy renders without crash (tested with reinstall via `install.sh`)

View file

@ -0,0 +1,250 @@
# Canon Right-Pane Architecture
A structural redesign of the Canon TUI's right side, informed by the
phase-1 Tasks widget critique and a UX review against Nielsen heuristics
and TUI design patterns (Posting, k9s, Elia).
This is a decisions document — not an implementation plan. Decisions
here feed a subsequent execution plan.
---
## Design principles
These principles override any individual decision below if they conflict.
### 1. Chat-first, keyboard-second
Users should accomplish every task by **chatting with the agent**
("show me P1 tasks", "open the plan"), with keyboard shortcuts as a
secondary accelerator. Shortcuts that aren't obvious from the UI
(e.g. `Shift+click`, chorded keys, hidden modifiers) are forbidden.
If a feature only exists via a shortcut, it doesn't exist.
Every interaction must have **two paths**:
- **Chat:** a natural-language phrase the agent can route.
- **Click:** a visible affordance (button, tab, row) a mouse can hit.
Keyboard shortcuts are a third path, shown in tooltips or a footer.
Never the only path.
### 2. Clickable exits on every screen
Escape is a shortcut, not a contract. Every full-screen view, modal,
drill-down, or drawer must expose a **visible, clickable back/close**
affordance:
- Full-screen screens: a `← Back` button top-left, plus a `✕` top-right.
- Drawers/overlays: a `✕` close button.
- Inline detail views (replace pattern): a `← Back to list` button above
the content.
Escape still works — but it's never the only way out.
### 3. One thing on screen at a time
In a 50 %-wide pane on an 80-column terminal, splitting means cramming.
Default to showing **one thing at a time**, with explicit drill-downs
for depth. Multi-pane layouts are opt-in, not the default.
### 4. Progressive disclosure
Don't show every filter, field, and action at once. Show what 80 % of
users need immediately; put the rest one click away. Active filters
appear as removable chips; inactive filter controls live in a
"Filters" popover.
### 5. Anti-generic aesthetic
No decorative emojis. No gradients. No rounded corners where terminals
can't render them. Every glyph, color, and weight must earn its place.
(From `neo-user-journey` / `impeccable` skills.)
---
## Decisions
### D1. Collapse the aggregate "GitHub" tab
**Today:** The Planning section has a "GitHub" tab that stacks three
unrelated widgets (`StatusOverview`, `PlansView`, `PRsView`) into one
scrolling view.
**Decision:** Replace it with three siblings:
- **Board** (the new view, renamed from "Tasks")
- **Plans**
- **PRs**
`StatusOverview` moves to a 1-row summary strip docked at the top of the
Planning section — visible regardless of which tab is active.
**Why:** One tab per question. Users pick by intent, not by scrolling
through a grab-bag.
### D2. Rename "Tasks" → "Board"; add type filter chips
**Today:** Tasks shows every issue on project board #8, which confused
the reviewer who expected plans.
**Decision:**
- Rename tab to **Board** — 1:1 with GitHub Projects V2 terminology.
- Add a row of **type filter chips** above the table: `All · Plans · Bugs · Features`. Click a chip to filter. Multiple chips can be
stacked as additive filters.
- The existing Plans tab becomes redundant long-term — Board with
`type=Plans` preset gives the same view. Phase out `PlansView` after
the Board transition stabilises.
**Why:** Eliminates the "what even is a task?" confusion. The chip row
gives users an obvious click path to the common cases without digging
into the Filters popover.
### D3. Remove the left sidebar — move Plan + Files into the right pane
**Today:** `[SideBar][Conversation][ProjectStatePane]` — three columns.
On anything below 160 cols it's cramped, and the sidebar's Plan + Files
content competes with the right pane for attention.
**Decision:**
- Delete the left `SideBar` widget.
- Main layout becomes **`[Conversation][ProjectStatePane]`** — two
columns. Conversation claims ~50 %, right pane ~50 % by default;
users drag the divider to rebalance.
- Right pane grows a new **Context** section with tabs:
- **Plan** — the Plan widget.
- **Files** — the `ProjectDirectoryTree`.
- Right-pane toolbar now has three buttons: **Context · Planning · State**.
**Why:** Two columns scale to any terminal width. Plan and Files are
*contextual* references that support the conversation — they belong on
the same side as the other project-state content, not competing in a
separate rail.
### D4. Replace-pattern drill-down (no more horizontal split)
**Today:** Selecting a row splits the Tasks tab into table (3fr) +
detail (2fr). Inside a 50 %-wide pane that's ~25 cols per side.
**Decision:** **Replace, don't split.** When a user selects a row:
1. The table is replaced in-place by the detail view.
2. A **breadcrumb + Back button** appears at the top:
`← Back to Board #142 Wire tasks tab`
3. The detail view has full width of the Tasks tab.
4. Clicking **← Back** or pressing Escape returns to the table with the
previously-selected row still highlighted.
No separate pushed screen for most cases — the drill-down happens
*inside* the Tasks tab. A deeper drill (e.g. viewing comments, linked
PRs) does push `TaskDetailScreen`, which also gains a clickable
`← Back` button.
**Why:** Full width for the content users actually want to read.
Mirrors Posting's detail flow. Keeps the mental model simple (one
thing at a time).
### D5. Accordion sections with radio-style headers
**Today:** Toolbar buttons act like checkboxes — any subset of sections
can be simultaneously visible, stacked vertically. Two visible sections
inside a 50 %-wide pane × 50 % each = everything cramped.
**Decision:** **Accordion by default.**
- Section headers become full-width clickable bars showing the section
name and a small expand/collapse chevron.
- Clicking a header **expands that section and collapses all others**.
- Toolbar buttons at the top become a **radio-group** (one active at a
time). Visual state matches: the active button has the accent
background, others are muted.
- To show multiple sections, users click a **"Stack"** icon-button in
the pane toolbar (tooltip: *"Show multiple sections at once"*) which
switches to multi-expand mode — all sections visible, each at 1fr.
- No hidden shortcut. No Shift+click. The Stack button is the only way
to enter multi-view mode.
**Why:** Principle #1 (no hidden shortcuts). The default case (one
section) matches the most common user goal. Power users get a one-click
path to split-view.
### D6. Panel routing registry + filter-aware intents
**Today:** `main.py:_PANEL_MAP` is a hard-coded dict mapping panel IDs
to `(section_id, tab_id)` tuples. Adding a panel requires editing that
map + the agent prompt.
**Decision:**
- Introduce a **panel registry**: each right-pane section declares its
panel IDs + filter schema via a classmethod or module-level constant.
At startup, `MainScreen` scans registered sections and builds the
routing map dynamically.
- Extend the `open_panel` sessionUpdate schema:
```
{"open_panel": {
"panel": "board",
"filters": {"priority": "P1", "status": "in_progress"},
"highlight": "#142"
}}
```
Filters apply before the tab becomes visible. `highlight` focuses a
specific row if present.
- **New skill:** `.claude/skills/canon-panel-routing/SKILL.md`. Documents
the registry contract, the schema, worked examples for adding a new
panel ("Deployments panel in 4 steps"). Future panels become hours of
work, not days.
**Why:** The right pane will grow many panels over time (deployments,
metrics, logs, secrets, docs…). Making each one a single-file addition
with machine-discoverable filters is a forcing function for consistent
UX.
### D7. Every full-screen exit is clickable
Derived from Principle #2.
- `TaskDetailScreen` header grows a `← Back` button + a `✕` close button.
Both call `app.pop_screen`. Escape still works.
- Any future modal/drawer follows the same rule.
- Footer still shows the keyboard shortcut (`Back esc`) so keyboard
users have a hint — but the clickable affordance is primary.
---
## Out of scope (for this architecture round)
These are valid future concerns but not decided here:
- Theming / color palette — covered by `docs/pm-widget-polish.md`.
- Virtualized DataTable / caching — performance phase.
- Command palette `task:` prefix — future integration.
- Sparklines, relative dates, milestone progress glyphs — cosmetic.
---
## Success criteria for the redesign
When the decisions above are implemented:
1. A new user can discover every feature **without reading docs** — by
clicking around the UI and asking the agent for help.
2. The word "cramped" does not appear in a fresh critique against a
100-column terminal.
3. Every full-screen view has a visible way out that isn't Escape.
4. Adding a new right-pane panel takes < 1 hour for someone who has
read the `canon-panel-routing` skill.
5. The `show me X` chat commands support filters, not just panel names.
---
## References
- Phase-1 PRD: `docs/prd-pm-widget.md`
- Phase-1 oversight: `docs/pm-widget-oversight.md`
- Phase-1 polish backlog: `docs/pm-widget-polish.md`
- Phase-1 critique: issue #22, "Critique findings" comment
- Phase-1 PR: #23
- Nielsen heuristics: via `ux-journey-architect` skill
- TUI patterns: via `textual-tui` skill (DataTable, ContentSwitcher,
Screen stack, responsive layouts)

161
docs/pm-widget-oversight.md Normal file
View file

@ -0,0 +1,161 @@
# PM Widget — Oversight Plan
Migration guide from the claude-code-config conversation to canon-tui.
Open this file in a new Claude Code session at `/Users/cerratoa/dega/canon-tui`.
---
## Context
Carlos needs the Canon TUI to be an interactive PM dashboard — not just
a read-only Gantt chart. The PRD is at `docs/prd-pm-widget.md`. This
document covers the full execution sequence.
## Step 1: Install skills
Install these skills into `.claude/skills/` in this repo (canon-tui).
Workers will reference them during implementation.
### Required skills
| Skill | Source | Why |
|-------|--------|-----|
| textual-tui-skill | `https://github.com/aperepel/textual-tui-skill` | 40+ Textual widget patterns, layout system, styling |
| TUI Design System | `https://mcpmarket.com/es/tools/skills/tui-design-system` | Layout paradigms: Miller Columns, Widget Dashboards, Multi-panel |
| impeccable | `https://github.com/pbakaus/impeccable` | UX audit/polish — `/audit`, `/polish`, `/distill` commands |
| neo-user-journey | `https://github.com/Cornjebus/neo-user-journey` | Nielsen's heuristics scoring, anti-pattern detection |
### How to install
For each skill, fetch the main `.md` file and save it:
```bash
# Example for textual-tui-skill
curl -sL https://raw.githubusercontent.com/aperepel/textual-tui-skill/main/textual-tui.md \
-o .claude/skills/textual-tui.md
# Repeat for each skill — check each repo for the correct file name
```
Or tell Claude: "Install these skills into `.claude/skills/`" and give
it the table above. It will fetch and save them.
## Step 2: Create execution plan
Run `/plan` with a reference to the PRD:
```
/plan Implement the interactive PM widget per docs/prd-pm-widget.md
```
The plan should follow these constraints (from exec-plans rules):
- 4-8 items max
- Tests before implementation (TDD)
- Every item has `(deps: N)` annotations
- Shell-verifiable completion criteria
- Max 3 files per item
### Suggested plan structure
```
1. Data layer — task_provider.py (fetch full issue data from GitHub)
2. Tests — test_task_widgets.py with mocked gh output (deps: 1)
3. TaskTable widget — DataTable with row selection (deps: 1)
4. TaskDetail widget — ContentSwitcher with markdown body (deps: 1)
5. FilterToolbar widget — status/milestone/priority filtering (deps: 1)
6. Integration — wire into ProjectStatePane "Tasks" tab (deps: 2, 3, 4, 5)
7. Drill-down screen — TaskDetailScreen with push/pop (deps: 6)
8. Verify — verify-tui.py checks + smoke test (deps: 7)
```
Items 2-5 can run in parallel (all depend only on item 1).
## Step 3: Run the orchestrator
```bash
bash ~/.claude/scripts/orch-run.sh <YYYYMMDD-slug> --issue N
```
Workers spawn in worktrees, execute items respecting deps, verifier
agents run `verify-tui.py` + `pytest` + `ruff` after each item.
## Step 4: Verify
After orchestrator completes:
```bash
# Headless widget verification
uv run python tools/verify-tui.py --verbose
# Unit tests
pytest -q tests/test_task_widgets.py
# Lint + types
ruff check . && ty check
# Visual smoke test
uv run canon .
# → Open project state pane → Tasks tab → verify table loads
# → Select a row → detail panel appears
# → Press Enter on "View comments" → drill-down screen opens
# → Press Escape → returns to task list
```
## Textual patterns to use
These are the key patterns from the research. Workers should follow them.
### Master-detail (DataTable + ContentSwitcher)
```python
@on(DataTable.RowSelected)
def show_detail(self, event: DataTable.RowSelected) -> None:
issue = self.issues[event.row_key.value]
self.query_one(TaskDetail).load(issue)
self.query_one(ContentSwitcher).current = "detail"
```
### Drill-down (screen stack)
```python
class TaskDetailScreen(Screen):
BINDINGS = [("escape", "app.pop_screen", "Back")]
def __init__(self, issue_id: int) -> None:
super().__init__()
self.issue_id = issue_id
```
### Row keys = issue IDs
```python
table.add_row(status, title, milestone, priority, key=str(issue.id))
```
## Reference apps to study
Workers should look at these for layout and interaction patterns:
- **Posting** (posting.sh) — sidebar + detail panels, closest to our layout
- **Harlequin** — DataTable at scale
- **Elia** — master-detail navigation
## Key files in this repo
| Existing file | Relevance |
|---------------|-----------|
| `src/toad/widgets/project_state_pane.py` | Add "Tasks" tab here |
| `src/toad/widgets/github_views/github_timeline_provider.py` | Extend with issue detail fetch |
| `src/toad/widgets/github_views/timeline_data.py` | Data models to extend or parallel |
| `src/toad/widgets/github_views/fetch.py` | `_run_gh()` — reuse for all gh calls |
| `src/toad/widgets/gantt_timeline.py` | Reference for how existing widgets work |
| `tools/verify-tui.py` | Add new widget checks here |
| `dega-core.yaml` | GitHub project config (repo, project_number) |
## What NOT to do
- Don't edit the Gantt timeline or existing GitHub views — additive only
- Don't add issue editing (read-only for v1)
- Don't add natural language querying (future phase)
- Don't redesign the ProjectStatePane layout — just add a tab
- Don't add new dependencies unless absolutely necessary

157
docs/prd-pm-widget.md Normal file
View file

@ -0,0 +1,157 @@
# PRD: Interactive Project Management Widget
## Problem
The Canon TUI has a Gantt timeline and status overview, but they are
read-only. Carlos needs to manage work directly from the terminal:
see tasks, drill into details, and query project state — without
leaving Canon. Today he has to go to GitHub, which breaks flow.
## Users
- **Carlos (PM/founder)**: wants one place to see all tasks, click for
detail, drill deeper, and ask questions about project state.
- **Alberto (engineer)**: needs to see what's assigned, what's blocked,
and what to work on next — all inside the TUI where agents run.
## Desired outcome
A PM widget inside Canon TUI that supports:
1. **Task list view** — see all issues from the GitHub project board
in a DataTable with columns: status, title, milestone, priority,
assignee, effort.
2. **Detail panel** — select a row, a panel opens below or beside with
the full issue body (rendered markdown), labels, dates, linked PRs.
3. **Drill-down** — from the detail panel, navigate deeper: view
comments, linked issues, or push a full-screen detail view.
4. **Back navigation** — Escape pops back up the stack at every level.
5. **Filtering** — filter by milestone, status, assignee, priority
using a toolbar or keyboard shortcuts.
6. **Refresh** — auto-refresh on a timer (configurable, default 60s),
plus manual refresh keybinding.
## Non-goals
- Editing issues from the TUI (read-only for v1)
- Natural language querying (future phase)
- Notifications or alerts
- Non-GitHub providers
## Technical approach
### Data source
Reuse the existing `GitHubTimelineProvider` and `_run_gh()` infra in
`src/toad/widgets/github_views/`. Add a new fetch method for full issue
details (body, comments, linked PRs) alongside the existing
`fetch_milestones()` and `fetch_items()`.
### Widget architecture
```
ProjectStatePane (existing, add new tab)
└─ TabbedContent
├─ "GitHub" tab (existing timeline + status)
├─ "Tasks" tab (NEW)
│ ├─ FilterToolbar (status, milestone, priority dropdowns)
│ ├─ TaskTable (DataTable, cursor_type="row")
│ └─ TaskDetail (ContentSwitcher, shows on row select)
│ ├─ Markdown body
│ ├─ Labels, dates, metadata
│ └─ "View comments" / "View linked PRs" actions
└─ "State" tab (existing build status)
```
### Textual patterns
| Pattern | Widget | Use |
|---------|--------|-----|
| Master-detail | `DataTable` + `ContentSwitcher` | Task list + detail panel |
| Drill-down | `push_screen` / `pop_screen` | Full issue view, comments |
| Navigation | `OptionList` or filter bar | Milestone/status filtering |
| Rich content | `Markdown` / `MarkdownViewer` | Issue body rendering |
| Collapsible sections | `Collapsible` | Metadata groups in detail |
### Key interactions
- `RowSelected` on DataTable → swap ContentSwitcher to show detail
- `RowHighlighted` → optional preview in a smaller pane
- Enter on "View comments" → `push_screen(CommentsScreen(issue_id))`
- Escape → `pop_screen()` or collapse detail panel
- `r` → manual refresh
- `/` → filter input
### Data model
Extend `TimelineItem` or create a new `TaskItem` dataclass:
```python
@dataclass
class TaskItem:
id: int
title: str
status: str # Todo | In Progress | Done
milestone: str
priority: str # p1-must-ship .. p4-cut
assignee: str
effort: str
body: str # markdown
labels: list[str]
start_date: str
target_date: str
url: str
comments_count: int
linked_prs: list[str]
```
### Files to create/modify
| File | Action |
|------|--------|
| `src/toad/widgets/github_views/task_provider.py` | New — fetch full issue data |
| `src/toad/widgets/task_table.py` | New — DataTable widget for tasks |
| `src/toad/widgets/task_detail.py` | New — detail panel (ContentSwitcher) |
| `src/toad/widgets/filter_toolbar.py` | New — filtering controls |
| `src/toad/screens/task_detail_screen.py` | New — full-screen drill-down |
| `src/toad/widgets/project_state_pane.py` | Modify — add "Tasks" tab |
| `src/toad/widgets/github_views/github_timeline_provider.py` | Modify — add issue detail fetch |
| `tools/verify-tui.py` | Modify — add task widget checks |
| `tests/test_task_widgets.py` | New — unit tests |
## Verification
```bash
# All widgets render without error
uv run python tools/verify-tui.py --verbose
# Unit tests pass
pytest -q tests/test_task_widgets.py
# Lint + types clean
ruff check src/toad/widgets/task_table.py src/toad/widgets/task_detail.py
ty check
# Smoke test: tasks tab appears, rows load, detail opens on select
uv run canon . &
# → open Tasks tab → verify DataTable populated → select row → detail shows
```
## Success criteria (shell-verifiable)
- `uv run python tools/verify-tui.py --widget tasks` exits 0
- `pytest -q tests/test_task_widgets.py` exits 0
- `ruff check src/toad/widgets/task_table.py src/toad/widgets/task_detail.py src/toad/widgets/filter_toolbar.py src/toad/screens/task_detail_screen.py` exits 0
- `grep -c 'class TaskTable' src/toad/widgets/task_table.py` returns 1
- `grep -c 'class TaskDetail' src/toad/widgets/task_detail.py` returns 1
- `grep -c '"Tasks"' src/toad/widgets/project_state_pane.py` returns at least 1
## Reference apps
- **Posting** (posting.sh) — sidebar + detail panels, closest layout match
- **Harlequin** — DataTable at scale, SQL IDE
- **Elia** — master-detail chat interface
## Timeline
One day. Workers execute in parallel via orchestrator.

View file

@ -0,0 +1,95 @@
# Canon TUI — Right-Pane v2
## Layout
- Removed the left sidebar; TUI is now a 2-column `[Agent][Right Pane]` instead of 3-column
- Plan + Files widgets moved into a new **Context** section inside the right pane
- Right pane has 3 sections: **Context · Planning · State**
- Accordion behavior: clicking a section button opens that one and collapses the others (no hidden shortcuts)
- `⊟` stack-toggle button for power users who want multiple sections open at once
## Board (the new unified view)
- Replaces the old "Tasks" / "Plans" / "PRs" / "Status" / "GitHub" tab sprawl with a single Board tab
- Lists both GitHub issues and PRs side by side
- Type filter chips: **All · Plans · PRs · Bugs · Features**
- Dynamic columns per chip:
- **Plans** → Progress bar from issue-body checkboxes (`██████░░░░ 65%`)
- **PRs** → Review state / CI status / Age / Author with glyphs (`✓ approved`, `✓ pass`, `2d`)
- **All / Bugs / Features** → Status / Title / Milestone / Priority / Assignee
- Filter toolbar: Status / Milestone / Priority dropdowns + title search + Refresh button
- Default status filter is **Active** (Todo + In progress) — Done hidden unless picked explicitly
- Title search jumps into focus via `/` keybinding
- Inline status label is now bold, accent-bordered, hard to miss: "Showing 12 of 34", "No tasks match filters", "Loading", or error
## Drill-down
- Clicking a row **replaces** the list with a full-width detail view inside the Board tab (no more cramped 25-col split)
- Top bar shows `← Back` button + breadcrumb (`Board #142 Wire tasks tab`)
- Deeper drill into comments/PRs pushes a full-screen `TaskDetailScreen` with `← Back` and `✕` close buttons
- Escape works as a shortcut everywhere, but every view has a visible clickable exit
## Chat-first routing
Natural-language panel routing works without the agent needing any custom knowledge — parsed client-side.
Opens:
- "show me the board" / "show me tasks"
- "show me PRs" / "show me plans" / "show me bugs" / "show me features"
- "show me P1 tasks" / "show me done tasks" / "show me P2 in progress"
- "show me the timeline" / "show me the plan" / "show me the files"
- `open the …`, `go to the …`, `switch to …` variants
Closes:
- "close the board" / "hide the timeline"
- "close the right panel" / "hide everything" → hides all sections
Panel opens instantly on user submit, before the agent replies.
## Data layer
- `TaskProvider` now fetches PRs via `gh pr list` alongside issues
- `TaskItem` gained PR fields: `is_pr`, `review_state`, `ci_state`, `mergeable`, `author`
- Plan items get `progress_pct` computed from markdown checkboxes in the issue body
- PR fetch failures don't block issue fetch (graceful degradation)
## Panel routing registry (for future extension)
- `PANEL_ROUTES`, `PANEL_FILTERS`, `PANEL_TYPE_PRESETS` live in `project_state_pane.py` — adding a new panel is a single-file change
- Filter-aware intents: agent / chat can emit `open_panel` with `context.filters={...}` to open a pre-filtered view
- New skill at `.claude/skills/canon-panel-routing/SKILL.md` documents the registry and gives a 4-step recipe for adding a panel
## Critique fixes (from v1 PR #23)
- Added `/` and `r` keybindings with a title-search input
- Fixed refresh-timer leak when the pane was hidden
- Suppressed spurious `Select.Changed` events during programmatic option resets
- `TaskDetailScreen` now updates live instead of snapshotting stale data
- Replaced bare `except Exception` with typed `NoMatches` where appropriate
## Docs + skills added
- `docs/canon-right-pane-architecture.md` — architectural decisions + chat-first/clickable-exits principles
- `docs/pm-widget-polish.md` — polish backlog for future passes
- `docs/prd-pm-widget.md` — the original PRD
- `docs/pm-widget-oversight.md` — migration notes
- `.claude/skills/canon-panel-routing/SKILL.md` — panel-extension recipe
- Installed reference skills: `textual-tui`, `ux-journey-architect`, impeccable audit/polish/distill/critique/layout/clarify
## What was tried and reverted
- **StatusStrip** (14-day close sparkline + priority distribution bar + milestone summary) was built, shipped, and removed — the signals lacked enough data context to be meaningful in the current state
## Testing
- 44 tests pass: provider parsing, filter predicates, dynamic columns, PR fetch, checkbox-progress, intent detection (open + close), interaction flows via `App.run_test()` pilot
- End-to-end harnesses verify 11 open-phrase cases and 4 close-phrase cases land on the right section/tab with the right filters
- `verify-tui --verbose` passes, `verify-tui --widget live` fetches 100 real tasks from the project board
- `ruff check` clean on all new/modified files
## PRs
- **#23** — Tasks tab foundation (merged-ready)
- **#24** — Right-pane v2 (this consolidation, mergeable to `conductor`)

View file

@ -1,5 +1,6 @@
from functools import partial
from pathlib import Path
from typing import Any
import random
from textual import on
@ -25,7 +26,6 @@ from toad.widgets.plan import Plan
from toad.widgets.throbber import Throbber
from toad.widgets.conversation import Conversation
from toad.widgets.project_directory_tree import ProjectDirectoryTree
from toad.widgets.side_bar import SideBar
from toad.widgets.builder_view import BuilderView
from toad.widgets.canon_state import CanonState, CanonStateWidget
from toad.widgets.project_state_pane import ProjectStatePane
@ -96,7 +96,6 @@ class MainScreen(Screen, can_focus=False):
busy_count = var(0)
throbber: getters.query_one[Throbber] = getters.query_one("#throbber")
conversation = getters.query_one(Conversation)
side_bar = getters.query_one(SideBar)
project_directory_tree = getters.query_one("#project_directory_tree")
column = reactive(False)
@ -144,17 +143,6 @@ class MainScreen(Screen, can_focus=False):
def compose(self) -> ComposeResult:
with containers.Horizontal(id="main-split"):
with containers.Center():
yield SideBar(
SideBar.Panel("Plan", Plan([])),
SideBar.Panel(
"Project",
ProjectDirectoryTree(
self.project_path,
id="project_directory_tree",
),
flex=True,
),
)
yield Conversation(
self.project_path,
self._agent,
@ -177,7 +165,6 @@ class MainScreen(Screen, can_focus=False):
def update_node_styles(self, animate: bool = True) -> None:
self.conversation.update_node_styles(animate=animate)
self.query_one(Footer).update_node_styles(animate=animate)
self.query_one(SideBar).update_node_styles(animate=animate)
def action_session_previous(self) -> None:
if self.screen.id is not None:
@ -207,7 +194,7 @@ class MainScreen(Screen, can_focus=False):
)
for entry in message.entries
]
self.query_one("SideBar Plan", Plan).entries = entries
self.query_one(Plan).entries = entries
@on(messages.SessionUpdate)
async def on_session_update(self, event: messages.SessionUpdate) -> None:
@ -263,12 +250,18 @@ class MainScreen(Screen, can_focus=False):
self.conversation.prompt.suggest(event.option.id)
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
if action == "show_sidebar" and self.side_bar.has_focus_within:
return False
return True
def action_show_sidebar(self) -> None:
self.side_bar.query_one("Collapsible CollapsibleTitle").focus()
"""Legacy keybinding — now opens the Context section in the right pane."""
self.split_enabled = True
pane = self.query_one("#project_state_pane", ProjectStatePane)
pane.show_section("section-context")
pane.activate_tab("tab-plan")
try:
pane.query_one(Plan).focus()
except Exception:
pass
def action_toggle_project_state(self) -> None:
"""Toggle the right-side project state pane.
@ -293,19 +286,19 @@ class MainScreen(Screen, can_focus=False):
pane.show_section(section_id)
pane.activate_tab(tab_id)
def _forward_canon_state(self, state: "CanonState") -> None:
async def _forward_canon_state(self, state: "CanonState") -> None:
"""Forward canon state directly to State view."""
pane = self.query_one("#project_state_pane", ProjectStatePane)
for view in pane.query(BuilderView):
view._render_state(state)
await view._render_state(state)
def action_show_planning(self) -> None:
"""Open pane and show Planning section (GitHub tab)."""
self._show_section_tab("section-planning", "tab-github")
"""Open pane and show Planning section (Board tab)."""
self._show_section_tab("section-planning", "tab-tasks")
def action_show_github(self) -> None:
"""Open pane and show GitHub tab inside Planning."""
self._show_section_tab("section-planning", "tab-github")
"""Open pane and show the Plans tab inside Planning."""
self._show_section_tab("section-planning", "tab-gh-plans")
def action_show_timeline(self) -> None:
"""Open pane and show Timeline tab inside Planning."""
@ -351,13 +344,13 @@ class MainScreen(Screen, can_focus=False):
self.call_later(self._forward_canon_state, canon.state)
@on(CanonStateWidget.CanonStateUpdated)
def _on_canon_updated(
async def _on_canon_updated(
self,
event: CanonStateWidget.CanonStateUpdated,
) -> None:
"""Auto-switch between Builder and Automation on phase change."""
event.stop()
self._forward_canon_state(event.state)
await self._forward_canon_state(event.state)
def watch_split_enabled(self, enabled: bool) -> None:
"""Show/hide the project state pane."""
@ -372,49 +365,60 @@ class MainScreen(Screen, can_focus=False):
message.stop()
self.split_enabled = False
# Map ACP panel IDs to (section_id, tab_id)
_PANEL_MAP: dict[str, tuple[str, str]] = {
"planning": ("section-planning", "tab-github"),
"github": ("section-planning", "tab-github"),
"timeline": ("section-planning", "tab-timeline"),
"state": ("section-state", "tab-builder"),
"builder": ("section-state", "tab-builder"),
}
# Map ACP panel IDs to section_id for close
_PANEL_SECTION_MAP: dict[str, str] = {
"planning": "section-planning",
"github": "section-planning",
"timeline": "section-planning",
"state": "section-state",
"builder": "section-state",
}
@on(acp_messages.OpenPanel)
def on_acp_open_panel(self, message: acp_messages.OpenPanel) -> None:
"""Agent requests opening a panel."""
"""Agent requests opening a panel.
The panel ID is looked up in ``PANEL_ROUTES`` (declared in
``project_state_pane``). Optional ``message.context`` may carry
``filters`` a dict applied to the panel after it opens (see
the ``canon-panel-routing`` skill).
Alias IDs like ``plans`` / ``prs`` route to the Board tab with the
matching type chip pre-activated.
"""
from toad.widgets.project_state_pane import (
PANEL_ROUTES,
PANEL_TYPE_PRESETS,
)
message.stop()
panel_id = message.panel_id
if panel_id == "project_state":
self.split_enabled = True
return
mapping = self._PANEL_MAP.get(panel_id)
if mapping:
self._show_section_tab(*mapping)
mapping = PANEL_ROUTES.get(panel_id)
if not mapping:
return
self._show_section_tab(*mapping)
# Combine explicit filters from context with the alias's type preset.
combined_filters: dict[str, Any] = {}
preset_type = PANEL_TYPE_PRESETS.get(panel_id)
if preset_type:
combined_filters["type"] = preset_type
if message.context:
ctx_filters = message.context.get("filters")
if isinstance(ctx_filters, dict):
combined_filters.update(ctx_filters)
if combined_filters and mapping[1] == "tab-tasks":
pane = self.query_one("#project_state_pane", ProjectStatePane)
pane.apply_task_filters(combined_filters)
@on(acp_messages.ClosePanel)
def on_acp_close_panel(self, message: acp_messages.ClosePanel) -> None:
"""Agent requests closing a panel."""
from toad.widgets.project_state_pane import PANEL_ROUTES
message.stop()
panel_id = message.panel_id
if panel_id == "project_state":
pane = self.query_one("#project_state_pane", ProjectStatePane)
pane.hide_all_sections()
return
section_id = self._PANEL_SECTION_MAP.get(panel_id)
if section_id:
mapping = PANEL_ROUTES.get(panel_id)
if mapping:
pane = self.query_one("#project_state_pane", ProjectStatePane)
pane.hide_section(section_id)
pane.hide_section(mapping[0])
def action_focus_prompt(self) -> None:
self.conversation.focus_prompt()
@ -425,10 +429,6 @@ class MainScreen(Screen, can_focus=False):
await self.app.save_settings()
await self.app.switch_mode("store")
@on(SideBar.Dismiss)
def on_side_bar_dismiss(self, message: SideBar.Dismiss):
message.stop()
self.conversation.focus_prompt()
def watch_column(self, column: bool) -> None:
self.conversation.styles.max_width = (

View file

@ -0,0 +1,164 @@
"""Full-screen drill-down for a single task.
Pushed via ``app.push_screen`` from :class:`toad.widgets.task_detail.TaskDetail`
when the user activates the "View comments" action. Escape pops back to
the list.
"""
from __future__ import annotations
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.css.query import NoMatches
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Markdown, Static
from toad.widgets.github_views.task_provider import TaskDetailData, TaskItem
class TaskDetailScreen(Screen[None]):
"""Full-screen task detail with Escape-to-pop.
Constructed with just the :class:`TaskItem`; the owning pane calls
:meth:`set_details` once the async fetch completes, so the screen
always shows the latest body and linked PRs even if the user drilled
in before the fetch returned.
"""
BINDINGS = [
Binding("escape", "app.pop_screen", "Back"),
]
DEFAULT_CSS = """
TaskDetailScreen #task-screen-nav {
height: auto;
padding: 0 1;
}
TaskDetailScreen #task-screen-back {
min-width: 12;
height: 1;
margin-right: 1;
border: none;
background: $primary 30%;
color: $text;
}
TaskDetailScreen #task-screen-close {
min-width: 5;
height: 1;
border: none;
background: $surface;
color: $text-muted;
}
TaskDetailScreen #task-screen-breadcrumb {
color: $text-muted;
height: 1;
width: 1fr;
}
TaskDetailScreen #task-screen-body {
padding: 1 2;
}
TaskDetailScreen .task-screen-title {
text-style: bold;
padding-bottom: 1;
}
TaskDetailScreen .task-screen-meta {
color: $text-muted;
padding-bottom: 1;
}
TaskDetailScreen .task-screen-error {
color: $error;
text-style: bold;
}
"""
def __init__(
self,
task: TaskItem,
details: TaskDetailData | None = None,
) -> None:
super().__init__()
self._task_item = task
self._details = details
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
with Horizontal(id="task-screen-nav"):
yield Button(
"← Back",
id="task-screen-back",
tooltip="Return to the previous view (Esc)",
)
yield Static(
f" Board #{self._task_item.number}",
id="task-screen-breadcrumb",
)
yield Button(
"",
id="task-screen-close",
tooltip="Close this view",
)
with VerticalScroll(id="task-screen-body"):
yield Static(
f"#{self._task_item.number}{self._task_item.title}",
classes="task-screen-title",
)
yield Static(
_format_meta(self._task_item, self._details),
id="task-screen-meta",
classes="task-screen-meta",
)
yield Markdown(_body_text(self._details), id="task-screen-body-md")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id in {"task-screen-back", "task-screen-close"}:
event.stop()
self.app.pop_screen()
def set_details(self, details: TaskDetailData) -> None:
"""Swap in fetched body + metadata (called after lazy load)."""
self._details = details
try:
self.query_one("#task-screen-meta", Static).update(
_format_meta(self._task_item, details)
)
self.query_one("#task-screen-body-md", Markdown).update(
_body_text(details)
)
except NoMatches:
return
def set_error(self, message: str) -> None:
"""Show a fetch error in place of the body."""
try:
md = self.query_one("#task-screen-body-md", Markdown)
except NoMatches:
return
md.update(f"**Failed to load details:** {message}")
def _body_text(details: TaskDetailData | None) -> str:
if details is None or not details.body:
return "_(no description)_"
return details.body
def _format_meta(task: TaskItem, details: TaskDetailData | None) -> str:
parts: list[str] = [f"status: {task.status.value}"]
if task.milestone_title:
parts.append(f"milestone: {task.milestone_title}")
if task.priority is not None:
parts.append(f"priority: {task.priority.value}")
if task.assignees:
parts.append(f"assignees: {', '.join(task.assignees)}")
comments = details.comments_count if details else task.comments_count
parts.append(f"comments: {comments}")
if details and details.linked_prs:
pr_refs = ", ".join(
f"#{pr.get('number')}" for pr in details.linked_prs if pr.get("number")
)
parts.append(f"linked PRs: {pr_refs}")
if task.url:
parts.append(task.url)
return " · ".join(parts)

View file

@ -242,6 +242,16 @@ SCHEMA: list[SchemaDict] = [
"help": "Show agent's 'thoughts' in the conversation?",
"type": "boolean",
},
{
"key": "quiet",
"title": "Quiet during canon phases",
"help": "Suppress agent prose in chat during active canon "
"phases (init, scaffold, develop, run). Status updates "
"flow through the state pane instead. Strategy phase "
"and errors always break through.",
"type": "boolean",
"default": True,
},
# {
# "key": "warn",
# "title": "Warning against dangerous commands?",

View file

@ -136,14 +136,14 @@ class BuilderView(Widget, can_focus=True):
)
yield Static("[dim] No metrics[/]", id="builder-metrics")
def on_canon_state_widget_canon_state_updated(
async def on_canon_state_widget_canon_state_updated(
self,
event: CanonStateWidget.CanonStateUpdated,
) -> None:
"""Refresh view when canon state changes."""
self._render_state(event.state)
await self._render_state(event.state)
def _render_state(self, state: CanonState) -> None:
async def _render_state(self, state: CanonState) -> None:
"""Rebuild the builder view from canon state."""
# Status bar: phase + status
status_bar = self.query_one("#builder-status-bar", Static)
@ -161,15 +161,15 @@ class BuilderView(Widget, can_focus=True):
# Pipeline flow
pipeline = self.query_one("#builder-pipeline", PipelineView)
pipeline.render_flow(state.flow)
await pipeline.render_flow(state.flow)
# Logs
scroll = self.query_one(VerticalScroll)
scroll.remove_children()
await scroll.remove_children()
logs = state.logs[-MAX_LOG_LINES:]
if not logs:
scroll.mount(
await scroll.mount(
Static(
"No build logs",
classes="empty-state",
@ -177,8 +177,8 @@ class BuilderView(Widget, can_focus=True):
)
)
else:
for entry in logs:
scroll.mount(Static(_render_log(entry)))
widgets = [Static(_render_log(entry)) for entry in logs]
await scroll.mount_all(widgets)
scroll.scroll_end(animate=False)
# Metrics

View file

@ -152,6 +152,116 @@ Need help? Ask on {HELP_URL}
"""
_PANEL_KEYWORDS: tuple[tuple[tuple[str, ...], str], ...] = (
# (keywords, panel_id) — order matters; more-specific first.
(("pull requests", "pull request", " prs ", " pr "), "prs"),
(("the board", "board"), "board"),
(("tasks", "issues"), "board"),
(("plans",), "plans"),
(("the plan", "execution plan", "plan"), "plan"),
(("bugs",), "bugs"),
(("features",), "features"),
(("timeline", "gantt"), "timeline"),
(("files", "file tree", "project files"), "files"),
(("build state", "builder", "run state", "the state"), "state"),
)
_PRIORITY_PATTERN = (
("p1", "P1"),
("p2", "P2"),
("p3", "P3"),
("p4", "P4"),
)
_STATUS_PATTERN = (
("in progress", "in_progress"),
("in-progress", "in_progress"),
("todo", "todo"),
("to do", "todo"),
("done", "done"),
)
def _detect_panel_intent(text: str) -> tuple[str, dict[str, str] | None] | None:
"""Parse user text for a 'show me X' intent → (panel_id, filters) | None.
Handles phrases like "show me the board", "open the plan", "show me P1
tasks", "show me done PRs". Returns ``None`` when the text doesn't look
like a panel request, so the agent can handle it.
"""
lower = f" {text.lower().strip()} "
trigger = any(
phrase in lower
for phrase in (
"show me",
"show the",
"open the",
"open ",
"go to ",
"switch to ",
)
)
if not trigger:
return None
panel_id: str | None = None
for keywords, pid in _PANEL_KEYWORDS:
if any(kw in lower for kw in keywords):
panel_id = pid
break
if panel_id is None:
return None
filters: dict[str, str] = {}
for needle, value in _PRIORITY_PATTERN:
if f" {needle} " in lower or f" {needle}s " in lower:
filters["priority"] = value
break
for needle, value in _STATUS_PATTERN:
if f" {needle} " in lower:
filters["status"] = value
break
return (panel_id, filters or None)
def _detect_close_intent(text: str) -> str | None:
"""Parse user text for a 'close the X / hide the X' intent → panel_id.
Returns the panel_id to close, ``"project_state"`` for "close the
right panel" / "close everything", or ``None`` if no close intent.
"""
lower = f" {text.lower().strip()} "
trigger = any(
phrase in lower
for phrase in (
"close the",
"close ",
"hide the",
"hide ",
"dismiss",
"collapse the",
"collapse ",
)
)
if not trigger:
return None
# Whole-pane phrasings → project_state (hides everything).
for phrase in (
"right panel",
"right pane",
"project state",
"everything",
"it all",
" all panels",
):
if phrase in lower:
return "project_state"
# Otherwise match the same panel keywords as the open path.
for keywords, pid in _PANEL_KEYWORDS:
if any(kw in lower for kw in keywords):
return pid
return None
class Loading(Static):
"""Tiny widget to show loading indicator."""
@ -812,6 +922,22 @@ class Conversation(containers.Vertical):
if text.startswith("/") and await self.slash_command(text):
# Canon has processed the slash command.
return
# Client-side panel routing: if the user asks to open or close a
# panel, post the event ourselves so the UI reacts instantly
# regardless of whether the agent chooses to emit one.
close_target = _detect_close_intent(text)
if close_target is not None:
self.post_message(acp_messages.ClosePanel(panel_id=close_target))
else:
panel_intent = _detect_panel_intent(text)
if panel_intent is not None:
panel_id, filters = panel_intent
self.post_message(
acp_messages.OpenPanel(
panel_id=panel_id,
context={"filters": filters} if filters else None,
)
)
await self.post(UserInput(text))
self.window.scroll_end(animate=False)
self._loading = await self.post(Loading("Please wait..."), loading=True)
@ -948,10 +1074,27 @@ class Conversation(containers.Vertical):
kept.append(line)
return "\n".join(kept)
def _is_canon_quiet(self) -> bool:
"""Check if agent prose should be suppressed for the active phase."""
if not self.app.settings.get("agent.quiet", bool):
return False
from toad.widgets.canon_state import CanonStateWidget
try:
canon = self.app.query_one("#canon-state", CanonStateWidget)
except NoMatches:
return False
state = canon.state
return (
state.status in ("running", "in_progress")
and state.phase in ("init", "scaffold", "develop", "run")
)
@on(acp_messages.Update)
async def on_acp_agent_message(self, message: acp_messages.Update):
message.stop()
self._agent_thought = None
if self._is_canon_quiet():
return
text = self._strip_json_lines(message.text)
if text.strip():
await self.post_agent_response(text)
@ -1022,8 +1165,7 @@ class Conversation(containers.Vertical):
tool_id, ToolCall
)
except NoMatches:
status = tool_call.get("status")
if status is not None and status != "failed":
if tool_call.get("status") != "failed":
return
await self.post(ToolCall(tool_call, id=message.tool_id), new_block=True)
else:
@ -1097,6 +1239,7 @@ class Conversation(containers.Vertical):
output_byte_limit=message.output_byte_limit,
id=message.terminal_id,
minimum_terminal_width=width,
quiet=self._is_canon_quiet(),
)
self.terminals[message.terminal_id] = terminal
terminal.display = False

View file

@ -0,0 +1,362 @@
"""FilterToolbar — status/milestone/priority selects + refresh button.
Posts a :class:`FilterToolbar.FiltersChanged` message whenever a selection
changes, and :class:`FilterToolbar.RefreshRequested` when the refresh
button is pressed.
Also exposes a module-level :func:`filter_tasks` predicate used both by the
Tasks pane and by unit tests.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.widgets import Button, Input, Select
from toad.widgets.github_views.task_provider import TaskItem
from toad.widgets.github_views.timeline_provider import ItemStatus, Priority
_ANY = "__any__"
_ACTIVE = "__active__" # Active = Todo + In progress (default, excludes Done)
_STATUS_OPTIONS: tuple[tuple[str, str], ...] = (
("Active (default)", _ACTIVE),
("All statuses", _ANY),
("Todo", ItemStatus.TODO.value),
("In progress", ItemStatus.IN_PROGRESS.value),
("Done", ItemStatus.DONE.value),
)
_PRIORITY_OPTIONS: tuple[tuple[str, str], ...] = (
("All priorities", _ANY),
("P1", "1"),
("P2", "2"),
("P3", "3"),
("P4", "4"),
)
def filter_tasks(
tasks: Iterable[TaskItem],
*,
status: ItemStatus | None = None,
milestone_id: str | None = None,
priority: Priority | None = None,
title_query: str | None = None,
type_filter: str | None = None,
exclude_done: bool = False,
) -> list[TaskItem]:
"""Return the subset of ``tasks`` matching all non-None filters.
``type_filter`` matches against labels of the form ``type:<value>``
(case-insensitive). Pass ``"plan"`` to return only tasks labelled
``type:plan``. The special value ``"all"`` or ``None`` disables the
type filter.
``exclude_done`` drops DONE tasks when no explicit status is set.
Ignored when ``status`` is provided (explicit wins).
"""
query = (title_query or "").strip().lower() or None
type_needle = _normalize_type(type_filter)
result: list[TaskItem] = []
for task in tasks:
if status is not None:
if task.status is not status:
continue
elif exclude_done and task.status is ItemStatus.DONE:
continue
if milestone_id is not None and task.milestone_id != milestone_id:
continue
if priority is not None and task.priority is not priority:
continue
if query is not None and query not in task.title.lower():
continue
if type_needle is not None and not _task_has_type(task, type_needle):
continue
result.append(task)
return result
def _normalize_type(raw: str | None) -> str | None:
if not raw:
return None
lower = raw.strip().lower()
if lower in ("", "all"):
return None
return lower
def _task_has_type(task: TaskItem, needle: str) -> bool:
# "pr" is special — it matches the is_pr flag rather than a label.
if needle == "pr":
return task.is_pr
# For all other types, issues only (exclude PRs unless asked for them).
if task.is_pr:
return False
prefix = f"type:{needle}"
return any(lbl.lower() == prefix for lbl in task.labels)
@dataclass(frozen=True)
class FilterState:
"""Snapshot of the toolbar's current filter selections."""
status: ItemStatus | None = None
milestone_id: str | None = None
priority: Priority | None = None
title_query: str | None = None
type_filter: str | None = None
exclude_done: bool = True # Default: hide Done tasks
class FilterToolbar(Vertical):
"""Two-row filter toolbar: primary controls on top, type chips below."""
DEFAULT_CSS = """
FilterToolbar {
height: auto;
padding: 0 1;
}
FilterToolbar #filter-primary-row {
height: auto;
}
FilterToolbar #filter-primary-row > Select {
width: 1fr;
margin-right: 1;
}
FilterToolbar #filter-primary-row > Input {
width: 1fr;
margin-right: 1;
}
FilterToolbar #filter-primary-row > Button {
width: auto;
}
FilterToolbar #filter-chip-row {
height: auto;
padding: 0 0 0 0;
}
FilterToolbar #filter-chip-row > Button {
min-width: 10;
height: 1;
margin-right: 1;
border: none;
background: $surface;
color: $text-muted;
}
FilterToolbar #filter-chip-row > Button.active {
background: $primary 30%;
color: $text;
text-style: bold;
}
"""
_TYPE_CHIPS: tuple[tuple[str, str], ...] = (
("All", "all"),
("Plans", "plan"),
("PRs", "pr"),
("Bugs", "bug"),
("Features", "feature"),
)
class FiltersChanged(Message):
"""Emitted when any select value changes."""
def __init__(self, state: FilterState) -> None:
super().__init__()
self.state = state
class RefreshRequested(Message):
"""Emitted when the refresh button is pressed."""
def __init__(
self,
milestones: Iterable[tuple[str, str]] = (),
*,
id: str | None = None,
) -> None:
super().__init__(id=id)
self._milestones: list[tuple[str, str]] = list(milestones)
def compose(self) -> ComposeResult:
with Horizontal(id="filter-primary-row"):
yield Select(
options=list(_STATUS_OPTIONS),
value=_ACTIVE,
allow_blank=False,
id="filter-status",
)
yield Select(
options=self._milestone_options(),
value=_ANY,
allow_blank=False,
id="filter-milestone",
)
yield Select(
options=list(_PRIORITY_OPTIONS),
value=_ANY,
allow_blank=False,
id="filter-priority",
)
yield Input(
placeholder="Filter title… (press / to focus)",
id="filter-title",
)
yield Button("Refresh", id="filter-refresh", variant="primary")
with Horizontal(id="filter-chip-row"):
for label, value in self._TYPE_CHIPS:
btn = Button(label, id=f"chip-type-{value}")
if value == "all":
btn.add_class("active")
yield btn
def set_milestones(self, milestones: Iterable[tuple[str, str]]) -> None:
"""Replace the milestone dropdown's options while preserving selection.
Suppresses ``Select.Changed`` during the swap so programmatic option
resets don't masquerade as user input.
"""
self._milestones = list(milestones)
try:
select = self.query_one("#filter-milestone", Select)
except NoMatches:
return
current = select.value
with self.prevent(Select.Changed):
select.set_options(self._milestone_options())
if current != Select.BLANK and current in {
v for _, v in self._milestone_options()
}:
select.value = current
else:
select.value = _ANY
def focus_title_input(self) -> None:
"""Move focus to the title-query input (called by ``/`` binding)."""
try:
self.query_one("#filter-title", Input).focus()
except NoMatches:
return
def current_state(self) -> FilterState:
"""Read the current filter selections."""
raw_status = self._value("#filter-status")
status, exclude_done = _to_status_and_flag(raw_status)
return FilterState(
status=status,
milestone_id=_to_milestone(self._value("#filter-milestone")),
priority=_to_priority(self._value("#filter-priority")),
title_query=self._title_query(),
type_filter=self._active_type_chip(),
exclude_done=exclude_done,
)
def _active_type_chip(self) -> str | None:
"""Return the value of the currently-active type chip."""
for _, value in self._TYPE_CHIPS:
try:
btn = self.query_one(f"#chip-type-{value}", Button)
except NoMatches:
continue
if "active" in btn.classes:
return None if value == "all" else value
return None
def set_active_type(self, value: str) -> None:
"""Activate the chip matching ``value`` (e.g. 'plan', 'all')."""
normalized = value.lower() if value else "all"
for _, v in self._TYPE_CHIPS:
try:
btn = self.query_one(f"#chip-type-{v}", Button)
except NoMatches:
continue
if v == normalized:
btn.add_class("active")
else:
btn.remove_class("active")
def on_select_changed(self, event: Select.Changed) -> None:
event.stop()
self.post_message(self.FiltersChanged(self.current_state()))
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id != "filter-title":
return
event.stop()
self.post_message(self.FiltersChanged(self.current_state()))
def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id or ""
if btn_id == "filter-refresh":
event.stop()
self.post_message(self.RefreshRequested())
return
if btn_id.startswith("chip-type-"):
event.stop()
value = btn_id.removeprefix("chip-type-")
self.set_active_type(value)
self.post_message(self.FiltersChanged(self.current_state()))
def _milestone_options(self) -> list[tuple[str, str]]:
return [("All milestones", _ANY), *self._milestones]
def _value(self, selector: str) -> str | None:
try:
select = self.query_one(selector, Select)
except NoMatches:
return None
value = select.value
if value == Select.BLANK:
return None
return str(value)
def _title_query(self) -> str | None:
try:
query = self.query_one("#filter-title", Input).value
except NoMatches:
return None
query = query.strip()
return query or None
def _to_status_and_flag(raw: str | None) -> tuple[ItemStatus | None, bool]:
"""Parse the status Select value into (status, exclude_done).
- ``__active__`` (None, True) # Todo + In progress
- ``__any__`` (None, False) # include Done
- specific value (ItemStatus.X, False)
- unknown (None, True) # fall back to Active default
"""
if raw is None or raw == _ACTIVE:
return (None, True)
if raw == _ANY:
return (None, False)
try:
return (ItemStatus(raw), False)
except ValueError:
return (None, True)
def _to_status(raw: str | None) -> ItemStatus | None:
"""Legacy helper — still used elsewhere. Returns only the status."""
return _to_status_and_flag(raw)[0]
def _to_priority(raw: str | None) -> Priority | None:
if raw is None or raw == _ANY:
return None
try:
return Priority(int(raw))
except (ValueError, TypeError):
return None
def _to_milestone(raw: str | None) -> str | None:
if raw is None or raw == _ANY:
return None
return raw

View file

@ -272,6 +272,27 @@ class GitHubTimelineProvider:
self._cached_fields = fields
return fields
async def fetch_task_details(
self, issue_number: int
) -> dict[str, Any]:
"""Fetch body, comments, labels, assignees, and linked PRs for an issue.
Returns the raw ``gh issue view --json`` payload so callers can
decide how to render it. Kept on the timeline provider so callers
with a single provider handle can still drill into issue details.
"""
raw = await _run_gh(
"issue",
"view",
str(issue_number),
"--repo",
self._repo,
"--json",
"number,body,comments,labels,assignees,url,closedByPullRequestsReferences",
)
result: dict[str, Any] = json.loads(raw)
return result
async def _fetch_issues_and_project(
self,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:

View file

@ -0,0 +1,403 @@
"""TaskProvider — fetches project-board tasks (issues) with rich metadata.
Kept separate from ``TimelineProvider`` so the timeline data path stays
stable. ``TaskItem`` is a richer superset of ``ProviderItem`` used by the
interactive Tasks widget (body, comments, linked PRs, assignees).
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any, Protocol, runtime_checkable
from toad.widgets.github_views.fetch import _run_gh
from toad.widgets.github_views.github_timeline_provider import (
_PROJECT_ITEMS_QUERY,
_normalize_status,
_parse_date,
_parse_priority,
_parse_risk_labels,
)
from toad.widgets.github_views.timeline_provider import ItemStatus, Priority
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class TaskItem:
"""A project-board item (issue or PR) with metadata for the Board widget.
Superset of ``ProviderItem`` including fields only required by the
interactive list and detail views. ``is_pr`` distinguishes PRs from
issues; the PR-specific fields (``review_state``, ``ci_state``,
``mergeable``) are ``None`` for plain issues.
"""
id: str
number: int
title: str
status: ItemStatus
milestone_id: str | None = None
milestone_title: str = ""
priority: Priority | None = None
assignees: list[str] = field(default_factory=list)
effort: str | None = None
labels: list[str] = field(default_factory=list)
risk_labels: list[str] = field(default_factory=list)
start_date: date | None = None
target_date: date | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
comments_count: int = 0
url: str = ""
state: str = "open"
# PR-only fields (populated when is_pr=True, else None)
is_pr: bool = False
review_state: str | None = None # APPROVED / CHANGES_REQUESTED / REVIEW_REQUIRED / COMMENTED
ci_state: str | None = None # SUCCESS / FAILURE / PENDING / NONE
mergeable: str | None = None # MERGEABLE / CONFLICTING / UNKNOWN
author: str | None = None
# Plan-only field (populated when labels contain "type:plan")
progress_pct: int | None = None # 0..100, or None when no checklist found
@dataclass(frozen=True)
class TaskDetailData:
"""Lazy-loaded detail payload for a single task."""
number: int
body: str = ""
comments_count: int = 0
linked_prs: list[dict[str, Any]] = field(default_factory=list)
labels: list[str] = field(default_factory=list)
assignees: list[str] = field(default_factory=list)
url: str = ""
def _comments_count(value: Any) -> int:
"""Parse a ``comments`` field which may be an int or a list of comment dicts.
``gh issue list --json comments`` returns the full comment list; older
call sites return an integer count. Handle both defensively.
"""
if value is None:
return 0
if isinstance(value, list):
return len(value)
try:
return int(value)
except (TypeError, ValueError):
return 0
def _parse_datetime(value: str | None) -> datetime | None:
"""Parse an ISO-8601 datetime (with trailing Z) into a datetime."""
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
log.debug("unparseable datetime: %s", value)
return None
# Markdown checkbox regex — matches `- [ ]` / `- [x]` / `* [X]` / `+ [ ]`.
_CHECKBOX_RE = re.compile(r"^\s*[-*+]\s+\[( |x|X)\]", re.MULTILINE)
def _progress_from_body(body: str) -> int | None:
"""Return % of checked boxes in ``body``, or ``None`` if no checklist."""
if not body:
return None
matches = _CHECKBOX_RE.findall(body)
if not matches:
return None
checked = sum(1 for m in matches if m.lower() == "x")
return round(100 * checked / len(matches))
def _pr_to_task_item(pr: dict[str, Any]) -> TaskItem:
"""Convert a ``gh pr list --json`` entry to a TaskItem with PR fields."""
number = int(pr.get("number", 0))
labels = [lbl.get("name", "") for lbl in pr.get("labels", [])]
state_raw = str(pr.get("state", "open")).lower()
status = ItemStatus.IN_PROGRESS if state_raw == "open" else ItemStatus.DONE
if pr.get("isDraft"):
status = ItemStatus.TODO
milestone_data = pr.get("milestone") or {}
milestone_id = (
str(milestone_data.get("number"))
if milestone_data.get("number") is not None
else None
)
assignees = [
a.get("login", "") for a in (pr.get("assignees") or []) if a
]
author = (pr.get("author") or {}).get("login") or None
rollup = pr.get("statusCheckRollup") or []
ci_state = _summarize_ci(rollup)
return TaskItem(
id=f"pr-{number}",
number=number,
title=pr.get("title", ""),
status=status,
milestone_id=milestone_id,
milestone_title=milestone_data.get("title", "") or "",
priority=_parse_priority(labels),
assignees=assignees,
labels=labels,
risk_labels=_parse_risk_labels(labels),
created_at=_parse_datetime(pr.get("createdAt")),
updated_at=_parse_datetime(pr.get("updatedAt")),
url=pr.get("url", ""),
state=state_raw,
is_pr=True,
review_state=pr.get("reviewDecision") or "REVIEW_REQUIRED",
ci_state=ci_state,
mergeable=pr.get("mergeable"),
author=author,
)
def _summarize_ci(rollup: list[dict[str, Any]]) -> str:
"""Collapse a statusCheckRollup list to one of SUCCESS / FAILURE / PENDING / NONE."""
if not rollup:
return "NONE"
states: set[str] = set()
for entry in rollup:
state = (
entry.get("state")
or entry.get("conclusion")
or entry.get("status")
or ""
).upper()
if state:
states.add(state)
if "FAILURE" in states or "ERROR" in states:
return "FAILURE"
if "PENDING" in states or "IN_PROGRESS" in states or "QUEUED" in states:
return "PENDING"
if states and all(s in {"SUCCESS", "COMPLETED"} for s in states):
return "SUCCESS"
return "NONE"
@runtime_checkable
class TaskProviderProtocol(Protocol):
"""Minimal protocol implemented by ``TaskProvider``."""
async def fetch_tasks(self) -> list[TaskItem]: ...
async def fetch_task_details(self, issue_number: int) -> TaskDetailData: ...
class TaskProvider:
"""Fetches project-board tasks from GitHub via ``gh`` CLI.
Args:
repo: Owner/repo string (e.g. ``"DEGAorg/claude-code-config"``).
project_number: GitHub Projects V2 board number.
"""
def __init__(self, repo: str, project_number: int) -> None:
if "/" not in repo:
msg = f"repo must be owner/name, got: {repo!r}"
raise ValueError(msg)
self._repo = repo
self._owner = repo.split("/", 1)[0]
self._project_number = project_number
async def fetch_tasks(self) -> list[TaskItem]:
"""Fetch issues + PRs from the repo, enriched with board fields."""
issues_task = asyncio.create_task(self._fetch_issues())
project_task = asyncio.create_task(self._fetch_project_data())
prs_task = asyncio.create_task(self._fetch_prs())
issues, project, prs = await asyncio.gather(
issues_task, project_task, prs_task
)
board_map = _build_board_map(project)
tasks: list[TaskItem] = []
for issue in issues:
number = issue.get("number", 0)
labels = [lbl.get("name", "") for lbl in issue.get("labels", [])]
board = board_map.get(number, {})
status = _normalize_status(board.get("Status"))
if not board.get("Status") and issue.get("state", "").lower() == "closed":
status = ItemStatus.DONE
milestone_data = issue.get("milestone") or {}
milestone_id = (
str(milestone_data.get("number"))
if milestone_data.get("number") is not None
else None
)
milestone_title = milestone_data.get("title", "") or ""
assignees = [
a.get("login", "") for a in issue.get("assignees", []) if a
]
# Plan progress = ratio of checked markdown boxes in body.
progress_pct: int | None = None
if any(lbl.lower() == "type:plan" for lbl in labels):
progress_pct = _progress_from_body(issue.get("body", ""))
tasks.append(
TaskItem(
id=str(number),
number=number,
title=issue.get("title", ""),
status=status,
milestone_id=milestone_id,
milestone_title=milestone_title,
priority=_parse_priority(labels),
assignees=assignees,
effort=board.get("Effort"),
labels=labels,
risk_labels=_parse_risk_labels(labels),
start_date=_parse_date(board.get("Start Date")),
target_date=_parse_date(board.get("Target Date")),
created_at=_parse_datetime(issue.get("createdAt")),
updated_at=_parse_datetime(issue.get("updatedAt")),
comments_count=_comments_count(issue.get("comments")),
url=issue.get("url", ""),
state=issue.get("state", "open").lower(),
progress_pct=progress_pct,
)
)
for pr in prs:
tasks.append(_pr_to_task_item(pr))
return tasks
async def fetch_task_details(self, issue_number: int) -> TaskDetailData:
"""Fetch body, comments, and linked PRs for a single issue."""
raw = await _run_gh(
"issue",
"view",
str(issue_number),
"--repo",
self._repo,
"--json",
"number,body,comments,labels,assignees,url,closedByPullRequestsReferences",
)
data: dict[str, Any] = json.loads(raw)
comments = data.get("comments") or []
linked = data.get("closedByPullRequestsReferences") or []
return TaskDetailData(
number=int(data.get("number", issue_number)),
body=data.get("body", "") or "",
comments_count=len(comments),
linked_prs=list(linked),
labels=[lbl.get("name", "") for lbl in data.get("labels", [])],
assignees=[
a.get("login", "") for a in data.get("assignees", []) if a
],
url=data.get("url", ""),
)
async def _fetch_issues(self) -> list[dict[str, Any]]:
raw = await _run_gh(
"issue",
"list",
"--repo",
self._repo,
"--state",
"all",
"--json",
"number,title,state,labels,body,createdAt,updatedAt,milestone,url,assignees,comments",
"--limit",
"200",
)
result: list[dict[str, Any]] = json.loads(raw)
return result
async def _fetch_prs(self) -> list[dict[str, Any]]:
"""Fetch open + recently-merged PRs. Used to populate the PRs chip.
Returns an empty list if the call fails (never blocks issue fetch).
"""
try:
raw = await _run_gh(
"pr",
"list",
"--repo",
self._repo,
"--state",
"all",
"--json",
"number,title,state,labels,createdAt,updatedAt,url,author,"
"reviewDecision,statusCheckRollup,mergeable,isDraft,milestone,assignees",
"--limit",
"100",
)
result: list[dict[str, Any]] = json.loads(raw)
return result
except Exception as exc:
log.warning("Failed to fetch PRs: %s", exc)
return []
async def _fetch_project_data(self) -> dict[str, Any]:
raw = await _run_gh(
"api",
"graphql",
"-f",
f"owner={self._owner}",
"-F",
f"number={self._project_number}",
"-f",
f"query={_PROJECT_ITEMS_QUERY}",
timeout_s=30,
)
result: dict[str, Any] = json.loads(raw)
return result
def _build_board_map(
project_data: dict[str, Any],
) -> dict[int, dict[str, str]]:
"""Flatten GraphQL project data to issue_number -> {field: value}."""
project = (
project_data.get("data", {})
.get("organization", {})
.get("projectV2", {})
)
items = project.get("items", {}).get("nodes", [])
board_map: dict[int, dict[str, str]] = {}
for item in items:
if not item:
continue
content = item.get("content")
if not content or "number" not in content:
continue
number = content["number"]
fields: dict[str, str] = {}
for fv in item.get("fieldValues", {}).get("nodes", []):
if not fv:
continue
field_name = (fv.get("field") or {}).get("name", "")
if not field_name:
continue
value = (
fv.get("text")
or fv.get("name")
or fv.get("title")
or fv.get("date")
)
if fv.get("number") is not None and value is None:
value = str(fv["number"])
if value is not None:
fields[field_name] = str(value)
board_map[number] = fields
return board_map
assert isinstance(
TaskProvider.__new__(TaskProvider), TaskProviderProtocol
), "TaskProvider does not satisfy TaskProviderProtocol"

View file

@ -91,21 +91,23 @@ class PipelineView(Widget):
id="pipeline-placeholder",
)
def render_flow(self, flow: FlowState | None) -> None:
async def render_flow(self, flow: FlowState | None) -> None:
"""Rebuild the pipeline boxes from flow state."""
row = self.query_one("#pipeline-row", Horizontal)
row.remove_children()
await row.remove_children()
if not flow or not flow.steps:
row.mount(
await row.mount(
Static("[dim]No flow data[/]", id="pipeline-placeholder")
)
return
widgets: list[Static] = []
for i, step in enumerate(flow.steps):
if i > 0:
row.mount(Static("", classes="step-arrow"))
widgets.append(Static("", classes="step-arrow"))
label = _label_for(step, flow.labels)
css_class = _step_class(step, flow.active, flow.completed)
row.mount(Static(label, classes=css_class))
widgets.append(Static(label, classes=css_class))
await row.mount_all(widgets)

View file

@ -10,19 +10,33 @@ from typing import Any
import yaml
from textual import on, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.timer import Timer
from textual.widgets import Button, TabbedContent, TabPane
from textual.widgets import (
Button,
ContentSwitcher,
DataTable,
Static,
TabbedContent,
TabPane,
)
from toad.widgets.builder_view import BuilderView
from toad.widgets.canon_state import CanonStateWidget
from toad.widgets.filter_toolbar import FilterToolbar, FilterState, filter_tasks
from toad.widgets.gantt_timeline import GanttTimeline
from toad.widgets.github_state import GitHubStateWidget
from toad.widgets.github_views.github_timeline_provider import (
GitHubTimelineProvider,
)
from toad.widgets.github_views.task_provider import TaskItem, TaskProvider
from toad.widgets.github_views.timeline_data import build_timeline
from toad.widgets.plan import Plan
from toad.widgets.project_directory_tree import ProjectDirectoryTree
from toad.widgets.task_detail import TaskDetail
from toad.widgets.task_table import TaskTable
log = logging.getLogger(__name__)
@ -54,6 +68,7 @@ def _read_timeline_config(
# Section IDs — used as TabbedContent widget IDs and toolbar button suffix
SECTION_CONTEXT = "section-context"
SECTION_PLANNING = "section-planning"
SECTION_STATE = "section-state"
@ -68,11 +83,57 @@ class _SectionDef:
# Ordered list of sections — add new ones here
SECTIONS: list[_SectionDef] = [
_SectionDef(SECTION_STATE, "State"),
_SectionDef(SECTION_CONTEXT, "Context"),
_SectionDef(SECTION_PLANNING, "Planning"),
_SectionDef(SECTION_STATE, "State"),
]
# Panel routing registry.
#
# Maps an agent-facing panel ID (used in ACP ``open_panel`` messages) to a
# ``(section_id, tab_id)`` pair. Aliases pointing at the same tab are allowed
# — the agent prompt can refer to the panel by any of them. To add a new
# panel: add the tab in ``compose`` and register its alias(es) here.
PANEL_ROUTES: dict[str, tuple[str, str]] = {
"context": (SECTION_CONTEXT, "tab-plan"),
"plan": (SECTION_CONTEXT, "tab-plan"),
"files": (SECTION_CONTEXT, "tab-files"),
"planning": (SECTION_PLANNING, "tab-tasks"),
"timeline": (SECTION_PLANNING, "tab-timeline"),
# Board with pre-applied type chip (handled by open_panel routing below).
"tasks": (SECTION_PLANNING, "tab-tasks"),
"board": (SECTION_PLANNING, "tab-tasks"),
"plans": (SECTION_PLANNING, "tab-tasks"),
"prs": (SECTION_PLANNING, "tab-tasks"),
"pull_requests": (SECTION_PLANNING, "tab-tasks"),
"bugs": (SECTION_PLANNING, "tab-tasks"),
"features": (SECTION_PLANNING, "tab-tasks"),
"github": (SECTION_PLANNING, "tab-tasks"),
"status": (SECTION_PLANNING, "tab-tasks"),
"state": (SECTION_STATE, "tab-builder"),
"builder": (SECTION_STATE, "tab-builder"),
}
# For panel aliases that imply a type filter, map the alias to the chip value.
PANEL_TYPE_PRESETS: dict[str, str] = {
"plans": "plan",
"prs": "pr",
"pull_requests": "pr",
"bugs": "bug",
"features": "feature",
}
# Filter schema: panel ID → list of supported filter keys. Used by the
# agent prompt and documented in the ``canon-panel-routing`` skill.
PANEL_FILTERS: dict[str, tuple[str, ...]] = {
"tasks": ("status", "milestone", "priority", "title", "type"),
"board": ("status", "milestone", "priority", "title", "type"),
}
class ProjectStatePane(Vertical):
"""Toggleable right pane with N dynamic sections.
@ -85,6 +146,12 @@ class ProjectStatePane(Vertical):
class AllSectionsHidden(Message):
"""Posted when every section is hidden — pane should close."""
BINDINGS = [
Binding("r", "refresh_tasks", "Refresh tasks", show=False),
Binding("slash", "focus_task_filter", "Filter tasks", show=False),
Binding("escape", "tasks_back", "Back to list", show=False),
]
DEFAULT_CSS = """
ProjectStatePane {
display: none;
@ -121,6 +188,63 @@ class ProjectStatePane(Vertical):
padding: 0 1;
}
ProjectStatePane #tasks-switcher {
height: 1fr;
}
ProjectStatePane #tasks-list-view,
ProjectStatePane #tasks-detail-view {
height: 1fr;
}
ProjectStatePane #task-table {
width: 1fr;
height: 1fr;
}
ProjectStatePane #task-detail {
width: 1fr;
height: 1fr;
}
ProjectStatePane #tasks-breadcrumb {
height: auto;
padding: 0 1;
}
ProjectStatePane #tasks-back-btn {
min-width: 12;
height: 1;
margin-right: 1;
border: none;
background: $primary 30%;
color: $text;
}
ProjectStatePane #tasks-breadcrumb-label {
color: $text-muted;
height: 1;
padding-top: 0;
}
ProjectStatePane #tasks-status {
height: auto;
min-height: 2;
padding: 1 2;
margin: 0 1;
background: $primary 20%;
color: $text;
text-style: bold;
border-left: thick $primary;
}
ProjectStatePane #tasks-status.error {
background: $error 25%;
color: $text;
text-style: bold;
border-left: thick $error;
}
ProjectStatePane .empty-state {
color: $text-muted;
text-style: italic;
@ -139,24 +263,59 @@ class ProjectStatePane(Vertical):
super().__init__(**kwargs)
self._project_path = project_path or Path(".").resolve()
self._refresh_timer: Timer | None = None
self._tasks_refresh_timer: Timer | None = None
self._provider = self._make_provider()
self._task_provider = self._make_task_provider()
self._all_tasks: list[TaskItem] = []
self._filter_state = FilterState()
self._selected_task_id: str | None = None
self._stack_mode: bool = False
def compose(self) -> ComposeResult:
# Toolbar with one button per section
# Toolbar: one button per section + a stack-mode toggle
with Horizontal(id="pane-toolbar"):
for sec in SECTIONS:
yield Button(
sec.button_label,
id=f"btn-{sec.section_id}",
)
yield Button(
"",
id="btn-stack-toggle",
tooltip="Show multiple sections at once (click a section button in this mode to toggle it)",
)
# --- GitHub / Timeline section ---
with TabbedContent(id=SECTION_PLANNING, classes="pane-section"):
with TabPane("GitHub", id="tab-github"):
yield GitHubStateWidget(
project_path=str(self._project_path),
id="github_state",
# --- Context section (plan + files) ---
with TabbedContent(id=SECTION_CONTEXT, classes="pane-section"):
with TabPane("Plan", id="tab-plan"):
yield Plan([], id="pane-plan")
with TabPane("Files", id="tab-files"):
yield ProjectDirectoryTree(
self._project_path,
id="project_directory_tree",
)
# --- Planning section: Board / Timeline.
# Plans and PRs are now chip filters on the Board, not separate tabs.
with TabbedContent(id=SECTION_PLANNING, classes="pane-section"):
with TabPane("Board", id="tab-tasks"):
with ContentSwitcher(initial="tasks-list-view", id="tasks-switcher"):
with Vertical(id="tasks-list-view"):
yield FilterToolbar(id="task-filter-toolbar")
yield Static("", id="tasks-status")
yield TaskTable(id="task-table")
with Vertical(id="tasks-detail-view"):
with Horizontal(id="tasks-breadcrumb"):
yield Button(
"← Back",
id="tasks-back-btn",
tooltip="Return to the task list (Esc)",
)
yield Static(
"",
id="tasks-breadcrumb-label",
)
yield TaskDetail(id="task-detail")
with TabPane("Timeline", id="tab-timeline"):
yield GanttTimeline(id="pane-gantt")
@ -172,19 +331,22 @@ class ProjectStatePane(Vertical):
yield BuilderView(id="builder-view")
def on_mount(self) -> None:
# GitHub hidden by default, State visible
# All sections start hidden; the user opens one via toolbar / chat.
self.query_one(f"#{SECTION_CONTEXT}").display = False
self.query_one(f"#{SECTION_PLANNING}").display = False
self.query_one(f"#{SECTION_STATE}").display = False
self._sync_toolbar()
self._fetch_timeline()
self._fetch_tasks()
def watch_display(self, visible: bool) -> None:
"""Stop timer when entire pane is hidden."""
"""Stop timers when entire pane is hidden."""
if not visible:
self._stop_timeline_timer()
self._stop_tasks_timer()
def _sync_timeline_timer(self, section_id: str, *, visible: bool) -> None:
"""Start/stop the refresh timer when the GitHub section toggles."""
"""Start/stop the timeline refresh timer when the Planning section toggles."""
if section_id != SECTION_PLANNING:
return
if visible:
@ -201,6 +363,17 @@ class ProjectStatePane(Vertical):
self._refresh_timer.stop()
self._refresh_timer = None
@on(TabbedContent.TabActivated, f"#{SECTION_PLANNING}")
def _on_planning_tab_activated(
self, event: TabbedContent.TabActivated
) -> None:
active = event.tabbed_content.active
if active == "tab-tasks":
self._fetch_tasks()
self._start_tasks_timer()
else:
self._stop_tasks_timer()
# ------------------------------------------------------------------
# Toolbar — generic button handler
# ------------------------------------------------------------------
@ -208,11 +381,21 @@ class ProjectStatePane(Vertical):
@on(Button.Pressed)
def _on_toolbar_button(self, event: Button.Pressed) -> None:
btn_id = event.button.id or ""
if btn_id == "btn-stack-toggle":
event.stop()
self._stack_mode = not self._stack_mode
self._sync_toolbar()
return
if not btn_id.startswith("btn-section-"):
return
event.stop()
section_id = btn_id.removeprefix("btn-")
self.toggle_section(section_id)
if self._stack_mode:
# Multi-select mode — toggle the single section
self.toggle_section(section_id)
else:
# Default accordion mode — show only the clicked section
self.show_single_section(section_id)
def _sync_toolbar(self) -> None:
"""Sync all toolbar buttons and fire AllSectionsHidden if needed."""
@ -225,6 +408,22 @@ class ProjectStatePane(Vertical):
any_visible = True
else:
btn.remove_class("active")
# Stack-mode toggle visual state
try:
stack_btn = self.query_one("#btn-stack-toggle", Button)
except NoMatches:
stack_btn = None
if stack_btn is not None:
if self._stack_mode:
stack_btn.add_class("active")
stack_btn.tooltip = (
"Multi-section mode ON — click again for single-section"
)
else:
stack_btn.remove_class("active")
stack_btn.tooltip = (
"Show multiple sections at once (accordion mode is on)"
)
if not any_visible:
self.post_message(self.AllSectionsHidden())
@ -238,6 +437,15 @@ class ProjectStatePane(Vertical):
self._sync_toolbar()
self._sync_timeline_timer(section_id, visible=True)
def show_single_section(self, section_id: str) -> None:
"""Show ``section_id`` and hide all other sections (accordion)."""
for sec in SECTIONS:
visible = sec.section_id == section_id
widget = self.query_one(f"#{sec.section_id}")
widget.display = visible
self._sync_timeline_timer(sec.section_id, visible=visible)
self._sync_toolbar()
def hide_section(self, section_id: str) -> None:
"""Hide a section by its ID."""
self.query_one(f"#{section_id}").display = False
@ -308,3 +516,296 @@ class ProjectStatePane(Vertical):
def refresh_timeline(self) -> None:
"""Re-fetch timeline data. Called via socket controller."""
self._fetch_timeline()
# ------------------------------------------------------------------
# Tasks — provider → filter → table → detail
# ------------------------------------------------------------------
def _make_task_provider(self) -> TaskProvider | None:
cfg = _read_timeline_config(self._project_path)
if cfg is None:
return None
return TaskProvider(
repo=cfg["repo"],
project_number=cfg["project_number"],
)
@work(exclusive=True, exit_on_error=False, group="fetch-tasks")
async def _fetch_tasks(self) -> None:
if self._task_provider is None:
self._set_tasks_status("No task provider configured.", error=True)
return
self._set_tasks_status("Loading tasks…")
try:
tasks = await self._task_provider.fetch_tasks()
except Exception as exc:
log.warning("Task fetch failed: %s", exc)
self._set_tasks_status(f"Task fetch failed: {exc}", error=True)
return
self._all_tasks = tasks
self._sync_milestone_options(tasks)
self._apply_filters()
def _set_tasks_status(self, message: str, *, error: bool = False) -> None:
"""Update the inline status label above the table."""
try:
label = self.query_one("#tasks-status", Static)
except NoMatches:
return
label.update(message)
if error:
label.add_class("error")
else:
label.remove_class("error")
def _sync_milestone_options(self, tasks: list[TaskItem]) -> None:
seen: dict[str, str] = {}
for task in tasks:
if task.milestone_id and task.milestone_id not in seen:
seen[task.milestone_id] = task.milestone_title or task.milestone_id
try:
toolbar = self.query_one("#task-filter-toolbar", FilterToolbar)
except NoMatches:
return
toolbar.set_milestones([(title, mid) for mid, title in seen.items()])
def _apply_filters(self) -> None:
filtered = filter_tasks(
self._all_tasks,
status=self._filter_state.status,
milestone_id=self._filter_state.milestone_id,
priority=self._filter_state.priority,
title_query=self._filter_state.title_query,
type_filter=self._filter_state.type_filter,
exclude_done=self._filter_state.exclude_done,
)
try:
table = self.query_one("#task-table", TaskTable)
except NoMatches:
return
table.set_column_set(self._filter_state.type_filter or "all")
table.set_tasks(filtered)
total = len(self._all_tasks)
shown = len(filtered)
if total == 0:
self._set_tasks_status("No tasks loaded.")
elif shown == 0:
self._set_tasks_status(
f"No tasks match filters ({total} total). Press r to refresh."
)
else:
self._set_tasks_status(
f"Showing {shown} of {total} tasks."
)
@on(FilterToolbar.FiltersChanged)
def _on_filters_changed(self, event: FilterToolbar.FiltersChanged) -> None:
event.stop()
self._filter_state = event.state
self._apply_filters()
@on(FilterToolbar.RefreshRequested)
def _on_tasks_refresh_requested(
self, event: FilterToolbar.RefreshRequested
) -> None:
event.stop()
self._fetch_tasks()
@on(TaskDetail.DrillDownRequested)
def _on_task_drill_down(self, event: TaskDetail.DrillDownRequested) -> None:
event.stop()
self.open_task_drill_down(event.task)
@on(DataTable.RowSelected, "#task-table")
def _on_task_row_selected(self, event: DataTable.RowSelected) -> None:
event.stop()
key = event.row_key.value
if key is None:
return
table = self.query_one("#task-table", TaskTable)
task = table.get_task(str(key))
if task is None:
return
detail = self.query_one("#task-detail", TaskDetail)
detail.show_task(task)
self._selected_task_id = task.id
self._show_tasks_detail(task)
self._fetch_task_details(task.number)
def _show_tasks_detail(self, task: TaskItem) -> None:
"""Switch the Tasks tab to the detail view and update the breadcrumb."""
try:
switcher = self.query_one("#tasks-switcher", ContentSwitcher)
label = self.query_one("#tasks-breadcrumb-label", Static)
except NoMatches:
return
label.update(f" Board #{task.number} {task.title}")
switcher.current = "tasks-detail-view"
def _show_tasks_list(self) -> None:
"""Switch back to the list view and restore focus on the table."""
try:
switcher = self.query_one("#tasks-switcher", ContentSwitcher)
table = self.query_one("#task-table", TaskTable)
except NoMatches:
return
switcher.current = "tasks-list-view"
table.focus()
@on(Button.Pressed, "#tasks-back-btn")
def _on_tasks_back(self, event: Button.Pressed) -> None:
event.stop()
self._show_tasks_list()
@work(exclusive=True, exit_on_error=False, group="fetch-task-details")
async def _fetch_task_details(self, number: int) -> None:
if self._task_provider is None:
return
try:
details = await self._task_provider.fetch_task_details(number)
except Exception as exc:
log.warning("Task detail fetch failed for #%s: %s", number, exc)
return
try:
detail = self.query_one("#task-detail", TaskDetail)
except NoMatches:
return
detail.show_details(details)
def _start_tasks_timer(self) -> None:
if self._tasks_refresh_timer is None:
self._tasks_refresh_timer = self.set_interval(
self.REFRESH_INTERVAL, self._fetch_tasks
)
def _stop_tasks_timer(self) -> None:
if self._tasks_refresh_timer is not None:
self._tasks_refresh_timer.stop()
self._tasks_refresh_timer = None
# ------------------------------------------------------------------
# Keybindings — active only while the Tasks tab is visible
# ------------------------------------------------------------------
def _is_tasks_tab_active(self) -> bool:
try:
tc = self.query_one(f"#{SECTION_PLANNING}", TabbedContent)
except NoMatches:
return False
return tc.display and tc.active == "tab-tasks"
def action_refresh_tasks(self) -> None:
if not self._is_tasks_tab_active():
return
self._fetch_tasks()
def action_focus_task_filter(self) -> None:
if not self._is_tasks_tab_active():
return
try:
toolbar = self.query_one("#task-filter-toolbar", FilterToolbar)
except NoMatches:
return
toolbar.focus_title_input()
def apply_task_filters(self, filters: dict[str, Any] | None) -> None:
"""Apply chat-supplied filters to the Board / Tasks view.
Recognised keys (all optional): ``status``, ``milestone``, ``priority``,
``title``. Invalid values are ignored. Missing keys keep the existing
selection.
"""
if not filters:
return
from toad.widgets.github_views.timeline_provider import (
ItemStatus as _Status,
Priority as _Prio,
)
state = self._filter_state
status = state.status
milestone_id = state.milestone_id
priority = state.priority
title_query = state.title_query
raw_status = filters.get("status")
if isinstance(raw_status, str):
try:
status = _Status(raw_status.lower())
except ValueError:
log.debug("unknown status filter: %s", raw_status)
raw_priority = filters.get("priority")
if raw_priority is not None:
try:
priority = _Prio(int(str(raw_priority).lstrip("pP")))
except (ValueError, TypeError):
log.debug("unknown priority filter: %s", raw_priority)
raw_milestone = filters.get("milestone")
if isinstance(raw_milestone, str) and raw_milestone:
milestone_id = raw_milestone
raw_title = filters.get("title")
if isinstance(raw_title, str):
title_query = raw_title or None
type_filter = state.type_filter
raw_type = filters.get("type")
if isinstance(raw_type, str):
normalized = raw_type.strip().lower()
type_filter = None if normalized in ("", "all") else normalized
self._filter_state = FilterState(
status=status,
milestone_id=milestone_id,
priority=priority,
title_query=title_query,
type_filter=type_filter,
# An explicit status from chat overrides the default hide-done.
exclude_done=state.exclude_done if status is None else False,
)
# Reflect the applied type in the chip UI so the active state matches.
try:
toolbar = self.query_one("#task-filter-toolbar", FilterToolbar)
except NoMatches:
pass
else:
toolbar.set_active_type(type_filter or "all")
self._apply_filters()
def action_tasks_back(self) -> None:
"""Pop the detail view if active, else no-op."""
if not self._is_tasks_tab_active():
return
try:
switcher = self.query_one("#tasks-switcher", ContentSwitcher)
except NoMatches:
return
if switcher.current == "tasks-detail-view":
self._show_tasks_list()
# ------------------------------------------------------------------
# Drill-down — live-updating screen with lazy details
# ------------------------------------------------------------------
def open_task_drill_down(self, task: TaskItem) -> None:
"""Push the full-screen task detail, fetching details into it live."""
# Lazy import — avoids cycle with ``toad.screens``.
from toad.screens.task_detail_screen import TaskDetailScreen
screen = TaskDetailScreen(task)
self.app.push_screen(screen)
self._fetch_task_details_into(task.number, screen)
@work(exclusive=True, exit_on_error=False, group="fetch-drill-details")
async def _fetch_task_details_into(
self,
number: int,
screen: Any,
) -> None:
if self._task_provider is None:
return
try:
details = await self._task_provider.fetch_task_details(number)
except Exception as exc:
log.warning("Drill-down detail fetch failed for #%s: %s", number, exc)
screen.set_error(str(exc))
return
screen.set_details(details)

View file

@ -0,0 +1,180 @@
"""TaskDetail widget — ContentSwitcher between an empty state and task detail.
Master-detail partner of :class:`toad.widgets.task_table.TaskTable`. The
table calls :meth:`TaskDetail.show_task` on row selection to render the
selected task's metadata immediately; body + comments are populated via
:meth:`TaskDetail.show_details` once the async fetch completes.
"""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Container, VerticalScroll
from textual.message import Message
from textual.widgets import Button, Collapsible, ContentSwitcher, Markdown, Static
from toad.widgets.github_views.task_provider import TaskDetailData, TaskItem
_EMPTY_ID = "empty"
_DETAIL_ID = "detail"
class TaskDetail(Container):
"""Detail pane for the Tasks widget.
Holds a :class:`ContentSwitcher` with two children:
* an empty-state placeholder shown before any row is selected,
* a detail view with the task title, rendered Markdown body, and a
:class:`Collapsible` metadata panel exposing labels, dates, linked
PRs, and a "View comments" button that pushes the drill-down
screen.
"""
DEFAULT_CSS = """
TaskDetail {
height: 1fr;
width: 1fr;
}
TaskDetail ContentSwitcher {
height: 1fr;
}
TaskDetail #detail {
padding: 1 2;
}
TaskDetail .task-detail-title {
text-style: bold;
padding-bottom: 1;
}
TaskDetail .task-detail-empty {
content-align: center middle;
color: $text-muted;
height: 1fr;
}
TaskDetail Button {
margin-top: 1;
}
"""
class DrillDownRequested(Message):
"""Emitted when the user activates "View comments"."""
def __init__(self, task: TaskItem) -> None:
super().__init__()
self.task = task
def __init__(
self,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
self._task_item: TaskItem | None = None
self._details: TaskDetailData | None = None
def compose(self) -> ComposeResult:
with ContentSwitcher(initial=_EMPTY_ID):
yield Static(
"Select a task to see details.",
id=_EMPTY_ID,
classes="task-detail-empty",
)
with VerticalScroll(id=_DETAIL_ID):
yield Static("", id="task-detail-title", classes="task-detail-title")
yield Static("", id="task-detail-summary")
yield Markdown("", id="task-detail-body")
with Collapsible(title="Metadata", id="task-detail-meta"):
yield Static("", id="task-detail-meta-body")
yield Button(
"View comments",
id="task-detail-view-comments",
variant="primary",
)
def show_task(self, task: TaskItem) -> None:
"""Render immediate task metadata and switch to the detail view."""
self._task_item = task
self._details = None
self.query_one("#task-detail-title", Static).update(
f"#{task.number}{task.title}"
)
self.query_one("#task-detail-summary", Static).update(
_render_summary(task)
)
self.query_one("#task-detail-body", Markdown).update(
"_Loading body…_"
)
self.query_one("#task-detail-meta-body", Static).update(
_render_meta(task, None)
)
self.query_one(ContentSwitcher).current = _DETAIL_ID
def show_details(self, details: TaskDetailData) -> None:
"""Render the lazy-loaded body + linked PRs once available."""
self._details = details
self.query_one("#task-detail-body", Markdown).update(
details.body or "_(no description)_"
)
self.query_one("#task-detail-meta-body", Static).update(
_render_meta(self._task_item, details)
)
def clear(self) -> None:
"""Reset back to the empty state."""
self._task_item = None
self._details = None
self.query_one(ContentSwitcher).current = _EMPTY_ID
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Drill into the full-screen detail view on "View comments"."""
if event.button.id != "task-detail-view-comments":
return
if self._task_item is None:
return
event.stop()
self.post_message(self.DrillDownRequested(self._task_item))
def _render_summary(task: TaskItem) -> str:
"""Short one-line metadata summary shown above the body."""
parts: list[str] = [f"status: {task.status.value}"]
if task.milestone_title:
parts.append(f"milestone: {task.milestone_title}")
if task.priority is not None:
parts.append(f"priority: {task.priority.value}")
if task.assignees:
parts.append(f"assignees: {', '.join(task.assignees)}")
if task.effort:
parts.append(f"effort: {task.effort}")
return " · ".join(parts)
def _render_meta(
task: TaskItem | None, details: TaskDetailData | None
) -> str:
"""Multi-line metadata block for the Collapsible panel."""
if task is None:
return ""
lines: list[str] = []
if task.labels:
lines.append(f"labels: {', '.join(task.labels)}")
if task.start_date:
lines.append(f"start: {task.start_date.isoformat()}")
if task.target_date:
lines.append(f"target: {task.target_date.isoformat()}")
if task.created_at:
lines.append(f"created: {task.created_at.date().isoformat()}")
if task.updated_at:
lines.append(f"updated: {task.updated_at.date().isoformat()}")
comments = details.comments_count if details else task.comments_count
lines.append(f"comments: {comments}")
if details and details.linked_prs:
pr_refs = ", ".join(
f"#{pr.get('number')}" for pr in details.linked_prs if pr.get("number")
)
lines.append(f"linked PRs: {pr_refs}")
if task.url:
lines.append(f"url: {task.url}")
return "\n".join(lines)

View file

@ -0,0 +1,207 @@
"""TaskTable — DataTable master listing project-board items.
Subclasses ``DataTable`` with ``cursor_type="row"``. Row keys are the
``TaskItem.id`` string so selection events round-trip back to the
owning task via ``event.row_key.value``.
Columns are **contextual** the caller picks a ``ColumnSet`` that
matches the active type-chip filter (All, Plans, PRs, Bugs, Features).
Switching the column set on the fly clears the existing columns and
re-renders the rows.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from textual.widgets import DataTable
from toad.widgets.github_views.task_provider import TaskItem
from toad.widgets.github_views.timeline_provider import ItemStatus, Priority
_STATUS_LABELS: dict[ItemStatus, str] = {
ItemStatus.TODO: "Todo",
ItemStatus.IN_PROGRESS: "In Progress",
ItemStatus.DONE: "Done",
}
_PRIORITY_LABELS: dict[Priority, str] = {
Priority.P1: "P1",
Priority.P2: "P2",
Priority.P3: "P3",
Priority.P4: "P4",
}
# Named column sets per active chip. Keys match the chip values in
# FilterToolbar._TYPE_CHIPS. The first entry in each tuple is the column
# header; the second is a lambda (task) → cell string.
COLUMN_SETS: dict[str, tuple[tuple[str, Any], ...]] = {
"all": (
("Status", lambda t: _format_status(t.status)),
("Title", lambda t: _truncate(t.title, 60)),
("Milestone", lambda t: t.milestone_title),
("Priority", lambda t: _format_priority(t.priority)),
("Assignee", lambda t: _format_assignees(t.assignees)),
),
"plan": (
("Status", lambda t: _format_status(t.status)),
("Title", lambda t: _truncate(t.title, 50)),
("Progress", lambda t: _format_progress(t.progress_pct)),
("Milestone", lambda t: t.milestone_title),
("Priority", lambda t: _format_priority(t.priority)),
),
"pr": (
("#", lambda t: f"#{t.number}"),
("Title", lambda t: _truncate(t.title, 45)),
("Review", lambda t: _format_review(t.review_state)),
("CI", lambda t: _format_ci(t.ci_state)),
("Age", lambda t: _format_age(t.created_at)),
("Author", lambda t: t.author or ""),
),
"bug": (
("Status", lambda t: _format_status(t.status)),
("Title", lambda t: _truncate(t.title, 55)),
("Priority", lambda t: _format_priority(t.priority)),
("Assignee", lambda t: _format_assignees(t.assignees)),
("Age", lambda t: _format_age(t.created_at)),
),
"feature": (
("Status", lambda t: _format_status(t.status)),
("Title", lambda t: _truncate(t.title, 55)),
("Milestone", lambda t: t.milestone_title),
("Priority", lambda t: _format_priority(t.priority)),
("Assignee", lambda t: _format_assignees(t.assignees)),
),
}
def _format_status(status: ItemStatus) -> str:
return _STATUS_LABELS.get(status, status.value)
def _format_priority(priority: Priority | None) -> str:
if priority is None:
return ""
return _PRIORITY_LABELS.get(priority, "")
def _format_assignees(assignees: list[str]) -> str:
if not assignees:
return ""
if len(assignees) == 1:
return assignees[0]
return f"{assignees[0]} +{len(assignees) - 1}"
def _format_progress(pct: int | None) -> str:
if pct is None:
return ""
filled = round(pct / 10)
bar = "" * filled + "" * (10 - filled)
return f"{bar} {pct:>3}%"
def _format_review(state: str | None) -> str:
if not state:
return ""
mapping = {
"APPROVED": "✓ approved",
"CHANGES_REQUESTED": "✗ changes",
"REVIEW_REQUIRED": "… needed",
"COMMENTED": "· comment",
}
return mapping.get(state, state.lower())
def _format_ci(state: str | None) -> str:
if not state:
return ""
mapping = {
"SUCCESS": "✓ pass",
"FAILURE": "✗ fail",
"PENDING": "… run",
"NONE": "",
}
return mapping.get(state, state.lower())
def _format_age(created: datetime | None) -> str:
if created is None:
return ""
now = datetime.now(timezone.utc)
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
delta = now - created
days = delta.days
if days < 1:
hours = delta.seconds // 3600
return f"{hours}h"
if days < 30:
return f"{days}d"
if days < 365:
return f"{days // 30}mo"
return f"{days // 365}y"
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 1] + "\u2026"
class TaskTable(DataTable[str]):
"""DataTable listing ``TaskItem`` rows keyed by issue id.
Column layout is chosen via :meth:`set_column_set`. Call it before
:meth:`set_tasks` (or in any order the table re-renders).
"""
DEFAULT_CSS = """
TaskTable {
height: 1fr;
}
"""
def __init__(self, **kwargs: Any) -> None:
super().__init__(zebra_stripes=True, **kwargs)
self.cursor_type = "row"
self._tasks: dict[str, TaskItem] = {}
self._task_order: list[str] = []
self._column_set: str = "all"
def set_column_set(self, name: str) -> None:
"""Switch the visible columns to ``COLUMN_SETS[name]`` and re-render."""
if name not in COLUMN_SETS:
name = "all"
if name == self._column_set and self.columns:
return
self._column_set = name
self.clear(columns=True)
headers = tuple(h for h, _ in COLUMN_SETS[name])
self.add_columns(*headers)
if self._tasks:
self._rerender_rows()
def set_tasks(self, tasks: list[TaskItem]) -> None:
"""Replace all rows with ``tasks``. Row keys = ``task.id``."""
self._tasks = {t.id: t for t in tasks}
self._task_order = [t.id for t in tasks]
if not self.columns:
self.set_column_set(self._column_set)
return
self._rerender_rows()
def _rerender_rows(self) -> None:
self.clear()
formatters = COLUMN_SETS[self._column_set]
for task_id in self._task_order:
task = self._tasks.get(task_id)
if task is None:
continue
self.add_row(*(fmt(task) for _, fmt in formatters), key=task.id)
def get_task(self, task_id: str) -> TaskItem | None:
"""Return the ``TaskItem`` previously set for ``task_id``."""
return self._tasks.get(task_id)

View file

@ -68,6 +68,7 @@ class TerminalTool(Terminal):
classes: str | None = None,
disabled: bool = False,
minimum_terminal_width: int = -1,
quiet: bool = False,
):
super().__init__(
name=name,
@ -77,6 +78,7 @@ class TerminalTool(Terminal):
minimum_terminal_width=minimum_terminal_width,
)
self._command = command
self._quiet = quiet
self._output_byte_limit = output_byte_limit
self._command_task: asyncio.Task | None = None
self._output: deque[bytes] = deque()
@ -242,8 +244,7 @@ class TerminalTool(Terminal):
data = await shell_read(reader, BUFFER_SIZE)
if process_data := unicode_decoder.decode(data, final=not data):
self._record_output(data)
if await self.write(process_data):
self.display = True
await self.write(process_data)
if not data:
break
finally:
@ -252,9 +253,8 @@ class TerminalTool(Terminal):
self.finalize()
return_code = self._return_code = await process.wait()
if return_code == 0:
self.display = False
else:
if return_code != 0:
self.display = True
self.add_class("-error")
self.border_title = Content.assemble(
f"{command} [{return_code}]",

176
tests/conftest.py Normal file
View file

@ -0,0 +1,176 @@
"""Shared fixtures for Tasks-widget tests.
Provides mock ``gh`` CLI responses so ``TaskProvider`` tests stay offline
and deterministic.
"""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from typing import Any
import pytest
from toad.widgets.github_views.task_provider import TaskDetailData, TaskItem
from toad.widgets.github_views.timeline_provider import ItemStatus, Priority
@pytest.fixture
def mock_issues_payload() -> str:
"""Raw JSON string returned by ``gh issue list --json ...``."""
issues: list[dict[str, Any]] = [
{
"number": 101,
"title": "Wire Tasks tab",
"state": "OPEN",
"labels": [{"name": "p1"}, {"name": "risk:scope"}],
"createdAt": "2026-04-10T12:00:00Z",
"updatedAt": "2026-04-11T08:00:00Z",
"milestone": {"number": 1, "title": "M1 — UI"},
"url": "https://github.com/acme/proj/issues/101",
"assignees": [{"login": "alberto"}],
"comments": 4,
},
{
"number": 102,
"title": "Ship PM widget",
"state": "CLOSED",
"labels": [{"name": "p3"}],
"createdAt": "2026-04-01T09:00:00Z",
"updatedAt": "2026-04-12T10:00:00Z",
"milestone": None,
"url": "https://github.com/acme/proj/issues/102",
"assignees": [],
"comments": 0,
},
]
return json.dumps(issues)
@pytest.fixture
def mock_project_payload() -> str:
"""Raw JSON string returned by ``gh api graphql ...`` project query."""
data = {
"data": {
"organization": {
"projectV2": {
"items": {
"nodes": [
{
"content": {"number": 101},
"fieldValues": {
"nodes": [
{
"field": {"name": "Status"},
"name": "In Progress",
},
{
"field": {"name": "Effort"},
"number": 2,
},
{
"field": {"name": "Start Date"},
"date": "2026-04-10",
},
{
"field": {"name": "Target Date"},
"date": "2026-04-18",
},
]
},
},
{
"content": {"number": 102},
"fieldValues": {"nodes": []},
},
]
}
}
}
}
}
return json.dumps(data)
@pytest.fixture
def mock_issue_detail_payload() -> str:
"""Raw JSON returned by ``gh issue view <n> --json ...``."""
data = {
"number": 101,
"body": "## Heading\n\nBody text with **markdown**.",
"comments": [
{"author": {"login": "alice"}, "body": "+1"},
{"author": {"login": "bob"}, "body": "LGTM"},
],
"labels": [{"name": "p1"}, {"name": "risk:scope"}],
"assignees": [{"login": "alberto"}],
"url": "https://github.com/acme/proj/issues/101",
"closedByPullRequestsReferences": [
{"number": 200, "url": "https://github.com/acme/proj/pull/200"},
],
}
return json.dumps(data)
@pytest.fixture
def sample_tasks() -> list[TaskItem]:
"""Deterministic list of ``TaskItem`` for widget tests."""
return [
TaskItem(
id="101",
number=101,
title="Wire Tasks tab",
status=ItemStatus.IN_PROGRESS,
milestone_id="1",
milestone_title="M1 — UI",
priority=Priority.P1,
assignees=["alberto"],
effort="2",
labels=["p1", "risk:scope"],
risk_labels=["risk:scope"],
start_date=date(2026, 4, 10),
target_date=date(2026, 4, 18),
created_at=datetime(2026, 4, 10, 12, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 4, 11, 8, 0, tzinfo=timezone.utc),
comments_count=4,
url="https://github.com/acme/proj/issues/101",
state="open",
),
TaskItem(
id="102",
number=102,
title="Ship PM widget",
status=ItemStatus.DONE,
milestone_id=None,
milestone_title="",
priority=Priority.P3,
assignees=[],
effort=None,
labels=["p3"],
risk_labels=[],
start_date=None,
target_date=None,
created_at=datetime(2026, 4, 1, 9, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 4, 12, 10, 0, tzinfo=timezone.utc),
comments_count=0,
url="https://github.com/acme/proj/issues/102",
state="closed",
),
]
@pytest.fixture
def sample_details() -> TaskDetailData:
"""Deterministic detail payload for a single task."""
return TaskDetailData(
number=101,
body="## Heading\n\nBody text.",
comments_count=2,
linked_prs=[
{"number": 200, "url": "https://github.com/acme/proj/pull/200"}
],
labels=["p1", "risk:scope"],
assignees=["alberto"],
url="https://github.com/acme/proj/issues/101",
)

491
tests/test_task_widgets.py Normal file
View file

@ -0,0 +1,491 @@
"""Tests for the Tasks-widget stack.
Covers three concerns:
1. Provider parsing ``TaskProvider.fetch_tasks`` against mocked ``_run_gh``.
2. Filter predicates ``filter_toolbar.filter_tasks`` status/milestone/priority.
3. Interaction flows via Textual's ``App.run_test()`` pilot:
- arrow + enter swaps the ``ContentSwitcher`` to the detail view,
- enter on "View comments" pushes ``TaskDetailScreen``,
- escape pops the screen back to the list.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, patch
import json
from dataclasses import replace
import pytest
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Button, ContentSwitcher, DataTable
from toad.widgets.filter_toolbar import filter_tasks
from toad.widgets.github_views.task_provider import (
TaskDetailData,
TaskItem,
TaskProvider,
)
from toad.widgets.github_views.timeline_provider import ItemStatus, Priority
from toad.widgets.task_detail import TaskDetail
from toad.widgets.task_table import TaskTable
# ---------------------------------------------------------------------------
# Provider parsing
# ---------------------------------------------------------------------------
class TestTaskProviderParsing:
"""``TaskProvider.fetch_tasks`` must join issues + project board data."""
@pytest.mark.asyncio
async def test_fetch_tasks_joins_issues_and_board(
self,
mock_issues_payload: str,
mock_project_payload: str,
) -> None:
responses = [mock_issues_payload, mock_project_payload]
async def fake_run_gh(*_args: Any, **_kwargs: Any) -> str:
return responses.pop(0)
with patch(
"toad.widgets.github_views.task_provider._run_gh",
side_effect=fake_run_gh,
):
provider = TaskProvider(repo="acme/proj", project_number=1)
tasks = await provider.fetch_tasks()
assert len(tasks) == 2
by_number = {t.number: t for t in tasks}
t101 = by_number[101]
assert t101.id == "101"
assert t101.title == "Wire Tasks tab"
assert t101.status == ItemStatus.IN_PROGRESS
assert t101.milestone_title == "M1 — UI"
assert t101.priority == Priority.P1
assert t101.assignees == ["alberto"]
assert t101.effort == "2"
assert t101.risk_labels == ["risk:scope"]
assert t101.comments_count == 4
t102 = by_number[102]
assert t102.status == ItemStatus.DONE # closed -> DONE
assert t102.priority == Priority.P3
assert t102.milestone_id is None
assert t102.assignees == []
@pytest.mark.asyncio
async def test_fetch_tasks_includes_prs(
self,
mock_issues_payload: str,
mock_project_payload: str,
) -> None:
"""PRs fetched via ``gh pr list`` show up as TaskItems with is_pr=True."""
prs_payload = json.dumps(
[
{
"number": 200,
"title": "feat: wire tasks tab",
"state": "OPEN",
"labels": [{"name": "type:feature"}],
"createdAt": "2026-04-10T10:00:00Z",
"updatedAt": "2026-04-15T12:00:00Z",
"url": "https://github.com/acme/proj/pull/200",
"author": {"login": "alberto"},
"reviewDecision": "APPROVED",
"statusCheckRollup": [
{"state": "SUCCESS"},
{"state": "SUCCESS"},
],
"mergeable": "MERGEABLE",
"isDraft": False,
"milestone": None,
"assignees": [],
}
]
)
responses = [mock_issues_payload, mock_project_payload, prs_payload]
async def fake_run_gh(*args: Any, **_kwargs: Any) -> str:
# Issues/project return first; pr list third.
if "pr" in args:
return prs_payload
return responses.pop(0)
with patch(
"toad.widgets.github_views.task_provider._run_gh",
side_effect=fake_run_gh,
):
provider = TaskProvider(repo="acme/proj", project_number=1)
tasks = await provider.fetch_tasks()
prs = [t for t in tasks if t.is_pr]
assert len(prs) == 1
pr = prs[0]
assert pr.id == "pr-200"
assert pr.number == 200
assert pr.author == "alberto"
assert pr.review_state == "APPROVED"
assert pr.ci_state == "SUCCESS"
assert pr.mergeable == "MERGEABLE"
def test_progress_from_body_checkboxes(self) -> None:
from toad.widgets.github_views.task_provider import _progress_from_body
assert _progress_from_body("") is None
assert _progress_from_body("no checkboxes here") is None
body = "\n".join(
[
"- [x] done",
"- [ ] pending",
"* [X] also done",
"+ [ ] another pending",
]
)
assert _progress_from_body(body) == 50
@pytest.mark.asyncio
async def test_fetch_task_details_parses_body_and_prs(
self, mock_issue_detail_payload: str
) -> None:
with patch(
"toad.widgets.github_views.task_provider._run_gh",
new=AsyncMock(return_value=mock_issue_detail_payload),
):
provider = TaskProvider(repo="acme/proj", project_number=1)
details = await provider.fetch_task_details(101)
assert details.number == 101
assert "markdown" in details.body
assert details.comments_count == 2
assert details.linked_prs[0]["number"] == 200
assert details.labels == ["p1", "risk:scope"]
# ---------------------------------------------------------------------------
# Filter predicates
# ---------------------------------------------------------------------------
class TestFilterPredicates:
"""``filter_tasks`` narrows a task list by status/milestone/priority."""
def test_no_filters_returns_all(
self, sample_tasks: list[TaskItem]
) -> None:
assert filter_tasks(sample_tasks) == sample_tasks
def test_filter_by_status(self, sample_tasks: list[TaskItem]) -> None:
result = filter_tasks(sample_tasks, status=ItemStatus.IN_PROGRESS)
assert [t.number for t in result] == [101]
def test_filter_by_milestone(
self, sample_tasks: list[TaskItem]
) -> None:
result = filter_tasks(sample_tasks, milestone_id="1")
assert [t.number for t in result] == [101]
def test_filter_by_priority(
self, sample_tasks: list[TaskItem]
) -> None:
result = filter_tasks(sample_tasks, priority=Priority.P3)
assert [t.number for t in result] == [102]
def test_combined_filters_intersect(
self, sample_tasks: list[TaskItem]
) -> None:
result = filter_tasks(
sample_tasks,
status=ItemStatus.DONE,
priority=Priority.P3,
)
assert [t.number for t in result] == [102]
def test_combined_no_match(self, sample_tasks: list[TaskItem]) -> None:
result = filter_tasks(
sample_tasks,
status=ItemStatus.IN_PROGRESS,
priority=Priority.P3,
)
assert result == []
def test_exclude_done_default_drops_done(
self, sample_tasks: list[TaskItem]
) -> None:
# Default exclude_done=True hides DONE when no explicit status is set.
active = filter_tasks(sample_tasks, exclude_done=True)
assert all(t.status != ItemStatus.DONE for t in active)
# exclude_done=False keeps DONE in the results.
all_tasks = filter_tasks(sample_tasks, exclude_done=False)
assert any(t.status == ItemStatus.DONE for t in all_tasks)
# Explicit DONE status wins over exclude_done flag.
done_only = filter_tasks(
sample_tasks, status=ItemStatus.DONE, exclude_done=True
)
assert all(t.status == ItemStatus.DONE for t in done_only)
def test_filter_by_type_label(
self, sample_tasks: list[TaskItem]
) -> None:
# Add a type:plan label to the first sample task and type:bug to second
tasks = list(sample_tasks)
t0 = tasks[0]
t1 = tasks[1]
tasks[0] = replace(t0, labels=[*t0.labels, "type:plan"])
tasks[1] = replace(t1, labels=[*t1.labels, "type:bug"])
plans = filter_tasks(tasks, type_filter="plan")
assert [t.number for t in plans] == [t0.number]
bugs = filter_tasks(tasks, type_filter="bug")
assert [t.number for t in bugs] == [t1.number]
all_types = filter_tasks(tasks, type_filter="all")
assert all_types == tasks
none_type = filter_tasks(tasks, type_filter=None)
assert none_type == tasks
# ---------------------------------------------------------------------------
# Interaction flows via App.run_test()
# ---------------------------------------------------------------------------
class _SelectionHarness(App[None]):
"""App that wires TaskTable ↔ TaskDetail without the full pane."""
def __init__(self, tasks: list[TaskItem]) -> None:
super().__init__()
self._task_list = tasks
self._by_id = {t.id: t for t in tasks}
def compose(self) -> ComposeResult:
with Horizontal():
yield TaskTable(id="tbl")
yield TaskDetail(id="detail")
async def on_mount(self) -> None:
tbl = self.query_one(TaskTable)
tbl.set_tasks(self._task_list)
tbl.focus()
def on_data_table_row_selected(
self, event: DataTable.RowSelected
) -> None:
key = event.row_key.value
if key is None:
return
task = self._by_id.get(str(key))
if task is not None:
self.query_one(TaskDetail).show_task(task)
@pytest.mark.asyncio
async def test_row_selection_swaps_content_switcher(
sample_tasks: list[TaskItem],
) -> None:
"""`pilot.press("down", "enter")` → ContentSwitcher shows detail view."""
app = _SelectionHarness(sample_tasks)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("down", "enter")
await pilot.pause()
detail = app.query_one(TaskDetail)
switcher = detail.query_one(ContentSwitcher)
assert switcher.current == "detail"
class _DrillDownHarness(App[None]):
"""App that mounts a TaskDetail pre-populated with a task.
Catches :class:`TaskDetail.DrillDownRequested` and pushes
:class:`TaskDetailScreen` mirrors the wiring in ``ProjectStatePane``.
"""
def __init__(
self, task: TaskItem, details: TaskDetailData
) -> None:
super().__init__()
self._task_item = task
self._task_details = details
def compose(self) -> ComposeResult:
yield TaskDetail(id="detail")
async def on_mount(self) -> None:
detail = self.query_one(TaskDetail)
detail.show_task(self._task_item)
detail.show_details(self._task_details)
# Focus the "View comments" control so Enter triggers drill-down.
for btn in detail.query(Button):
if "comment" in str(btn.label).lower():
btn.focus()
break
def on_task_detail_drill_down_requested(
self, event: TaskDetail.DrillDownRequested
) -> None:
from toad.screens.task_detail_screen import TaskDetailScreen
self.push_screen(TaskDetailScreen(event.task, self._task_details))
@pytest.mark.asyncio
async def test_view_comments_pushes_task_detail_screen(
sample_tasks: list[TaskItem], sample_details: TaskDetailData
) -> None:
"""Enter on "View comments" pushes ``TaskDetailScreen``."""
from toad.screens.task_detail_screen import TaskDetailScreen
app = _DrillDownHarness(sample_tasks[0], sample_details)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert isinstance(app.screen, TaskDetailScreen)
@pytest.mark.asyncio
async def test_escape_pops_task_detail_screen(
sample_tasks: list[TaskItem], sample_details: TaskDetailData
) -> None:
"""Escape from ``TaskDetailScreen`` pops back to the list screen."""
from toad.screens.task_detail_screen import TaskDetailScreen
app = _SelectionHarness(sample_tasks)
async with app.run_test() as pilot:
await pilot.pause()
app.push_screen(TaskDetailScreen(sample_tasks[0], sample_details))
await pilot.pause()
assert isinstance(app.screen, TaskDetailScreen)
await pilot.press("escape")
await pilot.pause()
assert not isinstance(app.screen, TaskDetailScreen)
@pytest.mark.asyncio
async def test_task_detail_screen_back_button_pops(
sample_tasks: list[TaskItem], sample_details: TaskDetailData
) -> None:
"""Clicking the ← Back button on ``TaskDetailScreen`` pops the screen."""
from toad.screens.task_detail_screen import TaskDetailScreen
app = _SelectionHarness(sample_tasks)
async with app.run_test() as pilot:
await pilot.pause()
screen = TaskDetailScreen(sample_tasks[0], sample_details)
app.push_screen(screen)
await pilot.pause()
assert isinstance(app.screen, TaskDetailScreen)
back_btn = screen.query_one("#task-screen-back", Button)
back_btn.press()
await pilot.pause()
assert not isinstance(app.screen, TaskDetailScreen)
@pytest.mark.asyncio
async def test_task_detail_screen_close_button_pops(
sample_tasks: list[TaskItem], sample_details: TaskDetailData
) -> None:
"""Clicking the ✕ close button on ``TaskDetailScreen`` pops the screen."""
from toad.screens.task_detail_screen import TaskDetailScreen
app = _SelectionHarness(sample_tasks)
async with app.run_test() as pilot:
await pilot.pause()
screen = TaskDetailScreen(sample_tasks[0], sample_details)
app.push_screen(screen)
await pilot.pause()
close_btn = screen.query_one("#task-screen-close", Button)
close_btn.press()
await pilot.pause()
assert not isinstance(app.screen, TaskDetailScreen)
# ---------------------------------------------------------------------------
# Client-side panel intent detection (chat → panel)
# ---------------------------------------------------------------------------
class TestPanelIntentDetection:
"""The conversation widget parses 'show me X' phrases client-side."""
def _detect(self, text: str):
from toad.widgets.conversation import _detect_panel_intent
return _detect_panel_intent(text)
def test_no_trigger_returns_none(self) -> None:
assert self._detect("just chatting about the weather") is None
assert self._detect("board") is None # no "show me"
assert self._detect("help me with testing") is None
def test_show_me_the_board(self) -> None:
assert self._detect("show me the board") == ("board", None)
assert self._detect("Show me the Board") == ("board", None)
def test_show_me_tasks_routes_to_board(self) -> None:
assert self._detect("show me tasks") == ("board", None)
assert self._detect("show me the tasks") == ("board", None)
def test_show_me_prs(self) -> None:
assert self._detect("show me PRs") == ("prs", None)
assert self._detect("show me pull requests") == ("prs", None)
assert self._detect("open the PRs") == ("prs", None)
def test_show_me_plans(self) -> None:
assert self._detect("show me plans") == ("plans", None)
def test_show_me_the_plan_singular(self) -> None:
# "the plan" (singular) is the exec plan in Context section
assert self._detect("show me the plan") == ("plan", None)
def test_show_me_timeline_files_state(self) -> None:
assert self._detect("show me the timeline") == ("timeline", None)
assert self._detect("show me the files") == ("files", None)
assert self._detect("show me the state") == ("state", None)
def test_priority_filter_extracted(self) -> None:
pid, filters = self._detect("show me P1 tasks")
assert pid == "board"
assert filters == {"priority": "P1"}
def test_status_filter_extracted(self) -> None:
pid, filters = self._detect("show me done tasks")
assert pid == "board"
assert filters == {"status": "done"}
def test_combined_priority_and_status(self) -> None:
pid, filters = self._detect("show me P2 in progress tasks")
assert pid == "board"
assert filters == {"priority": "P2", "status": "in_progress"}
def test_open_variants(self) -> None:
assert self._detect("open the timeline") == ("timeline", None)
assert self._detect("go to the board") == ("board", None)
assert self._detect("switch to PRs") == ("prs", None)
class TestCloseIntentDetection:
"""Close-panel phrasings route to ClosePanel for the right target."""
def _detect(self, text: str):
from toad.widgets.conversation import _detect_close_intent
return _detect_close_intent(text)
def test_no_close_keyword_returns_none(self) -> None:
assert self._detect("show me the board") is None
assert self._detect("what's next") is None
def test_close_the_right_panel_hides_everything(self) -> None:
assert self._detect("close the right panel") == "project_state"
assert self._detect("hide everything") == "project_state"
assert self._detect("dismiss the right pane") == "project_state"
def test_close_specific_panel(self) -> None:
assert self._detect("close the board") == "board"
assert self._detect("hide the timeline") == "timeline"
assert self._detect("close the plan") == "plan"
assert self._detect("collapse the files") == "files"

View file

@ -6,6 +6,7 @@ Usage:
uv run python tools/verify-tui.py --verbose
uv run python tools/verify-tui.py --widget gantt
uv run python tools/verify-tui.py --widget github
uv run python tools/verify-tui.py --widget tasks
Runs the app or individual widgets headless and reports layout,
scroll behavior, and rendering issues. Exit code 0 = all checks pass.
@ -14,6 +15,8 @@ scroll behavior, and rendering issues. Exit code 0 = all checks pass.
from __future__ import annotations
import argparse
import asyncio
import subprocess
import sys
from datetime import date
@ -183,8 +186,8 @@ def verify_pane_no_default(verbose: bool = False) -> bool:
from textual.app import App, ComposeResult
from toad.widgets.project_state_pane import (
ProjectStatePane,
SECTION_GITHUB,
SECTION_BUILDER,
SECTION_PLANNING,
SECTION_STATE,
)
errors: list[str] = []
@ -204,16 +207,20 @@ def verify_pane_no_default(verbose: bool = False) -> bool:
def _check(self) -> None:
pane = self.query_one("#psp", ProjectStatePane)
github = pane.query_one(f"#{SECTION_GITHUB}")
builder = pane.query_one(f"#{SECTION_BUILDER}")
planning = pane.query_one(f"#{SECTION_PLANNING}")
state = pane.query_one(f"#{SECTION_STATE}")
results["github_visible"] = github.display
results["builder_visible"] = builder.display
results["planning_visible"] = planning.display
results["state_visible"] = state.display
if github.display:
errors.append("GitHub section visible on mount (should be hidden)")
if builder.display:
errors.append("Builder section visible on mount (should be hidden)")
if planning.display:
errors.append(
"Planning section visible on mount (should be hidden)"
)
if state.display:
errors.append(
"State section visible on mount (should be hidden)"
)
self.exit()
@ -226,6 +233,208 @@ def verify_pane_no_default(verbose: bool = False) -> bool:
return len(errors) == 0, errors, results
def verify_tasks(verbose: bool = False) -> bool:
"""Verify Tasks widget stack: mount + down/enter/escape interaction."""
from datetime import datetime
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import ContentSwitcher, DataTable
from toad.widgets.github_views.task_provider import TaskDetailData, TaskItem
from toad.widgets.github_views.timeline_provider import ItemStatus, Priority
from toad.widgets.filter_toolbar import FilterToolbar, filter_tasks
from toad.widgets.task_detail import TaskDetail
from toad.widgets.task_table import TaskTable
from toad.screens.task_detail_screen import TaskDetailScreen
errors: list[str] = []
results: dict[str, object] = {}
tasks = [
TaskItem(
id="101",
number=101,
title="Wire Tasks tab",
status=ItemStatus.IN_PROGRESS,
milestone_id="1",
milestone_title="M1 — UI",
priority=Priority.P1,
assignees=["alberto"],
effort="2",
labels=["p1-must-ship"],
comments_count=4,
created_at=datetime(2026, 4, 10, 12, 0),
updated_at=datetime(2026, 4, 15, 9, 0),
url="https://github.com/acme/proj/issues/101",
),
TaskItem(
id="102",
number=102,
title="Document widgets",
status=ItemStatus.DONE,
milestone_id=None,
milestone_title="",
priority=Priority.P3,
assignees=[],
effort=None,
labels=["p3"],
comments_count=0,
url="https://github.com/acme/proj/issues/102",
),
]
details = TaskDetailData(
number=101,
body="# body\n\nrendered markdown here.",
comments_count=2,
linked_prs=[{"number": 200, "title": "PR"}],
labels=["p1-must-ship"],
assignees=["alberto"],
url="https://github.com/acme/proj/issues/101",
)
# --- Test 1: Filter predicate still wired ---
filtered = filter_tasks(tasks, status=ItemStatus.IN_PROGRESS)
if [t.number for t in filtered] != [101]:
errors.append(
f"filter_tasks: expected [101], got {[t.number for t in filtered]}"
)
# --- Test 2: All three widgets mount + interaction flow ---
class TasksHarness(App[None]):
CSS = "Screen { overflow: hidden; }"
def compose(self) -> ComposeResult:
yield FilterToolbar(id="tb")
with Horizontal(id="body"):
yield TaskTable(id="tbl")
yield TaskDetail(id="detail")
async def on_mount(self) -> None:
tbl = self.query_one(TaskTable)
tbl.set_tasks(tasks)
tbl.focus()
def on_data_table_row_selected(
self, event: DataTable.RowSelected
) -> None:
key = event.row_key.value
if key is None:
return
match = next((t for t in tasks if t.id == str(key)), None)
if match is not None:
self.query_one(TaskDetail).show_task(match)
async def _run_interaction() -> None:
app = TasksHarness()
async with app.run_test(size=(120, 30)) as pilot:
await pilot.pause()
# Confirm all three widgets mounted.
app.query_one(FilterToolbar)
tbl = app.query_one(TaskTable)
detail = app.query_one(TaskDetail)
switcher = detail.query_one(ContentSwitcher)
results["row_count"] = tbl.row_count
results["initial_switcher"] = switcher.current
if tbl.row_count != len(tasks):
errors.append(
f"table row_count={tbl.row_count}, expected {len(tasks)}"
)
if switcher.current != "empty":
errors.append(
f"initial switcher={switcher.current}, expected 'empty'"
)
await pilot.press("down", "enter")
await pilot.pause()
results["after_enter_switcher"] = switcher.current
if switcher.current != "detail":
errors.append(
f"after enter switcher={switcher.current}, expected 'detail'"
)
# Escape on list screen should be a no-op (no screen pushed).
await pilot.press("escape")
await pilot.pause()
results["after_escape_screen"] = type(app.screen).__name__
if isinstance(app.screen, TaskDetailScreen):
errors.append(
"escape unexpectedly left TaskDetailScreen active on list"
)
# Push + escape round-trip: confirms screen-stack pop works.
app.push_screen(TaskDetailScreen(tasks[0], details))
await pilot.pause()
if not isinstance(app.screen, TaskDetailScreen):
errors.append("push_screen(TaskDetailScreen) did not activate")
await pilot.press("escape")
await pilot.pause()
results["final_screen"] = type(app.screen).__name__
if isinstance(app.screen, TaskDetailScreen):
errors.append(
"escape on TaskDetailScreen did not pop back to list"
)
asyncio.run(_run_interaction())
if verbose:
for key, val in results.items():
console.print(f" {key}: {val}")
return len(errors) == 0, errors, results
def verify_live_data_probe(verbose: bool = False) -> bool:
"""Probe the real GitHub API through TaskProvider.
Skips gracefully if `gh` is not installed or not authenticated.
"""
errors: list[str] = []
results: dict[str, object] = {}
# Probe `gh auth status` — skip if unauth'd or gh missing.
try:
proc = subprocess.run(
["gh", "auth", "status"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
except FileNotFoundError:
console.print(" [yellow]skipped: no gh auth[/yellow] (gh not installed)")
return True, [], {"status": "skipped-no-gh"}
except subprocess.TimeoutExpired:
console.print(" [yellow]skipped: no gh auth[/yellow] (timeout)")
return True, [], {"status": "skipped-timeout"}
if proc.returncode != 0:
console.print(" [yellow]skipped: no gh auth[/yellow]")
return True, [], {"status": "skipped-unauth"}
# Run the fetch.
from toad.widgets.github_views.task_provider import TaskProvider
async def _fetch() -> int:
provider = TaskProvider(
repo="DEGAorg/claude-code-config", project_number=8
)
tasks = await provider.fetch_tasks()
return len(tasks)
try:
count = asyncio.run(_fetch())
results["task_count"] = count
if verbose:
console.print(f" fetched {count} tasks from live GitHub API")
except Exception as exc: # noqa: BLE001 - probe surfaces any parser break
errors.append(f"live fetch failed: {exc.__class__.__name__}: {exc}")
return len(errors) == 0, errors, results
def verify_imports(verbose: bool = False) -> bool:
"""Verify all key modules import without error."""
errors: list[str] = []
@ -237,6 +446,11 @@ def verify_imports(verbose: bool = False) -> bool:
"toad.widgets.github_views.timeline_provider",
"toad.widgets.github_views.github_timeline_provider",
"toad.widgets.github_views.timeline_data",
"toad.widgets.github_views.task_provider",
"toad.widgets.task_table",
"toad.widgets.task_detail",
"toad.widgets.filter_toolbar",
"toad.screens.task_detail_screen",
]
for mod in modules:
try:
@ -252,7 +466,7 @@ def main() -> None:
parser.add_argument("--verbose", "-v", action="store_true")
parser.add_argument(
"--widget",
choices=["gantt", "imports", "pane", "all"],
choices=["gantt", "imports", "pane", "tasks", "live", "all"],
default="all",
)
args = parser.parse_args()
@ -261,8 +475,12 @@ def main() -> None:
"imports": verify_imports,
"gantt": verify_gantt,
"pane": verify_pane_no_default,
"tasks": verify_tasks,
}
if args.widget != "all":
# Live probe only runs when explicitly requested — it hits the network.
if args.widget == "live":
checks = {"live": verify_live_data_probe}
elif args.widget != "all":
checks = {args.widget: checks[args.widget]}
all_passed = True