diff --git a/docs-ai/065-bundled-agent-skills/000-plan.md b/docs-ai/065-bundled-agent-skills/000-plan.md index 21aedb62..ae6ee5b2 100644 --- a/docs-ai/065-bundled-agent-skills/000-plan.md +++ b/docs-ai/065-bundled-agent-skills/000-plan.md @@ -214,6 +214,11 @@ Decisions below were taken in the 2026-08-22 plan review (#712): ## Amendments +- Updated 2026-08-28 (K3 review follow-up): `BundledSkill` gained an optional `summary` + (`metadata.prowl-summary`) so Settings can show human-facing text while the agent-facing + `description` keeps its trigger phrasing; the Settings target chips became an aligned + full-width table showing each link's folder — see + [005-k3-settings-agent-skills.md](005-k3-settings-agent-skills.md). - Updated 2026-08-28: Implemented and verified K3: the Command Line Tool page gained the Agent Skills section over a `SkillInstallClient` dependency and an `AgentSkillsFeature` child; every action recomputes all chips so aliased targets stay consistent; success is a toast, failure an diff --git a/docs-ai/065-bundled-agent-skills/001-action.md b/docs-ai/065-bundled-agent-skills/001-action.md index 50019e65..26a4abe7 100644 --- a/docs-ai/065-bundled-agent-skills/001-action.md +++ b/docs-ai/065-bundled-agent-skills/001-action.md @@ -19,7 +19,8 @@ `metadata.prowl-install`. - **Registry.** `ProwlSkills` (`supacode/CLIService/Shared/ProwlSkills.swift`, part of `ProwlCLIShared` and compiled into the app) parses `name`, plain or `>-` `description`, and the - strictly nested `metadata.prowl-install` audience; `bundled(resourcesURL:)` serves the app, + strictly nested `metadata.prowl-install` audience and optional `metadata.prowl-summary` + display text; `bundled(resourcesURL:)` serves the app, `bundledForCLI(executableURL:environment:)` resolves the CLI's own bundle or `PROWL_SKILLS_DIR`, and `skill(id:)` is the locator 063 uses. - **Installer.** `SymlinkInstaller` (`SymlinkInstaller.swift`) owns one link slot with the diff --git a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md index 463e7a26..1f5e1b87 100644 --- a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md +++ b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md @@ -30,21 +30,27 @@ shared installer so both surfaces always report the same status. warning toast; a success delegates a success toast (`AppFeature` maps `agentSkills(.delegate(.linkChanged))` to `repositories.showToast`). - `AgentSkillsSectionView` renders under Installation and Connection: one row per skill (name, - id when it differs, description limited to three lines with the full text as a tooltip, - Reveal), and one chip per detected target with the status wording of `prowl skills list` - (Installed / Not installed / Linked elsewhere `→ destination` / Real file or directory / Broken - link `→ destination`) and one action button (Remove / Install / Replace / — / Repair). A real - file or directory shows only "Prowl never deletes it; remove it manually to link here." Empty - states: the bundle could not be read (message from `ProwlSkillsError`), no installable skills, - and no detected target (points at `prowl skills install --target claude|codex|agents`). Every - button carries a tooltip; chips show their link path on hover. + id when it differs, the skill's `metadata.prowl-summary` — falling back to the agent-facing + `description` — and Reveal), then one full-width line per detected target laid out as a + `Grid` so the four columns align: status icon + target name, the link folder + (`~`-abbreviated, monospaced, middle-truncated; a second line carries `→ destination` for a + foreign or dangling link, or the "not a symlink" explanation), the status wording of + `prowl skills list` (Installed / Not installed / Linked elsewhere / Real file or directory / + Broken link), and one action button (Remove / Install / Replace / — / Repair). Empty states: + the bundle could not be read (message from `ProwlSkillsError`), no installable skills, and no + detected target (points at `prowl skills install --target claude|codex|agents`). Every button + carries a tooltip; the folder cell's tooltip holds the full paths. +- `ProwlSkills` gained the optional `metadata.prowl-summary` field (`BundledSkill.summary`): + a skill's `description` is agent-facing trigger text (`prowl-cli`'s runs to a paragraph of + colloquial phrasings), so Settings shows the summary instead. Absent → nil; empty or duplicated + → `INVALID_SKILL_FRONTMATTER`. The CLI payload and contract are unchanged. - Docs: `docs/components/settings.md` gains an Agent Skills section (statuses, actions, aliased targets, empty states) and `docs/components/cli.md` notes that Settings offers the same user-scope actions with the same status as `prowl skills list`. ## Decisions and boundaries -- Success feedback is a toast plus the chip's own state change; only failures raise a modal +- Success feedback is a toast plus the line's own state change; only failures raise a modal alert. The CLI-install row alerts on success as well, but a per-link modal for up to three chips per skill would be noise, and the toast path is the same `AppFeature` mechanism. - Detection and status come from the client's `SkillTargetStatus`, so the reducer never reads @@ -77,6 +83,13 @@ and creating a replacement child) and found no P0/P1/P2; its one P3 was that thi action log had described the reducer order backwards ("after the `ifLet` had run"), corrected above. The review loop closed there. +Owner feedback after the loop (2026-08-28): the agent-facing description was far too long for +the row and its hover tooltip misbehaved, and the content-sized capsules had unequal widths with +an empty gap before the buttons. The row now shows `prowl-summary`, and the per-target lines +became an aligned full-width `Grid` that also shows each link's folder (impeccable layout pass: +three equivalent targets are a table, not floating chips; no nested containers inside the +grouped Form card). + ## Verification - TDD RED: 25 missing-symbol errors for `SkillInstallClientTests` before the client existed; diff --git a/docs/components/settings.md b/docs/components/settings.md index 045737be..5067e774 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -61,16 +61,19 @@ your agents' skill folders so every agent reads the version that matches the ins app. It is the GUI for [`prowl skills`](cli.md#prowl-skills) in user scope and shows the same status as `prowl skills list`. -- **Rows** — one per bundled `user` skill: name, description, and **Reveal** (shows the - bundled skill folder in Finder). Workflow-only skills are not listed. -- **Chips** — one per *detected* target: `Claude Code` (`~/.claude/skills`), `Codex` - (`~/.codex/skills`), and `Shared agents directory` (`~/.agents/skills`). A target is - detected when its parent folder (`~/.claude`, `~/.codex`, `~/.agents`) exists; hovering - a chip shows the link path. With no detected target the section says so and points at +- **Rows** — one per bundled `user` skill: name, a short summary (the skill's + `metadata.prowl-summary`; the agent-facing `description` is shown only when a skill has no + summary), and **Reveal** (shows the bundled skill folder in Finder). Workflow-only skills are + not listed. +- **Target lines** — one per *detected* target under each skill: `Claude Code` + (`~/.claude/skills`), `Codex` (`~/.codex/skills`), and `Shared agents directory` + (`~/.agents/skills`), each showing the link's folder (`~/.claude/skills/prowl-cli`), its + status, and one action. A target is detected when its parent folder (`~/.claude`, `~/.codex`, + `~/.agents`) exists. With no detected target the section says so and points at `prowl skills install --target `, which creates the folder. - **Statuses and actions** — one explicit action per skill × target link: - | Chip | Meaning | Button | + | Status | Meaning | Button | |---|---|---| | Installed | Symlink → this app's bundled skill | **Remove** (deletes the link only) | | Not installed | Nothing in the slot | **Install** (creates the skills folder if needed) | @@ -79,7 +82,7 @@ same status as `prowl skills list`. | Real file or directory | Something that is not a symlink occupies the slot | none — Prowl never deletes it; remove it manually | - **Aliased targets** — if `~/.claude/skills` and `~/.codex/skills` are symlinks to one - synced folder, both chips describe the same link: installing or removing through one + synced folder, both lines describe the same link: installing or removing through one updates the other immediately. - Results show as a toast; a failure (for example a real directory in the way) also shows an alert. Nothing is auto-linked after an update: a newly bundled skill simply appears diff --git a/supacode/Features/Settings/Views/AgentSkillsSectionView.swift b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift index 0dff1347..3910b078 100644 --- a/supacode/Features/Settings/Views/AgentSkillsSectionView.swift +++ b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift @@ -2,8 +2,8 @@ import ComposableArchitecture import SwiftUI /// Settings → Agents → Command Line Tool → Agent Skills: one row per bundled `user` -/// skill with a status chip and one action per detected target. Link behavior stays -/// in `AgentSkillsFeature`; this view only presents it. +/// skill, with one full-width line per detected target (status, link folder, action). +/// Link behavior stays in `AgentSkillsFeature`; this view only presents it. struct AgentSkillsSectionView: View { @Bindable var store: StoreOf @@ -65,7 +65,7 @@ struct AgentSkillsSectionView: View { } private func skillRow(_ row: AgentSkillsFeature.SkillRow) -> some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(row.skill.name) .font(.headline) @@ -82,43 +82,57 @@ struct AgentSkillsSectionView: View { .buttonStyle(.bordered) .controlSize(.small) } - Text(row.skill.description) + // The frontmatter description carries agent trigger phrasing; the summary is the + // human-facing text, so it wins whenever the skill provides one. + Text(row.skill.summary ?? row.skill.description) .foregroundStyle(.secondary) .font(.callout) - .lineLimit(3) - .help(row.skill.description) + .fixedSize(horizontal: false, vertical: true) + if !row.links.isEmpty { + linkTable(row) + } + } + } + + /// One line per detected target. A grid keeps the target, folder, status, and action columns + /// aligned across lines, so every line spans the row instead of sizing to its own text. + private func linkTable(_ row: AgentSkillsFeature.SkillRow) -> some View { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 8) { ForEach(row.links) { link in - linkRow(skill: row.skill, link: link) + if link.id != row.links.first?.id { + Divider() + } + linkLine(skill: row.skill, link: link) } } + .font(.callout) } - private func linkRow(skill: BundledSkill, link: AgentSkillsFeature.SkillLink) -> some View { - HStack(spacing: 8) { + private func linkLine(skill: BundledSkill, link: AgentSkillsFeature.SkillLink) -> some View { + GridRow { HStack(spacing: 6) { statusIcon(link.status) Text(link.target.displayName) - Text(statusText(link.status)) - .foregroundStyle(.secondary) } - .font(.callout) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(.quaternary, in: Capsule()) - .help(link.linkPath) - - if let destination = link.status.destination { - Text("→ \(destination)") - .font(.callout.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .help(destination) + VStack(alignment: .leading, spacing: 2) { + pathText(abbreviated(link.linkPath)) + if let detail = detail(for: link.status) { + if detail.isPath { + pathText(detail.text) + } else { + Text(detail.text) + .fixedSize(horizontal: false, vertical: true) + } + } } - - Spacer() - + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .help(link.status.destination.map { "\(link.linkPath) → \($0)" } ?? link.linkPath) + Text(statusText(link.status)) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) actionButton(skill: skill, link: link) + .gridColumnAlignment(.trailing) } } @@ -154,10 +168,6 @@ struct AgentSkillsSectionView: View { .help("Replace the link to another Prowl build at \(link.linkPath) with this app's bundled \(skill.id) skill") .buttonStyle(.bordered) .controlSize(.small) - } else { - Text("Prowl never deletes it; remove it manually to link here.") - .foregroundStyle(.secondary) - .font(.callout) } } } @@ -189,4 +199,30 @@ struct AgentSkillsSectionView: View { case .broken: "Broken link" } } + + /// The second folder line: where a foreign or dangling link points, or why a real file or + /// directory gets no action. + private func detail(for status: SymlinkInstallStatus) -> (text: String, isPath: Bool)? { + switch status { + case .installed, .notInstalled: + nil + case .installedDifferentSource(_, let destination): + destination.map { ("→ \(abbreviated($0))", true) } + ?? ("Not a symlink — Prowl never deletes it. Remove it manually to link here.", false) + case .broken(_, let destination): + ("→ \(abbreviated(destination))", true) + } + } + + /// Paths keep one line and truncate in the middle so the skill folder name stays visible. + private func pathText(_ path: String) -> some View { + Text(path) + .font(.callout.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + } + + private func abbreviated(_ path: String) -> String { + (path as NSString).abbreviatingWithTildeInPath + } }