diff --git a/.agents/skills/simplify/SKILL.md b/.agents/skills/simplify/SKILL.md new file mode 100644 index 0000000..ce5e951 --- /dev/null +++ b/.agents/skills/simplify/SKILL.md @@ -0,0 +1,55 @@ +--- +name: simplify +description: Review changed code for reuse, quality, and efficiency, then fix any issues found. +allowed-tools: Read, Grep, Glob, Bash, Edit +--- + +# Simplify: Code Review and Cleanup + +Review all changed files for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run `git diff` (or `git diff HEAD` if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. **Search for existing utilities and helpers** that could replace newly written code. Look for similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. **Flag any new function that duplicates existing functionality.** Suggest the existing function to use instead. +3. **Flag any inline logic that could use an existing utility** — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. **Redundant state**: state that duplicates existing state, cached values that could be derived +2. **Parameter sprawl**: adding new parameters to a function instead of generalizing or restructuring existing ones +3. **Copy-paste with slight variation**: near-duplicate code blocks that should be unified with a shared abstraction +4. **Leaky abstractions**: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. **Stringly-typed code**: using raw strings where constants, enums, or typed structures already exist in the codebase +6. **Unnecessary nesting**: wrapper containers or indirection that add no value +7. **Unnecessary comments**: comments explaining WHAT the code does (well-named identifiers already do that), narrating the change, or referencing the task/caller — delete; keep only non-obvious WHY (hidden constraints, subtle invariants, workarounds) + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. **Unnecessary work**: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. **Missed concurrency**: independent operations run sequentially when they could run in parallel +3. **Hot-path bloat**: new blocking work added to startup or per-request hot paths +4. **Recurring no-op updates**: state updates inside polling loops or event handlers that fire unconditionally — add a change-detection guard so downstream consumers aren't notified when nothing changed +5. **Unnecessary existence checks**: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +6. **Memory**: unbounded data structures, missing cleanup, event listener leaks +7. **Overly broad operations**: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). diff --git a/.gitignore b/.gitignore index cb952ba..dae583d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,8 @@ htmlcov/ # MCP **/.mcp.json -# App data (user-specific) -**/.wingman/ +# App data (user-specific). Repo-root .wingman/ holds the skills mirror, +# which IS tracked; user config lives in ~/.wingman/ and never enters the repo. **/.agent/ # OS diff --git a/.wingman/skills/better-interface/SKILL.md b/.wingman/skills/better-interface/SKILL.md new file mode 100644 index 0000000..96ce96c --- /dev/null +++ b/.wingman/skills/better-interface/SKILL.md @@ -0,0 +1,122 @@ +--- +name: better-interface +description: Design engineering principles for making TUI interfaces feel polished. Use when building Textual widgets, reviewing TUI code, implementing transitions, focus states, alignment, spacing, or any visual detail work. Triggers on TUI polish, design details, "make it feel better", "feels off", layout alignment, color consistency, responsive sizing. +--- + +# Details that make TUI interfaces feel better + +Great TUIs rarely come from a single thing. It's a collection of small details that compound into a professional experience. Apply these principles when building or reviewing Textual/terminal UI code. + +## Quick Reference + +| Category | When to Use | +| --- | --- | +| [Layout](layout.md) | Alignment, spacing, responsive sizing, content regions | +| [Color](color.md) | Palette consistency, contrast, semantic color usage | +| [Interaction](interaction.md) | Focus management, key hints, modal flow, feedback | +| [Typography](typography.md) | Text truncation, wrapping, Unicode, monospace alignment | + +## Core Principles + +### 1. Consistent Spacing + +Use a spacing scale (1, 2, 4 cells). Mismatched padding between related +elements is the most common thing that makes TUIs feel off. Textual's +`padding` and `margin` CSS properties use cell units. + +### 2. Optical Alignment Over Grid Alignment + +When geometric centering looks wrong in a terminal, adjust optically. +Asymmetric Unicode characters (arrows, bullets) and mixed-width content +need manual nudging. A centered title above left-aligned content often +needs 1 cell of left padding removed. + +### 3. Color Hierarchy + +Use 3-4 color tiers consistently: +- **Primary**: key actions, active focus (bright, saturated) +- **Secondary**: labels, metadata (dimmed) +- **Muted**: borders, separators (very dim) +- **Danger/Success**: semantic states only (red/green) + +Never use bright colors for passive elements. Reserve saturation for +things the user needs to notice. + +### 4. Focus is Sacred + +The focused widget must be visually distinct at a glance. Use border +color changes, not just cursor position. A user glancing at the screen +should instantly know where input will go. + +### 5. Responsive Layout + +TUI must work at 80x24 minimum. Use `fr` units and `max-width`/ +`min-width` in Textual CSS to adapt. Content that overflows should +truncate with ellipsis, never wrap into garbage. + +### 6. Feedback on Every Action + +Every keypress that does something should produce visible feedback +within one frame. If an operation takes time, show a spinner or +status message immediately — don't let the user wonder if their +input was received. + +### 7. Subtle Transitions + +Textual supports CSS transitions. Use short durations (150-300ms) +for background color and opacity changes on hover/focus. Never +animate layout properties (width, height) — terminal reflow is +not smooth. + +### 8. Border Consistency + +Pick one border style and stick with it. `tall` for primary +containers, `round` for cards/modals, `heavy` for emphasis. Mixing +`ascii`, `tall`, `round`, and `heavy` in one screen looks +incoherent. + +### 9. Key Hints + +Always show available keys in a footer or status bar. Format as +`key action` pairs separated by thin spaces. Dim the keys relative +to the actions. Update hints contextually as focus moves. + +### 10. Content Density + +Terminal space is precious. Default to dense layouts. Use blank +lines only to separate logical groups, never for decoration. +Single-line headers over multi-line. Abbreviate labels when +the full form is obvious from context. + +## Common Mistakes + +| Mistake | Fix | +| --- | --- | +| Inconsistent padding between similar widgets | Use a spacing constant | +| Bright colors on passive/decorative elements | Reserve bright for actions and focus | +| No visible focus indicator | Add border color or background change on focus | +| Content overflows and wraps badly | Set `overflow: hidden` with `text-overflow: ellipsis` | +| No key hints visible | Add contextual footer with available actions | +| Large empty regions at default size | Use `fr` units to distribute space | +| Mixed border styles in one view | Standardize on one style per element tier | +| No feedback on async operations | Show spinner/status immediately on action | + +## Review Checklist + +- [ ] Spacing is consistent between related elements +- [ ] Focused widget is visually distinct (border or background) +- [ ] Color usage follows the 3-4 tier hierarchy +- [ ] Layout works at 80x24 minimum terminal size +- [ ] Long text truncates with ellipsis, never wraps into garbage +- [ ] Key hints are visible and contextually updated +- [ ] Every user action produces visible feedback +- [ ] Border styles are consistent within each tier +- [ ] No bright colors on passive elements +- [ ] Transitions are short (150-300ms) and only on color/opacity + +## Reference Files + +- [layout.md](layout.md) — Alignment, spacing, responsive sizing +- [color.md](color.md) — Palette consistency, contrast, semantic usage +- [interaction.md](interaction.md) — Focus management, key hints, modals +- [typography.md](typography.md) — Truncation, wrapping, Unicode alignment diff --git a/.wingman/skills/better-interface/color.md b/.wingman/skills/better-interface/color.md new file mode 100644 index 0000000..57d879b --- /dev/null +++ b/.wingman/skills/better-interface/color.md @@ -0,0 +1,67 @@ +# Color + +Palette consistency, contrast, and semantic color usage in terminal UIs. + +## Color Hierarchy + +Use 3-4 tiers. Every color in the palette belongs to exactly one tier. + +| Tier | Purpose | Example (Tokyo Night) | +| --- | --- | --- | +| Primary | Key actions, active focus, links | `#7aa2f7` (blue) | +| Secondary | Labels, metadata, inactive tabs | `#565f89` (dim) | +| Muted | Borders, separators, background accents | `#3b4261` (very dim) | +| Semantic | Success, error, warning — state only | `#9ece6a`, `#f7768e`, `#e0af68` | + +### Rules + +- Never use semantic colors for decoration. Red means error. Green means success. +- Body text uses the default foreground — don't override it unless it's a label. +- Background colors should have enough contrast with text (4.5:1 minimum for + accessibility, though terminal users typically tolerate lower). + +## Consistency + +Pick colors from one palette. Mixing hex values from different themes +creates visual noise. + +```python +# Good — centralized palette +class Colors: + PRIMARY = "#7aa2f7" + DIM = "#565f89" + BORDER = "#3b4261" + ERROR = "#f7768e" + SUCCESS = "#9ece6a" + +# Bad — ad-hoc hex values scattered across widgets +label.styles.color = "#6699ff" # close to primary but not quite +border.styles.color = "#444444" # doesn't match the palette +``` + +## Dark-on-Dark Contrast + +Terminal UIs are almost always dark mode. Common mistakes: + +- Borders that are invisible against the background (too close in value) +- Dim text that disappears on some terminal themes +- Bright white text that causes eye strain + +Test with at least two terminal themes (one dark, one light-on-dark) +to catch contrast issues. + +## Focus Color + +The focused widget gets the primary color on its border or background. +Everything else stays muted. This creates an immediate visual hierarchy +without any animation. + +```tcss +Widget:focus { + border: tall $primary; +} + +Widget { + border: tall $muted; +} +``` diff --git a/.wingman/skills/better-interface/interaction.md b/.wingman/skills/better-interface/interaction.md new file mode 100644 index 0000000..a8b2165 --- /dev/null +++ b/.wingman/skills/better-interface/interaction.md @@ -0,0 +1,108 @@ +# Interaction + +Focus management, key hints, modal flow, and feedback in terminal UIs. + +## Focus Management + +Focus is the primary navigation mechanism in a TUI. It must be obvious, +predictable, and never lost. + +### Rules + +- Exactly one widget has focus at any time +- Focus moves in a logical order (top-to-bottom, left-to-right) +- After closing a modal, focus returns to the widget that opened it +- After deleting an item, focus moves to the next item (not the previous) +- Tab cycles through focusable widgets; Shift+Tab goes backwards +- Never trap focus in a non-modal context + +### Visual Indicator + +The focused widget must be immediately identifiable: + +```tcss +/* Good — clear focus state */ +Input:focus { + border: tall $accent; + background: $surface-darken-1; +} + +/* Bad — focus is only indicated by cursor position */ +Input:focus { + /* no visual change */ +} +``` + +## Key Hints + +Show available actions in a footer bar. Update contextually as the +user navigates. + +### Format + +``` +key1 action1 key2 action2 key3 action3 +``` + +- Keys are dimmed, actions are normal weight +- Separate pairs with 2+ spaces +- Show only contextually relevant keys (not every global binding) +- Put destructive actions (delete, quit without save) last + +### Example + +```python +# Good — contextual hints +def get_hints(self) -> str: + if self.mode == "list": + return "↑↓ navigate Enter select d delete q quit" + elif self.mode == "edit": + return "Ctrl+S save Esc cancel" +``` + +## Modal Flow + +Modals must: + +1. Capture all input (no key leaking to widgets behind) +2. Show a clear title and available actions +3. Be dismissible with Esc (cancel) and Enter (confirm) +4. Return focus to the opener on close +5. Dim or overlay the background to show modality + +### Anti-patterns + +- Modal that doesn't capture Tab (focus escapes to background) +- Modal with no Esc binding (user feels trapped) +- Stacked modals (modal opens another modal) — flatten the flow instead + +## Feedback + +### Immediate + +Every action needs visible confirmation within one frame: + +| Action | Feedback | +| --- | --- | +| Key press | Widget state changes visually | +| Submit form | Status message or transition | +| Delete item | Item disappears, focus moves | +| Error | Error message in status bar or inline | + +### Async Operations + +For anything that takes >100ms: + +1. Show a spinner or "Loading..." immediately +2. Update with result when done +3. Show error inline if it fails — don't silently revert + +```python +# Good — immediate feedback +self.status.update("Saving...") +await save() +self.status.update("Saved") + +# Bad — no feedback during save +await save() +``` diff --git a/.wingman/skills/better-interface/layout.md b/.wingman/skills/better-interface/layout.md new file mode 100644 index 0000000..324eb9c --- /dev/null +++ b/.wingman/skills/better-interface/layout.md @@ -0,0 +1,94 @@ +# Layout + +Alignment, spacing, and responsive sizing for terminal UIs. + +## Spacing Scale + +Use consistent cell-based spacing: 0, 1, 2, 4. Textual CSS `padding` +and `margin` are in cell units (not pixels). + +```tcss +/* Good — consistent spacing */ +#sidebar { + padding: 1 2; + margin: 0 1; +} + +#content { + padding: 1 2; +} + +/* Bad — arbitrary spacing */ +#sidebar { + padding: 1 3; + margin: 0 2; +} + +#content { + padding: 2 1; +} +``` + +## Responsive Sizing + +TUI must work at 80x24 minimum. Use fractional units and min/max +constraints. + +```tcss +/* Good — adapts to terminal width */ +#sidebar { + width: 1fr; + min-width: 20; + max-width: 40; +} + +#main { + width: 3fr; +} + +/* Bad — fixed width breaks on small terminals */ +#sidebar { + width: 30; +} +``` + +## Content Regions + +Split the screen into clear regions: header, content, footer. The +content region gets all remaining space via `1fr`. Header and footer +are fixed height. + +```tcss +Screen { + layout: vertical; +} + +#header { + height: 1; + dock: top; +} + +#footer { + height: 1; + dock: bottom; +} + +#content { + height: 1fr; +} +``` + +## Alignment + +- Left-align labels and values in forms +- Right-align numeric columns in tables +- Center titles only when the container is narrow (< 40 cols) +- Use `content-align: center middle` for empty states and loading screens + +## Overflow + +Content that doesn't fit must degrade gracefully: + +- Truncate long strings with ellipsis via `overflow: hidden` +- Scroll vertically in content regions, never horizontally +- Collapse optional columns in tables when terminal is narrow diff --git a/.wingman/skills/better-interface/typography.md b/.wingman/skills/better-interface/typography.md new file mode 100644 index 0000000..a8245cc --- /dev/null +++ b/.wingman/skills/better-interface/typography.md @@ -0,0 +1,90 @@ +# Typography + +Text truncation, wrapping, Unicode alignment, and monospace rendering in terminal UIs. + +## Truncation + +Long text must truncate with ellipsis, never wrap into a broken layout. + +```tcss +/* Good — truncate with ellipsis */ +.filename { + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} +``` + +### Where to Truncate + +| Content | Truncate? | Where | +| --- | --- | --- | +| File paths | Yes | From the left (`...rc/wingman/app.py`) | +| Chat messages | No | Let them wrap within the message area | +| Status text | Yes | From the right with ellipsis | +| Titles/headings | Yes | From the right with ellipsis | +| Table cells | Yes | From the right, keep header visible | + +## Wrapping + +Within content areas (chat messages, descriptions), wrapping is expected. +Use Textual's default wrapping behavior. But: + +- Never break mid-word for English text +- Code blocks should scroll horizontally, not wrap +- Wrap at the container boundary, not at an arbitrary column + +## Unicode and Box Drawing + +Terminal UIs use Unicode box-drawing characters for borders and trees. +Consistency matters: + +- Use one weight: light (`─ │ ┌ ┐ └ ┘`) or heavy (`━ ┃ ┏ ┓ ┗ ┛`) +- Don't mix rounded (`╭ ╮ ╰ ╯`) with square corners in the same widget +- Tree connectors: `├── └── │` — be consistent with trailing spaces + +### Width Issues + +Some Unicode characters are double-width in terminals (CJK, some emoji). +This breaks alignment in tables and fixed-width layouts. + +```python +# Good — use unicodedata to measure actual display width +import unicodedata + +def display_width(s: str) -> int: + return sum(2 if unicodedata.east_asian_width(c) in ('W', 'F') else 1 for c in s) + +# Bad — using len() for alignment +pad = " " * (20 - len(text)) # wrong if text contains wide chars +``` + +## Monospace Alignment + +Terminal text is monospace, which makes alignment easy — if you respect it: + +- Align columns with spaces, never tabs (tab width varies by terminal) +- Right-align numbers in columns +- Pad shorter strings to column width + +```python +# Good — aligned columns +f"{'Name':<20} {'Size':>8} {'Modified':<12}" +f"{'app.py':<20} {'1.2 KB':>8} {'2024-01-15':<12}" + +# Bad — no alignment +f"Name: {name} Size: {size} Modified: {modified}" +``` + +## Emphasis + +In a terminal, you have limited tools for emphasis: + +| Technique | Use for | Textual markup | +| --- | --- | --- | +| Bold | Section titles, key values | `[bold]text[/bold]` | +| Dim | Metadata, secondary info | `[dim]text[/dim]` | +| Color | Semantic meaning (see color.md) | `[#7aa2f7]text[/]` | +| Reverse | Selected items in lists | `[reverse]text[/reverse]` | + +Never combine more than two: bold + color is fine, bold + dim + italic + color is noise. diff --git a/.wingman/skills/commit/SKILL.md b/.wingman/skills/commit/SKILL.md new file mode 100644 index 0000000..9a02117 --- /dev/null +++ b/.wingman/skills/commit/SKILL.md @@ -0,0 +1,28 @@ +--- +name: commit +description: Create a git commit with conventional commit format. Injects current git state automatically. +allowed-tools: Bash(git add *), Bash(git status *), Bash(git commit *), Bash(git diff *) +--- + +## Context + +- Status: !`git status -sb` +- Diff: !`git diff HEAD --stat` +- Branch: !`git branch --show-current` +- Recent commits: !`git log --oneline -10` + +## Rules + +Commits are terse one-liner conventional commits: `type(scope): description`. + +- type: `feat`, `fix`, `refactor`, `docs`, `test`, `chore` +- scope: affected area (module, package, or feature) +- description: imperative mood, lowercase, no period, under 72 characters +- Never use "and" in a commit message. Two changes = two commits. + +Stage files by name (never `git add -A` or `git add .`). Group changes by intent. + +## Task + +Based on the context above, stage the relevant files and create the commit. +Do not use any other tools. Do not send any text besides the tool calls. diff --git a/.wingman/skills/greentext/SKILL.md b/.wingman/skills/greentext/SKILL.md new file mode 100644 index 0000000..d3984e8 --- /dev/null +++ b/.wingman/skills/greentext/SKILL.md @@ -0,0 +1,95 @@ +--- +name: greentext +description: Explain system behavior, request flows, architecture, or implementation logic as short 4chan-style greentext lines. Use when the user asks for a step-by-step explanation, says "explain the logic", asks for "greentext", or wants terse sequential reasoning with each line starting with `>`. +allowed-tools: Read, Grep, Glob +--- + +# Greentext + +Write explanations as short sequential `>` lines. + +## Contract + +- Every content line starts with `> ` +- No paragraphs +- No nested bullets +- No markdown headers unless the user explicitly asks for structure +- Keep each line to one event, cause, or invariant +- Prefer concrete nouns over abstractions +- Explain order: `step 1 -> step 2 -> step 3` +- If there is a key safety check, give it its own line +- If there is a failure mode, give it its own line +- Write like the bottomless pit supervisor. Deadpan delivery. + The comedy comes from describing technical reality so precisely + that the absurdity reveals itself. Never force a joke. +- One running motif per greentext is fine. Don't stretch it. +- Keep it short. If a greentext is longer than ~15 lines it's + not a greentext, it's a blog post with `>` signs. +- The punchline is its own line. Set up with straight facts. +- Still 100% technically accurate. Every line must be true. + If someone reads it as documentation they learn something. + +## Use this style for + +- request flow explanations +- lifecycle walkthroughs +- architecture boundaries +- state machine transitions +- "what happens if..." explanations +- "why won't this overwrite user X's data?" explanations + +## Do not use this style for + +- code blocks +- long essays +- formal docs +- user-facing product copy + +## Few-shot examples + +### Example 1: machine isolation + +```text +> be guest-agent +> wake up inside a VM with no idea who i am +> host says "you are dm-123" +> check bootstrap files on virtio-fs mount +> they also say dm-123 +> ok we agree. write hostname and ssh keys. +> delete the bootstrap files +> mount /home/machine over where they were +> tenant never sees the 5ms where their home dir was identity paperwork +``` + +### Example 2: delete lifecycle + +```text +> user sends DELETE /v1/machines/dm-xxx +> controlplane writes desiredState=destroyed to etcd +> tries to bill for storage overage on the way out +> stripe says that price doesn't exist in dev +> returns 500 +> the CRD mutation already committed +> machine was doomed the moment etcd accepted the write +> stripe's opinion was never required +``` + +### Example 3: fd leak + +```text +> be VhostUserDaemon +> spawn epoll worker threads at construction time +> nobody tells them to stop when i get dropped +> 50 create/destroy cycles later +> 1001 orphaned eventfds +> EMFILE +> can't create machines because the fd table is full of ghosts +``` + +## Response template + +```text +> step 1 +> step 2 +> step 3 +``` diff --git a/.wingman/skills/issue/SKILL.md b/.wingman/skills/issue/SKILL.md new file mode 100644 index 0000000..9751afc --- /dev/null +++ b/.wingman/skills/issue/SKILL.md @@ -0,0 +1,49 @@ +--- +name: issue +description: Create a GitHub issue using the wingman issue templates (bug, feature). Injects branch and commit context automatically. +allowed-tools: Bash(git *), Bash(gh *), Read +argument-hint: " " +--- + +## Context (auto-detected from HEAD) + +- Current branch: !`git branch --show-current` +- Last commit: !`git log -1 --oneline` +- Remote: !`git remote get-url origin` + +## Template Selection + +Parse the first positional arg from `$ARGUMENTS` as the issue type: + +| Type | Template file | Labels | +|---------|---------------------------------------------|---------------------| +| bug | `.github/ISSUE_TEMPLATE/bug_report.yml` | `bug,triage` | +| feature | `.github/ISSUE_TEMPLATE/feature_request.yml`| `enhancement` | + +The `bug_report.yml` file is a GitHub issue form. `gh issue create --body` submits plain +markdown, so translate the yaml form fields into a markdown body with the same section +headings (Bug Description, Steps to Reproduce, Expected Behavior, Environment, +Additional Context). + +For `feature_request.yml`, read the file and translate form fields the same way. + +## Rules + +- Issue title: short, imperative, no period, under 80 chars. No conventional-commit prefix + (that's for PRs, not issues). +- The remaining positional args from `$ARGUMENTS` (after the type) form the title. +- Use `gh issue create --title "..." --body "$(cat <<'EOF' ... EOF)" --label "..."`. +- Labels come from the table above. Do not invent new labels. + +## Task + +1. Parse `$ARGUMENTS`: + - First token: issue type (`bug` or `feature`). If missing or invalid, + ask the user which template to use. + - Remaining tokens: title. +2. Read the matching template file from `.github/ISSUE_TEMPLATE/`. +3. Fill every section. Leave no placeholder text. If a section truly has no content, write + "N/A" rather than deleting the section. +4. Auto-inject the branch and last-commit context into "Additional Context". +5. Run `gh issue create` with the right labels. +6. Return the issue URL. diff --git a/.wingman/skills/pr-review/SKILL.md b/.wingman/skills/pr-review/SKILL.md new file mode 100644 index 0000000..ccba072 --- /dev/null +++ b/.wingman/skills/pr-review/SKILL.md @@ -0,0 +1,75 @@ +--- +name: pr-review +description: Review PR comments from GitHub. Fetches inline review comments and issue comments, classifies by blocking vs non-blocking, and summarizes actionable items. +allowed-tools: Bash(gh *), Read, Grep, Glob, Agent +argument-hint: "<pr-number> [--repo owner/repo]" +--- + +# Review PR Comments + +## Context + +- Branch: !`git branch --show-current` +- Remote: !`gh repo view --json nameWithOwner -q .nameWithOwner` + +## Task + +Given PR number `$ARGUMENTS` (required), fetch and triage all reviewer comments. + +### 1. Fetch comments + +Run these two `gh api` calls to get both comment types: + +```bash +# Inline review comments (attached to specific lines) +gh api repos/{owner}/{repo}/pulls/{pr}/comments --paginate + +# Issue-level comments (general discussion) +gh api repos/{owner}/{repo}/issues/{pr}/comments --paginate +``` + +If `--repo` is provided in arguments, use that. Otherwise infer from the git remote. + +Filter out bot comments (author login ending in `[bot]` or `bot`). + +### 2. Parse and classify + +For each human comment, extract: + +- **Author**: `.user.login` +- **Type**: `review` (inline) or `discussion` (issue-level) +- **File + line**: `.path` and `.line` (review comments only) +- **Body**: `.body` +- **Blocking**: true if body contains `blocking:`, `must-fix:`, `bug:`, `security:`, or `tests:` prefix +- **Created**: `.created_at` + +### 3. Summarize + +Output a table grouped by blocking status: + +``` +## Blocking (must resolve before merge) + +| # | Author | File:Line | Comment | +|---|--------|-----------|---------| +| 1 | user | main.py:42 | concern about race condition | + +## Non-blocking (address or acknowledge) + +| # | Author | File:Line | Comment | +|---|--------|-----------|---------| +| 1 | user | utils.py:10 | nit: rename variable | +``` + +Comments without a blocking prefix are classified as non-blocking. + +### 4. Suggest next steps + +For each blocking comment, suggest the minimal fix. If the comment is a question, +draft a reply. If it requires a code change, identify the file and describe what to change. + +## Anti-patterns + +- Do not fetch the full PR diff. The comments already reference specific files and lines. +- Do not reply to or resolve comments automatically. Surface the information; let the user decide. +- Do not ignore bot comments silently. Filter them but mention the count. diff --git a/.wingman/skills/pr/SKILL.md b/.wingman/skills/pr/SKILL.md new file mode 100644 index 0000000..21c862e --- /dev/null +++ b/.wingman/skills/pr/SKILL.md @@ -0,0 +1,44 @@ +--- +name: pr +description: Create a pull request using the wingman PR template. Injects git state, commit history, and diff stats automatically. +allowed-tools: Bash(git *), Bash(gh *), Bash(awk *), Bash(sort *), Read +argument-hint: "[base-branch] [--branch <source-branch>]" +--- + +## Context (auto-detected from HEAD) + +- Current branch: !`git branch --show-current` +- Remote status: !`git status -sb | head -1` + +## PR Template + +Read `.github/PULL_REQUEST_TEMPLATE.md` and fill every section. The template is mandatory. +Do not free-form the PR body. + +## Rules + +- PR title: `type(scope): description` (conventional commit format, under 70 chars) +- Target branch: first positional arg from `$ARGUMENTS` if provided, otherwise `main` +- Source branch: if `--branch <name>` is in `$ARGUMENTS`, use that branch for diff/log + commands instead of HEAD. This is needed when the PR branch lives in a different worktree + or the main repo checkout. +- Push with `-u` if needed +- Use `gh pr create` with `--body "$(cat <<'EOF' ... EOF)"` for correct formatting +- **Hard limit: 500 changed LOC** (excluding generated files and lock files). + If the diff exceeds 500 lines, do not create the PR. Instead, propose a + stacked-PR plan: split changes into logical, independently reviewable PRs + where each PR is under 500 LOC. Each stacked PR targets the previous one's + branch (not main) until the final one merges the stack into main. +- Warn at 200 changed lines — suggest splitting proactively + +## Task + +1. Determine the source branch: use `--branch` value if provided, otherwise current HEAD +2. Gather context by running: + - `git log --oneline origin/main..<source-branch>` (commits for this PR) + - `git diff --stat origin/main..<source-branch>` (diff stats) + - `git diff --name-only origin/main..<source-branch>` (changed files) +3. Push the branch if needed +4. Read the PR template +5. Create the PR with every template section filled +6. Return the PR URL diff --git a/.wingman/skills/promote/SKILL.md b/.wingman/skills/promote/SKILL.md new file mode 100644 index 0000000..6d1aee2 --- /dev/null +++ b/.wingman/skills/promote/SKILL.md @@ -0,0 +1,47 @@ +--- +name: promote +description: Create a changelog-style promotion PR between two branches. Use for release promotions or branch-to-branch merges with a structured changelog. +allowed-tools: Bash(git *), Bash(gh *), Read +argument-hint: "[--from <branch>] [--to <branch>] [--dry-run]" +--- + +## Context (auto-detected) + +- Commits to promote: !`git log --oneline origin/main..HEAD 2>/dev/null || echo "(no commits ahead of main)"` + +## Rules + +- Default: promote current branch → `main` +- `--from <branch>` overrides source (default: current branch) +- `--to <branch>` overrides target (default: `main`) +- `--dry-run` prints the PR body without creating it +- Commits are grouped by conventional commit type into changelog sections + +## Changelog Sections + +| Prefix | Section | +|--------|---------| +| `feat` | Features | +| `fix` | Bug Fixes | +| `refactor` | Refactors | +| `perf` | Performance | +| `test` | Tests | +| `docs` | Documentation | +| `chore`, `ci` | Chores | + +Unrecognized prefixes go under Chores. + +## Task + +1. Parse `$ARGUMENTS` for `--from`, `--to`, `--dry-run` +2. Fetch the latest from origin for both branches +3. Get the commit list: `git log --format="%H %s" origin/<to>..origin/<from>` +4. If no commits, report that target is up to date and stop +5. Build the changelog body: + - Group commits by section using the table above + - Each bullet: `* **scope:** description ([short-hash](commit-url))` or `* description ([short-hash](commit-url))` if unscoped + - Include a "Full Changelog" comparison link + - Order sections: Features, Bug Fixes, Refactors, Performance, Tests, Documentation, Chores +6. If `--dry-run`, print the body and stop +7. Create the PR via `gh pr create --base <to> --head <from> --title "chore(release): promote <from> → <to>" --body "..."` +8. Return the PR URL diff --git a/.wingman/skills/simplify/SKILL.md b/.wingman/skills/simplify/SKILL.md new file mode 100644 index 0000000..ce5e951 --- /dev/null +++ b/.wingman/skills/simplify/SKILL.md @@ -0,0 +1,55 @@ +--- +name: simplify +description: Review changed code for reuse, quality, and efficiency, then fix any issues found. +allowed-tools: Read, Grep, Glob, Bash, Edit +--- + +# Simplify: Code Review and Cleanup + +Review all changed files for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run `git diff` (or `git diff HEAD` if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. **Search for existing utilities and helpers** that could replace newly written code. Look for similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. **Flag any new function that duplicates existing functionality.** Suggest the existing function to use instead. +3. **Flag any inline logic that could use an existing utility** — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. **Redundant state**: state that duplicates existing state, cached values that could be derived +2. **Parameter sprawl**: adding new parameters to a function instead of generalizing or restructuring existing ones +3. **Copy-paste with slight variation**: near-duplicate code blocks that should be unified with a shared abstraction +4. **Leaky abstractions**: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. **Stringly-typed code**: using raw strings where constants, enums, or typed structures already exist in the codebase +6. **Unnecessary nesting**: wrapper containers or indirection that add no value +7. **Unnecessary comments**: comments explaining WHAT the code does (well-named identifiers already do that), narrating the change, or referencing the task/caller — delete; keep only non-obvious WHY (hidden constraints, subtle invariants, workarounds) + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. **Unnecessary work**: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. **Missed concurrency**: independent operations run sequentially when they could run in parallel +3. **Hot-path bloat**: new blocking work added to startup or per-request hot paths +4. **Recurring no-op updates**: state updates inside polling loops or event handlers that fire unconditionally — add a change-detection guard so downstream consumers aren't notified when nothing changed +5. **Unnecessary existence checks**: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +6. **Memory**: unbounded data structures, missing cleanup, event listener leaks +7. **Overly broad operations**: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). diff --git a/src/wingman/app.py b/src/wingman/app.py index 0b51e99..fd0c109 100644 --- a/src/wingman/app.py +++ b/src/wingman/app.py @@ -34,8 +34,8 @@ from .memory import load_memory from .panels import PanelMixin from .sessions import load_sessions +from .skills import SkillManager from .streaming import StreamingController -from .tool_bridge import ToolBridgeMixin from .tools import ( check_completed_processes, get_background_processes, @@ -43,6 +43,7 @@ request_background, set_app_instance, ) +from .tools_ui import ToolsUIMixin from .ui import ( APIKeyScreen, ChatPanel, @@ -55,7 +56,7 @@ ) -class WingmanApp(PanelMixin, ToolBridgeMixin, App): +class WingmanApp(PanelMixin, ToolsUIMixin, App): """Wingman - Your copilot for the terminal""" TITLE = "Wingman" @@ -89,6 +90,7 @@ def __init__(self): self.cmds = Commands(self) self.streaming = StreamingController(self) self.compaction = CompactionController(self) + self.skills = SkillManager(self) self.events = EventHandler(self) self.forking = ForkController(self) self.scroll_sensitivity_y = 0.6 @@ -140,6 +142,7 @@ def on_mount(self) -> None: self._init_client(api_key) else: self.push_screen(APIKeyScreen(), self.on_api_key_entered) + self.skills.load() # Fetch marketplace servers in background self._init_dynamic_data() # Monitor background processes for completion diff --git a/src/wingman/commands.py b/src/wingman/commands.py index a9c00b3..da163fa 100644 --- a/src/wingman/commands.py +++ b/src/wingman/commands.py @@ -74,16 +74,79 @@ def dispatch(self, cmd: str) -> None: "import": lambda: self.import_file(arg), "fork": lambda: self.app.forking.fork(arg), "forks": lambda: self.app.forking.forks(arg), + "skills": lambda: self.skills_list(), } handler = handlers.get(command) if handler: handler() + elif self.app.skills.get(command): + self.invoke_skill(command, arg) else: self.app.show_info(f"Unknown command: {command}") # --- Simple commands --- + def invoke_skill(self, name: str, args: str) -> None: + """Expand a skill and send it to the model as a one-shot prompt. + + The expanded skill content is injected as a user message, + streamed to the model, and the model's response persists in + the conversation. The skill prompt itself is transient. + + Args: + name: Skill name. + args: User-provided arguments. + + """ + import time + + from .sessions import save_session + + prompt = self.app.skills.invoke(name, args) + if not prompt: + return + + panel = self.app.active_panel + if not panel: + return + + if panel._generating: + self.app.notify("Wait for response to complete", severity="warning", timeout=2) + return + + # Remove welcome message if present + try: + for child in panel.get_chat_container().children: + if "panel-welcome" in child.classes: + child.remove() + break + except Exception: + pass + + # Ensure session exists + if not panel.session_id: + panel.session_id = f"chat-{int(time.time() * 1000)}" + save_session(panel.session_id, []) + self.app.refresh_sessions() + self.app.update_status() + + # Show the skill invocation in chat (not the full prompt) + display = f"/{name}" + (f" {args}" if args else "") + panel.add_message("user", display) + + # Inject expanded prompt as a transient message for the model + panel.messages.append({"role": "user", "content": prompt, "_skill": True}) + + from .ui import Thinking + + chat = panel.get_chat_container() + thinking = Thinking(id="thinking") + chat.mount(thinking) + panel.get_scroll_container().scroll_end(animate=False) + + self.app.streaming.send_message(panel, prompt, thinking) + def ps(self) -> None: """List background processes.""" panel = self.app.active_panel @@ -107,6 +170,18 @@ def feature(self) -> None: """Open feature request.""" self.app.open_github_issue("feature_request.yml") + def skills_list(self) -> None: + """List available skills.""" + skills = self.app.skills.list_skills() + if not skills: + self.app.show_info("[dim]No skills found. Add skills to .agents/skills/[/]") + return + lines = ["[bold #7aa2f7]Skills[/] (use /skill-name to invoke)\n"] + for skill in skills: + hint = f" [dim]{skill.argument_hint}[/]" if skill.argument_hint else "" + lines.append(f" [#7aa2f7]/{skill.name}[/]{hint} [dim]{skill.description[:60]}[/]") + self.app.show_info("\n".join(lines)) + def ls(self, arg: str) -> None: """List files in working directory.""" panel = self.app.active_panel @@ -428,7 +503,7 @@ def import_file(self, arg: str) -> None: if msg["role"] in ("user", "assistant") and msg.get("content"): content = msg["content"] if isinstance(content, list): - content = " ".join(p.get("text", "") for p in content if isinstance(p, dict)) + content = " ".join(part.get("text", "") for part in content if isinstance(part, dict)) panel.messages.append({"role": msg["role"], "content": content}) count += 1 self.app.update_status() diff --git a/src/wingman/config.py b/src/wingman/config.py index 21598f3..85f425b 100644 --- a/src/wingman/config.py +++ b/src/wingman/config.py @@ -114,6 +114,7 @@ ("/clear", "Clear chat"), ("/help", "Show help"), ("/exit", "Quit Wingman"), + ("/skills", "List available skills"), ("/bug", "Report a bug"), ("/feature", "Request feature"), ] diff --git a/src/wingman/context.py b/src/wingman/context.py index ebb4a05..9a14c43 100644 --- a/src/wingman/context.py +++ b/src/wingman/context.py @@ -163,7 +163,7 @@ async def compact(self, client: AsyncDedalus) -> str: target_tokens = int(self.context_limit * COMPACT_TARGET) keep_recent = 4 - recent_tokens = sum(estimate_message_tokens(m) for m in self.messages[-keep_recent:]) + recent_tokens = sum(estimate_message_tokens(msg) for msg in self.messages[-keep_recent:]) while keep_recent < len(self.messages) - 2: next_msg = self.messages[-(keep_recent + 1)] diff --git a/src/wingman/events.py b/src/wingman/events.py index db0884b..c337547 100644 --- a/src/wingman/events.py +++ b/src/wingman/events.py @@ -242,9 +242,9 @@ def on_submit(self, event: Input.Submitted) -> None: from .ui import Thinking panel = None - for p in self.app.panels: - if p.panel_id in event.input.id: - panel = p + for candidate in self.app.panels: + if candidate.panel_id in event.input.id: + panel = candidate break if not panel: return diff --git a/src/wingman/panels.py b/src/wingman/panels.py index 100fa04..c10e526 100644 --- a/src/wingman/panels.py +++ b/src/wingman/panels.py @@ -73,10 +73,10 @@ def _refresh_welcome_art(self) -> None: def do_refresh(): force_compact = len(self.panels) > 1 - for p in self.panels: + for panel in self.panels: try: - p.query_one(".panel-welcome") - p._show_welcome(force_compact=force_compact) + panel.query_one(".panel-welcome") + panel._show_welcome(force_compact=force_compact) except Exception: pass diff --git a/src/wingman/skills.py b/src/wingman/skills.py new file mode 100644 index 0000000..dedd2fa --- /dev/null +++ b/src/wingman/skills.py @@ -0,0 +1,298 @@ +"""Skill loading and execution for wingman. + +Loads SKILL.md files from ``.agents/skills/`` (source of truth) and +``.wingman/skills/`` (fallback), parses frontmatter metadata, and +expands skill prompts with argument substitution and shell execution. + +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml + +if TYPE_CHECKING: + from .app import WingmanApp + +SKILL_DIRS = [".agents/skills", ".wingman/skills"] +SKILL_FILE = "SKILL.md" +FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) +SHELL_INLINE_RE = re.compile(r"!\`([^`]+)\`") + + +# fmt: off +@dataclass(frozen=True, slots=True) +class Skill: + """Parsed skill definition from a SKILL.md file. + + """ + + name: str + description: str + content: str + path: Path + allowed_tools: list[str] = field(default_factory=list) + argument_hint: str | None = None + user_invocable: bool = True + disable_model_invoke: bool = False +# fmt: on + + +def parse_frontmatter(text: str) -> tuple[dict, str]: + """Split SKILL.md into frontmatter dict and markdown body. + + Args: + text: Raw file contents. + + Returns: + (frontmatter_dict, markdown_body) tuple. + + """ + match = FRONTMATTER_RE.match(text) + if not match: + return {}, text + raw_yaml = match.group(1) + body = text[match.end() :] + try: + meta = yaml.safe_load(raw_yaml) or {} + except yaml.YAMLError: + meta = {} + return meta, body + + +def parse_allowed_tools(value: str | list | None) -> list[str]: + """Normalize allowed-tools to a list of strings. + + Args: + value: Frontmatter value — string (comma-separated) or list. + + Returns: + List of tool name strings. + + """ + if not value: + return [] + if isinstance(value, list): + return [str(t).strip() for t in value] + return [t.strip() for t in str(value).split(",")] + + +def load_skill(skill_dir: Path) -> Skill | None: + """Load a single skill from a directory containing SKILL.md. + + Args: + skill_dir: Path to the skill directory. + + Returns: + Parsed Skill or None if SKILL.md is missing/invalid. + + """ + skill_file = skill_dir / SKILL_FILE + if not skill_file.is_file(): + return None + + text = skill_file.read_text(encoding="utf-8") + meta, body = parse_frontmatter(text) + + name = meta.get("name", skill_dir.name) + description = meta.get("description", "") + if isinstance(description, str): + description = " ".join(description.split()) + + return Skill( + name=name, + description=description, + content=body, + path=skill_dir, + allowed_tools=parse_allowed_tools(meta.get("allowed-tools")), + argument_hint=meta.get("argument-hint"), + user_invocable=meta.get("user-invocable", True) is not False, + disable_model_invoke=meta.get("disable-model-invocation", False) is True, + ) + + +def discover_skills(cwd: Path) -> dict[str, Skill]: + """Discover all skills from standard directories. + + Searches ``.agents/skills/`` first (source of truth), then + ``.wingman/skills/`` as fallback. First occurrence of a skill + name wins. + + Args: + cwd: Working directory to resolve relative skill paths from. + + Returns: + Dict mapping skill name to Skill. + + """ + skills: dict[str, Skill] = {} + for rel_dir in SKILL_DIRS: + base = cwd / rel_dir + if not base.is_dir(): + continue + for entry in sorted(base.iterdir()): + if not entry.is_dir(): + continue + skill = load_skill(entry) + if skill and skill.name not in skills: + skills[skill.name] = skill + return skills + + +def substitute_arguments(content: str, args: str) -> str: + """Replace ``$ARGUMENTS`` and positional ``$0``, ``$1`` placeholders. + + Args: + content: Skill markdown body. + args: Raw argument string from the user. + + Returns: + Content with placeholders replaced. + + """ + if not args: + return content + + result = content.replace("$ARGUMENTS", args) + + parts = args.split() + for i, part in enumerate(parts): + result = result.replace(f"${i}", part) + + if "$ARGUMENTS" not in content and "$0" not in content: + result += f"\n\n## Arguments\n\n{args}" + + return result + + +def execute_shell_commands(content: str) -> str: + """Execute inline ``!`command``` blocks and replace with output. + + Args: + content: Skill markdown with shell commands. + + Returns: + Content with command outputs inlined. + + """ + + def run_command(match: re.Match) -> str: + cmd = match.group(1) + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + output = result.stdout.strip() or result.stderr.strip() + return output or "(no output)" + except subprocess.TimeoutExpired: + return "(command timed out)" + except Exception as e: + return f"(error: {e})" + + return SHELL_INLINE_RE.sub(run_command, content) + + +def expand_skill(skill: Skill, args: str = "") -> str: + """Build the full prompt for a skill invocation. + + Reads SKILL.md, substitutes arguments, executes shell commands, + and prepends the skill directory path for file references. + + Args: + skill: Parsed skill definition. + args: User-provided arguments. + + Returns: + Fully expanded prompt string. + + """ + content = skill.content + content = substitute_arguments(content, args) + content = execute_shell_commands(content) + + header = f"Skill: {skill.name}" + if skill.path.is_dir(): + header += f"\nBase directory: {skill.path}" + + return f"{header}\n\n{content}" + + +class SkillManager: + """Loads, caches, and executes skills. + + Attached to the app as ``self.skills``. + + """ + + def __init__(self, app: WingmanApp) -> None: + self.app = app + self.registry: dict[str, Skill] = {} + + def load(self, cwd: Path | None = None) -> None: + """Load skills from the working directory. + + Args: + cwd: Directory to search for skills. Defaults to app's + active panel working dir or process cwd. + + """ + if cwd is None: + panel = self.app.active_panel + cwd = panel.working_dir if panel else Path.cwd() + self.registry = discover_skills(cwd) + + def list_skills(self) -> list[Skill]: + """Return all loaded user-invocable skills. + + Returns: + List of skills where user_invocable is True. + + """ + return [skill for skill in self.registry.values() if skill.user_invocable] + + def get(self, name: str) -> Skill | None: + """Look up a skill by name. + + Args: + name: Skill name (e.g., "commit", "pr"). + + Returns: + Skill or None. + + """ + return self.registry.get(name) + + def invoke(self, name: str, args: str = "") -> str | None: + """Expand a skill and return its prompt. + + Args: + name: Skill name. + args: User arguments. + + Returns: + Expanded prompt string, or None if skill not found. + + """ + skill = self.get(name) + if not skill: + return None + return expand_skill(skill, args) + + def get_command_names(self) -> list[str]: + """Return skill names for slash-command registration. + + Returns: + List of skill names that are user-invocable. + + """ + return [skill.name for skill in self.list_skills()] diff --git a/src/wingman/streaming.py b/src/wingman/streaming.py index e82ab12..8f888bd 100644 --- a/src/wingman/streaming.py +++ b/src/wingman/streaming.py @@ -109,6 +109,9 @@ async def send_message( with contextlib.suppress(Exception): thinking.remove() + # Remove transient skill prompts before saving + panel.messages = [msg for msg in panel.messages if not msg.get("_skill")] + segments = get_segments(panel.panel_id) if segments: panel.messages.append({"role": "assistant", "segments": segments}) @@ -168,7 +171,8 @@ def build_messages( content_parts.append(f"\n[Tool: {cmd}]\n{output}\n") messages.append({"role": msg["role"], "content": "".join(content_parts)}) else: - messages.append(msg.copy()) + clean = {key: val for key, val in msg.items() if not key.startswith("_")} + messages.append(clean) if images and messages and messages[-1].get("role") == "user": messages[-1] = create_image_message_from_cache(text, images) @@ -180,7 +184,7 @@ def build_messages( system_content += f"\n\n{instructions}" memory = load_memory() if memory.entries: - memory_text = "\n".join(e.content for e in memory.entries) + memory_text = "\n".join(entry.content for entry in memory.entries) system_content += f"\n\n## Project Memory\n{memory_text}" messages = [{"role": "system", "content": system_content}] + messages @@ -309,6 +313,8 @@ def handle_error( for sw in self.app.query("StreamingText"): with contextlib.suppress(Exception): sw.remove() + # Clean up transient skill prompts + panel.messages = [msg for msg in panel.messages if not msg.get("_skill")] segments = get_segments(panel.panel_id) if segments: panel.messages.append({"role": "assistant", "segments": segments}) diff --git a/src/wingman/tool_bridge.py b/src/wingman/tools_ui.py similarity index 68% rename from src/wingman/tool_bridge.py rename to src/wingman/tools_ui.py index 0612788..cda79cc 100644 --- a/src/wingman/tool_bridge.py +++ b/src/wingman/tools_ui.py @@ -1,33 +1,37 @@ -"""Tool-to-UI bridge mixin for wingman. +"""UI callbacks for the tool execution layer. -Thread-safe methods that the tool execution layer (tools.py) calls -to mount widgets, update command status, and show diff modals. +Methods that tools.py calls to show command status widgets, +update the thinking spinner, and display diff modals. """ from __future__ import annotations -class ToolBridgeMixin: - """Bridge between tool execution and TUI widgets. +class ToolsUIMixin: + """UI methods called by tools.py via the app instance reference.""" - These methods are called from tools.py via the app instance - reference. They handle thread-safe widget mounting and updates. + def resolve_panel(self, panel_id: str | None = None): + """Find a panel by ID, falling back to the active panel. - """ + Args: + panel_id: Panel identifier, or None for active panel. + + Returns: + Panel instance or None. + + """ + if panel_id: + for panel in self.panels: + if panel.panel_id == panel_id: + return panel + return self.active_panel def _mount_command_status(self, command: str, widget_id: str, panel_id: str | None = None) -> None: """Mount a command status widget in the chat.""" from .ui import CommandStatus - panel = None - if panel_id: - for p in self.panels: - if p.panel_id == panel_id: - panel = p - break - if not panel: - panel = self.active_panel + panel = self.resolve_panel(panel_id) if not panel: return chat = panel.get_chat_container() @@ -55,14 +59,7 @@ def _update_thinking_status(self, status: str | None, panel_id: str | None = Non """Update the thinking spinner status text.""" from .ui import Thinking - panel = None - if panel_id: - for p in self.panels: - if p.panel_id == panel_id: - panel = p - break - if not panel: - panel = self.active_panel + panel = self.resolve_panel(panel_id) if not panel: return try: