diff --git a/plans/2026-07-13-mcp-tool-apps-custom-chat-renderers.md b/plans/2026-07-13-mcp-tool-apps-custom-chat-renderers.md new file mode 100644 index 0000000..ae6987d --- /dev/null +++ b/plans/2026-07-13-mcp-tool-apps-custom-chat-renderers.md @@ -0,0 +1,378 @@ +# 2026-07-13 — MCP tool apps: custom in-chat renderers + +**Status:** Planned + +## Goal + +Today, every MCP tool call renders identically — a collapsed JSON box in +`ToolCallGroup.svelte` with raw `{args, result}` dumps. The operator has to +expand and read JSON to understand what the agent did or found. This plan +introduces **tool renderers** — per-tool Svelte components that render rich, +purpose-built UI inline in the conversation, while unmatched tools stay +collapsed. + +Concrete examples: `get_entity("host:hubris")` renders as a compact entity card +with type badge, health dot, and key attributes — not as 30 lines of JSON. +`get_health_summary` renders as colored health bars. `list_lxcs` renders as a +sortable table. The operator reads the chat, not raw tool output. + +## UX principle: inline by default when matched + +Today: + +``` +┌─────────────────────────────┐ +│ 🔧 3 tools: get_entity, │ ← collapsed +│ get_health_summary, ... │ +│ ┌───────────────────────┐ │ +│ │ {"slug":"host:hubris",│ │ ← raw JSON when expanded +│ │ "type":"host",...} │ │ +│ └───────────────────────┘ │ +└─────────────────────────────┘ +The fleet is healthy. 42 hosts... +``` + +Target: + +``` +┌─────────────────────────────┐ +│ ● host:hubris [host] 🟢 │ ← entity card, inline +│ healthy · 2 min ago │ +│ IP: 10.13.13.1 │ +└─────────────────────────────┘ +┌─────────────────────────────┐ +│ ████████████ healthy 42 │ ← health bar, inline +│ ███ degraded 3 │ +│ ██ down 1 │ +└─────────────────────────────┘ +┌─────────────────────────────┐ +│ 🔧 1 tool: list_entities │ ← unmatched → still collapsed +└─────────────────────────────┘ +The fleet is healthy... +``` + +Tools with a custom renderer appear **inline** as their own card in the message +flow, between the user bubble and the assistant's markdown text. Tools without +a renderer stay in the collapsed `ToolCallGroup`. This naturally separates +"rich, informative" tools from "utility/plumbing" tools. + +### During streaming (tool in progress) + +``` +┌─────────────────────────────┐ +│ ◌ host:hubris │ ← skeleton while tool_result +│ [host] ⠋ loading… │ hasn't arrived yet +└─────────────────────────────┘ +``` + +Skeleton/spinner state shown while `type === 'tool_use'`; full card on `tool_result`. +The per-tool card has the same chrome as ToolCallGroup: spinner → check/X based +on completion and error state. + +### Error state + +``` +┌─────────────────────────────┐ +│ ✕ get_entity │ +│ Entity not found: "bad" │ +└─────────────────────────────┘ +``` + +## Architecture + +### Layer 1 — Server annotation (`internal/mcp/server.go`) + +Each tool handler that warrants a custom renderer adds a `__renderer` key to +its result JSON. This is a plain string field — no protocol changes, no new +content types. + +```go +// Before: +return queryEntity(ctx, pool, slug), nil + +// After: +r := queryEntity(ctx, pool, slug) +r["__renderer"] = "entity_card" +return jsonResult(r), nil +``` + +Renderer IDs by tool: + +| Tool | `__renderer` | Component | +|------|-------------|-----------| +| `get_entity` | `entity_card` | EntityCard.svelte | +| `whoami` | `entity_card` | EntityCard.svelte | +| `explain` | `entity_card` | EntityCard.svelte | +| `get_health_summary` | `health_summary` | HealthSummary.svelte | +| `list_lxcs` | `lxc_list` | LXCList.svelte | +| `list_entities` | `entity_table` | EntityTable.svelte | +| `search_knowledge` | `knowledge_results` | KnowledgeResults.svelte | +| `get_entity_knowledge` | `knowledge_results` | KnowledgeResults.svelte | +| `query_metrics` | `metric_chart` | MetricChart.svelte | +| `get_blast_radius` | `blast_radius` | BlastRadius.svelte | +| `get_change_history` | `change_log` | ChangeLog.svelte | +| `get_agent_activity` | `activity_log` | (reuses ChangeLog.svelte) | +| `get_state_snapshot` | `fleet_snapshot` | FleetSnapshot.svelte | + +Non-rendered tools (stay collapsed): `get_relations`, `list_my_secrets`, +`get_service_status`, `tail_log`, `get_lxc_state`, `ping_service`, +`get_patterns`, `get_skills`, `http_get`, `preflight`, `get_signal_history`, +`get_execution_status`, `get_audit_trail`, `get_trend`, +`get_event_timeline`, `upsert_knowledge`, `update_entity_attributes`, +`create_relationship`, `run`, `request_execution`. + +Gated execution tools (`run`, `request_execution`) already have +`InlineApproval.svelte` rendering in Chat.svelte — they stay in the collapsed +group so the approval card remains prominent at the message level. + +### Layer 2 — Renderer registry (`web/src/lib/tool-renderers.ts`) + +```ts +import type { ComponentType, SvelteComponent } from 'svelte' +import type { ToolCallResult } from '$lib/stores/chat' + +export interface ToolRenderer { + match: (tool: ToolCallResult) => boolean + component: ComponentType<{ tool: ToolCallResult }> +} + +const registry: ToolRenderer[] = [] + +export function registerToolRenderer(r: ToolRenderer) { + registry.push(r) +} + +export function getToolRenderer(tool: ToolCallResult): ToolRenderer | undefined { + return registry.find(r => r.match(tool)) +} +``` + +Match strategy: check `tool.name` against known tool names AND check +`tool.result?.__renderer` if the result is an object. The tool-name path +handles the streaming case (tool_use arrives before tool_result); the +`__renderer` path handles cases where the same tool can return different +shapes (not applicable today but future-proof). + +```ts +function hasRenderer(tool: ToolCallResult, id: string): boolean { + if (tool.name === id) return true + if (tool.result && typeof tool.result === 'object' && tool.result.__renderer === id) return true + return false +} +``` + +### Layer 3 — Auto-registration via Vite glob (`web/src/lib/renderers/index.ts`) + +Each renderer file exports a Svelte component and an `init()` call: + +```ts +// web/src/lib/renderers/entity-card.ts +import EntityCard from './EntityCard.svelte' +import { registerToolRenderer } from '$lib/tool-renderers' + +export function init() { + registerToolRenderer({ + match: (t) => hasRenderer(t, 'entity_card'), + component: EntityCard + }) +} +``` + +`web/src/lib/renderers/index.ts` imports and calls `init()` for every renderer +module. In `web/src/main.ts`, a single `import './lib/renderers'` wires +everything. Adding a new renderer means: create `Foo.svelte` + `foo.ts` with +`init()`, add the import to `index.ts`. No changes to ToolCallGroup or Chat. + +### Layer 4 — ToolCallGroup split (`web/src/lib/components/ToolCallGroup.svelte`) + +`ToolCallGroup` today receives `tools: ToolCallResult[]` and renders all of them. +Change: add an exported `unmatched` derived prop, and move the per-tool +rendering to Chat.svelte so inline cards appear in the message flow. + +**New ToolCallGroup props:** + +```ts +let { tools, unmatched, active = false }: { + tools: ToolCallResult[] + unmatched: ToolCallResult[] + active?: boolean +} = $props() +``` + +`tools` is the full list (for the summary: "3 tools"); `unmatched` is the +subset without renderers (for the collapsed body). The summary bar says +"1 tool" if only one unmatched tool exists, or "1 tool + 2 cards" to +acknowledge the inline ones. + +**Chat.svelte changes (lines 114-133):** + +```svelte +{:else} +
+ + {#each msg.tools as tool (tool.id)} + {@const renderer = getToolRenderer(tool)} + {#if renderer} + + {/if} + {/each} + + + !getToolRenderer(t))} + active={$streaming && i === $messages.length - 1} + /> + + + ... +
+{/if} +``` + +### Layer 5 — Renderer component contract + +Every renderer component receives a single `tool: ToolCallResult` prop. + +Component responsibilities: +- **Loading state** (`tool.type === 'tool_use'`): render a skeleton/spinner with + the tool name and relevant args +- **Success state** (`tool.type === 'tool_result' && !tool.error`): render the + rich card +- **Error state** (`tool.type === 'tool_result' && tool.error`): render error + with a compact message +- **No result** (tool completed but result is null/empty): render a minimal card + with just the tool name + checkmark + +Self-contained card: a border, padding, and the same size feel as the +existing approval cards. Each card is independent — no shared state between +renderers. + +## New components + +| Component | Matches | Visual | +|-----------|---------|--------| +| `EntityCard` | `get_entity`, `whoami`, `explain` | Slug + type badge + health dot + state + key attrs (IP, version) + "last checked" relative time | +| `HealthSummary` | `get_health_summary` | Horizontal stacked bar: green=healthy, amber=degraded, red=down, with counts | +| `LXCList` | `list_lxcs` | Compact table: name, host, IP, health dot, status | +| `EntityTable` | `list_entities` | Sortable table (reuses EntityTable.svelte from KB page) | +| `KnowledgeResults` | `search_knowledge`, `get_entity_knowledge` | Result cards: title + excerpt + tags | +| `MetricChart` | `query_metrics` | Sparkline or small bar chart of bucketed values | +| `BlastRadius` | `get_blast_radius` | Entity list grouped by distance (direct → 1 hop → 2 hops) | +| `ChangeLog` | `get_change_history`, `get_agent_activity` | Timeline of recent entries with timestamps | +| `FleetSnapshot` | `get_state_snapshot` | Summary grid: healthy/degraded/down counts + drift count | + +Some already exist partially: `EntityTable.svelte` and `EntityGraph` are used +on the KB page — the renderer can wrap or reuse them. + +## File changes + +| File | Change | +|------|--------| +| `internal/mcp/server.go` | Add `__renderer` to result JSON for ~12 tools | +| `web/src/lib/tool-renderers.ts` | New — registry + match helpers | +| `web/src/lib/renderers/index.ts` | New — imports all renderer init modules | +| `web/src/lib/renderers/entity-card.ts` | New — register `EntityCard` | +| `web/src/lib/renderers/EntityCard.svelte` | New — entity card component | +| `web/src/lib/renderers/health-summary.ts` | New — register `HealthSummary` | +| `web/src/lib/renderers/HealthSummary.svelte` | New — health bar component | +| `web/src/lib/renderers/lxc-list.ts` | New — register `LXCList` | +| `web/src/lib/renderers/LXCList.svelte` | New — LXC table component | +| `web/src/lib/renderers/knowledge-results.ts` | New — register `KnowledgeResults` | +| `web/src/lib/renderers/KnowledgeResults.svelte` | New — search results component | +| `web/src/lib/renderers/metric-chart.ts` | New — register `MetricChart` | +| `web/src/lib/renderers/MetricChart.svelte` | New — sparkline component | +| `web/src/lib/renderers/blast-radius.ts` | New — register `BlastRadius` | +| `web/src/lib/renderers/BlastRadius.svelte` | New — blast radius component | +| `web/src/lib/renderers/change-log.ts` | New — register `ChangeLog` | +| `web/src/lib/renderers/ChangeLog.svelte` | New — timeline component | +| `web/src/lib/renderers/fleet-snapshot.ts` | New — register `FleetSnapshot` | +| `web/src/lib/renderers/FleetSnapshot.svelte` | New — snapshot grid component | +| `web/src/lib/components/ToolCallGroup.svelte` | Add `unmatched` prop; render only unmatched tools in body; adjust summary text | +| `web/src/pages/Chat.svelte` | Filter matched tools to inline cards; pass unmatched to ToolCallGroup | +| `web/src/main.ts` | Add `import './lib/renderers'` | + +## Phases + +### Phase 1 — Infra + first renderer + +1. Create `tool-renderers.ts` registry +2. Create renderer directory + index +3. Modify `ToolCallGroup.svelte` to accept `unmatched` prop +4. Modify `Chat.svelte` to dispatch inline cards +5. Add `__renderer` to `get_entity`, `whoami`, `explain` in server.go +6. Create `EntityCard.svelte` — the flagship renderer +7. Test: ask Nomos "tell me about host:hubris" +8. Verify: entity card renders inline, unrelated tools stay collapsed + +### Phase 2 — Remaining renderers + +Add in priority order: +1. `HealthSummary` + `get_health_summary` annotation +2. `LXCList` + `list_lxcs` annotation +3. `KnowledgeResults` + `search_knowledge` / `get_entity_knowledge` annotation +4. `BlastRadius` + `get_blast_radius` annotation +5. `EntityTable` + `list_entities` annotation +6. `ChangeLog` + `get_change_history` / `get_agent_activity` annotation +7. `FleetSnapshot` + `get_state_snapshot` annotation +8. `MetricChart` + `query_metrics` annotation + +### Phase 3 — Polish + +1. Smooth streaming: renderer shows skeleton on `tool_use`, renders on `tool_result` +2. Error states: renderer shows compact error card (not raw JSON) +3. Mobile: renderers stack full-width on narrow viewports +4. Accessibility: renderer cards have proper ARIA labels + +## Risks / open questions + +1. **Tool name vs `__renderer` mismatch**: If the server adds `__renderer` but + the frontend hasn't deployed yet, the field is silently ignored and the + tool falls back to the collapsed group. No breakage. Vice versa (frontend + expects a renderer that the server doesn't emit): match falls through to + collapsed group. Graceful degradation in both directions. + +2. **Streaming jank**: During a turn, tool_use events arrive before tool_result. + The renderer sees `type: 'tool_use'` initially (shows skeleton), then Svelte + reactivity updates when the store merges `tool_result`. The card transitions + from skeleton → rich. This is the same model as ToolCallGroup's + spinner → check transition. No new complexity. + +3. **Multiple tools of same type**: A single agent turn might call `get_entity` + twice. Each gets its own inline card — natural, no dedup needed. + +4. **Card explosion**: If the agent calls 15 tools in one turn and 12 of them + match renderers, the chat gets 12 inline cards before any text. This could + be noisy if the agent is chatty. Mitigations: (a) renderers are compact + (3-4 lines), (b) the collapsed group still exists for unmatched tools, + (c) we can add a per-turn limit later ("show top 3, collapse rest") but + start simple. The agent in practice calls 3-6 tools per turn; 12 is + an edge case. + +5. **Security**: Renderers receive tool result data that already passed through + the SSE stream (authenticated). No new attack surface. Renderers render + data, not HTML — Svelte's auto-escaping handles XSS. No `{@html}` in any + renderer unless explicitly sanitized. + +6. **Persistence**: Tool results are already persisted in the DB (via Nomos's + incremental message persistence). On session reload, + `mergeToolCalls()` → `toChatMessages()` reconstructs `ToolCallResult[]` + including `result`. The `__renderer` field survives the roundtrip because + it's part of the result JSON, which is stored as-is. No schema change. + **Verified**: `cmd/nomos/continue.go` marshals tool results to JSONB + without filtering fields — `__renderer` passes through transparently. + +7. **The gated-execution tools** (`run`, `request_execution`): These already + surface as `InlineApproval` cards at the message level via + `extractApprovals()` in `chat.ts`. They deliberately stay in the collapsed + group — don't add a renderer for them. The approval card is the rich UI. + +## Cost / effort + +| Area | Estimate | +|------|----------| +| Server annotations | ~20 lines across ~12 tools | +| Registry + infra | ~50 lines (tool-renderers.ts + Chat.svelte + ToolCallGroup changes) | +| First renderer (EntityCard) | ~60 lines | +| Remaining 8 renderers | ~40-80 lines each | +| **Total** | ~500 lines, 1-2 sessions | diff --git a/plans/2026-07-12-wails-desktop-app.md b/plans/done/2026-07-12-wails-desktop-app.md similarity index 99% rename from plans/2026-07-12-wails-desktop-app.md rename to plans/done/2026-07-12-wails-desktop-app.md index ab232fd..ac23461 100644 --- a/plans/2026-07-12-wails-desktop-app.md +++ b/plans/done/2026-07-12-wails-desktop-app.md @@ -1,8 +1,8 @@ # 2026-07-12 — Wails desktop application -**Status:** In Progress — Phase 0 (0.1-0.4, 0.6) done, verified live in a -local browser test, and **deployed to production** (mac-mini, commit -`0c0f35a`, 2026-07-12). Phase 1 (Wails shell) not started. +**Status:** Done — Phases 0.0–0.6 deployed to production (mac-mini, commit +`0c0f35a`, 2026-07-12). Phases 1.0–1.4 implemented (commit `5d6d9e9`, +2026-07-13) — pushed to main. **Production deploy (2026-07-12):** merged to `main`, picked up by the 2-minute deploy poller (`scripts/deploy.sh`: pg_dump backup → rebuild → diff --git a/plans/index.md b/plans/index.md index cd4ef05..8cd4579 100644 --- a/plans/index.md +++ b/plans/index.md @@ -14,7 +14,7 @@ went sideways, open an investigation. | 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred | | 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open | | 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred | -| 2026-07-12 | [Wails desktop application](2026-07-12-wails-desktop-app.md) | In Progress — Phase 0 done and deployed to production (2026-07-12), Phase 1 not started | +| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred | ## Done @@ -44,6 +44,7 @@ See [`done/`](done/) for executed plans: | 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](done/2026-07-11-concurrent-task-execution.md) | | 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) | | 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) | +| 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) | ## Conventions