# Earl Web Playground Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Build a documentation-forward web playground that presents all loaded earl commands with an interactive try-it-out panel, using the existing Axum backend and React+shadcn frontend scaffold.

**Architecture:** Three-panel Stripe-style layout (sidebar + command detail + try-it panel) using react-resizable-panels. The Axum backend serves the React SPA via embedded static files. State managed with useReducer. Hash-based URL routing for bookmarkability.

**Tech Stack:** React 19, Vite, Tailwind CSS, shadcn components, cmdk, react-resizable-panels, react-markdown, @tanstack/react-virtual. Backend: Rust/Axum.

---

### Task 1: Fix CSS Bug and Add Design Tokens

**Files:**
- Modify: `web/src/index.css`

**Step 1: Fix the hsl/oklch mismatch bug**

The CSS variables use oklch() but the global rules wrap them in hsl(). Fix lines 46-55:

```css
* {
  border-color: var(--border);
}

body {
  min-height: 100vh;
  margin: 0;
  font-family: "Noto Sans", "Avenir Next", "Segoe UI", sans-serif;
  color: var(--foreground);
  background: var(--background);
}
```

**Step 2: Add accent, success, and warning color tokens**

Add to the `:root` block (replacing the existing grayscale `--accent` values):

```css
  --accent: oklch(0.55 0.20 260);
  --accent-foreground: oklch(0.985 0 0);
  --success: oklch(0.65 0.17 145);
  --success-foreground: oklch(0.985 0 0);
  --warning: oklch(0.70 0.15 70);
  --warning-foreground: oklch(0.985 0 0);
  --ring: oklch(0.55 0.12 260);
```

Add to the `.dark` block:

```css
  --accent: oklch(0.65 0.18 260);
  --accent-foreground: oklch(0.145 0 0);
  --success: oklch(0.72 0.15 145);
  --success-foreground: oklch(0.145 0 0);
  --warning: oklch(0.75 0.13 70);
  --warning-foreground: oklch(0.145 0 0);
  --ring: oklch(0.50 0.10 260);
```

Add to the `@theme inline` block:

```css
  --color-success: var(--success);
  --color-success-foreground: var(--success-foreground);
  --color-warning: var(--warning);
  --color-warning-foreground: var(--warning-foreground);
```

Also add a code surface custom property to `:root` and `.dark`:

```css
/* :root */
  --code-surface: oklch(0.975 0.005 260);

/* .dark */
  --code-surface: oklch(0.18 0.008 260);
```

And in `@theme inline`:

```css
  --color-code-surface: var(--code-surface);
```

**Step 3: Add monospace font stack**

Add to `@theme inline`:

```css
  --font-mono: "Noto Sans Mono", "SF Mono", "Fira Code", ui-monospace, monospace;
```

**Step 4: Commit**

```bash
git add web/src/index.css
git commit -m "fix: correct oklch/hsl mismatch and add accent color tokens"
```

---

### Task 2: Install Frontend Dependencies

**Files:**
- Modify: `web/package.json`
- Modify: `package.json` (catalog)

**Step 1: Install new dependencies**

```bash
cd /Users/jrandolf/Sources/earl
pnpm add -w react-markdown remark-gfm @tanstack/react-virtual --filter earl-web
```

If the monorepo uses a catalog for version pinning, add the packages to the root `package.json` catalog first, then add `"catalog:"` references in `web/package.json`.

**Step 2: Verify the build still works**

```bash
cd /Users/jrandolf/Sources/earl/web
pnpm build
```

Expected: Build succeeds with no errors.

**Step 3: Commit**

```bash
git add package.json web/package.json pnpm-lock.yaml
git commit -m "chore: add react-markdown, remark-gfm, and tanstack virtual"
```

---

### Task 3: Backend -- Add Secrets and Validate Endpoints

**Files:**
- Modify: `src/web/mod.rs`

**Step 1: Add `secrets` field to `ToolSummaryResponse`**

In the `ToolSummaryResponse` struct, add after `categories`:

```rust
    secrets: Vec<String>,
```

In `tool_summary_from_entry`, add:

```rust
    secrets: entry.template.annotations.secrets.clone(),
```

**Step 2: Add the validate endpoint**

Add a new route in `build_router`:

```rust
    .route("/api/validate", post(api_validate))
```

Add the handler:

```rust
#[derive(Debug, Deserialize)]
struct ValidateRequest {
    expression: String,
}

#[derive(Debug, Serialize)]
struct ValidateResponse {
    valid: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    command_key: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    bound_params: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    missing_required: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<ValidateError>,
}

#[derive(Debug, Serialize)]
struct ValidateError {
    code: String,
    message: String,
}

async fn api_validate(
    State(state): State<WebState>,
    Json(payload): Json<ValidateRequest>,
) -> Result<Json<ValidateResponse>, ApiError> {
    let expression = match parse_expression(&payload.expression) {
        Ok(expr) => expr,
        Err(err) => {
            return Ok(Json(ValidateResponse {
                valid: false,
                command_key: None,
                bound_params: None,
                missing_required: None,
                error: Some(ValidateError {
                    code: "parse_error".to_string(),
                    message: err.to_string(),
                }),
            }));
        }
    };

    let catalog = load_catalog(&state.cwd).map_err(|err| {
        ApiError::execution_error(format!("failed loading template catalog: {err:#}"))
    })?;

    let key = expression.command_key();
    let entry = match catalog.get(&key) {
        Some(entry) => entry,
        None => {
            return Ok(Json(ValidateResponse {
                valid: false,
                command_key: Some(key),
                bound_params: None,
                missing_required: None,
                error: Some(ValidateError {
                    code: "unknown_command".to_string(),
                    message: format!("unknown command `{key}`"),
                }),
            }));
        }
    };

    match bind_arguments(&expression, &entry.template.params) {
        Ok(args) => {
            let bound: Vec<String> = args.keys().cloned().collect();
            Ok(Json(ValidateResponse {
                valid: true,
                command_key: Some(key),
                bound_params: Some(bound),
                missing_required: Some(vec![]),
                error: None,
            }))
        }
        Err(err) => Ok(Json(ValidateResponse {
            valid: false,
            command_key: Some(key),
            bound_params: None,
            missing_required: None,
            error: Some(ValidateError {
                code: "bind_error".to_string(),
                message: err.to_string(),
            }),
        })),
    }
}
```

**Step 3: Add the secrets status endpoint**

Add route:

```rust
    .route("/api/secrets/status", post(api_secrets_status))
```

Add handler:

```rust
#[derive(Debug, Deserialize)]
struct SecretsStatusRequest {
    secrets: Vec<String>,
}

#[derive(Debug, Serialize)]
struct SecretStatus {
    configured: bool,
}

async fn api_secrets_status(
    Json(payload): Json<SecretsStatusRequest>,
) -> Json<std::collections::HashMap<String, SecretStatus>> {
    let manager = SecretManager::new();
    let mut result = std::collections::HashMap::new();
    for secret_name in payload.secrets {
        let configured = manager.get(&secret_name).is_ok();
        result.insert(secret_name, SecretStatus { configured });
    }
    Json(result)
}
```

**Step 4: Run existing tests**

```bash
cargo test --lib web
```

Expected: All existing tests pass. The new `secrets` field will appear in the JSON output of `api_tools_returns_expected_schema` but since the test only checks specific fields, it should still pass.

**Step 5: Commit**

```bash
git add src/web/mod.rs
git commit -m "feat(web): add secrets field, validate endpoint, and secrets status endpoint"
```

---

### Task 4: TypeScript Types and API Client

**Files:**
- Create: `web/src/lib/types.ts`
- Create: `web/src/lib/api.ts`
- Create: `web/src/lib/utils.ts` (if it doesn't exist -- check first, shadcn may have created it)

**Step 1: Create TypeScript types matching the Rust backend**

```typescript
// web/src/lib/types.ts

export type ParamType =
  | "string"
  | "integer"
  | "number"
  | "boolean"
  | "null"
  | "array"
  | "object";

export interface ParamSpec {
  name: string;
  type: ParamType;
  required: boolean;
  default?: unknown;
  description?: string;
}

export interface ToolSource {
  scope: "local" | "global";
  path: string;
}

export interface ToolSummary {
  key: string;
  provider: string;
  command: string;
  title: string;
  summary: string;
  description: string;
  mode: "read" | "write";
  protocol: "http" | "graphql" | "grpc" | "cli";
  categories: string[];
  secrets: string[];
  params: ParamSpec[];
  source: ToolSource;
  example_expression: string;
}

export interface ToolDetail extends ToolSummary {
  operation: unknown;
}

export interface ExecuteRequest {
  expression: string;
  confirm_write?: boolean;
}

export interface ExecuteResponse {
  key: string;
  mode: string;
  status: number;
  url: string;
  result: unknown;
  decoded: unknown;
  human_output: string;
}

export interface ApiError {
  error: {
    code: string;
    message: string;
  };
}

export interface ValidateRequest {
  expression: string;
}

export interface ValidateResponse {
  valid: boolean;
  command_key?: string;
  bound_params?: string[];
  missing_required?: string[];
  error?: {
    code: string;
    message: string;
  };
}

export interface SecretStatus {
  configured: boolean;
}

export interface HistoryEntry {
  id: string;
  timestamp: Date;
  expression: string;
  commandKey: string;
  response: ExecuteResponse | null;
  error: ApiError | null;
  durationMs: number;
}
```

**Step 2: Create API client**

```typescript
// web/src/lib/api.ts

import type {
  ToolSummary,
  ToolDetail,
  ExecuteRequest,
  ExecuteResponse,
  ValidateRequest,
  ValidateResponse,
  SecretStatus,
  ApiError,
} from "./types";

class ApiClient {
  private baseUrl: string;

  constructor(baseUrl = "") {
    this.baseUrl = baseUrl;
  }

  private async request<T>(
    path: string,
    options?: RequestInit
  ): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, {
      headers: { "Content-Type": "application/json" },
      ...options,
    });

    if (!response.ok) {
      const body = (await response.json()) as ApiError;
      throw body;
    }

    return response.json() as Promise<T>;
  }

  async listTools(): Promise<ToolSummary[]> {
    return this.request<ToolSummary[]>("/api/tools");
  }

  async getTool(key: string): Promise<ToolDetail> {
    return this.request<ToolDetail>(`/api/tools/${encodeURIComponent(key)}`);
  }

  async execute(req: ExecuteRequest): Promise<ExecuteResponse> {
    return this.request<ExecuteResponse>("/api/execute", {
      method: "POST",
      body: JSON.stringify(req),
    });
  }

  async validate(req: ValidateRequest): Promise<ValidateResponse> {
    return this.request<ValidateResponse>("/api/validate", {
      method: "POST",
      body: JSON.stringify(req),
    });
  }

  async checkSecrets(
    secrets: string[]
  ): Promise<Record<string, SecretStatus>> {
    return this.request<Record<string, SecretStatus>>(
      "/api/secrets/status",
      {
        method: "POST",
        body: JSON.stringify({ secrets }),
      }
    );
  }
}

export const api = new ApiClient();
```

**Step 3: Commit**

```bash
git add web/src/lib/types.ts web/src/lib/api.ts
git commit -m "feat(web): add TypeScript types and API client"
```

---

### Task 5: App State Reducer

**Files:**
- Create: `web/src/lib/state.ts`

**Step 1: Define the state shape and actions**

```typescript
// web/src/lib/state.ts

import type {
  ToolSummary,
  ToolDetail,
  ExecuteResponse,
  ApiError,
  ValidateResponse,
  HistoryEntry,
  SecretStatus,
} from "./types";

export interface AppState {
  // Catalog
  tools: ToolSummary[];
  catalogLoading: boolean;
  catalogError: string | null;

  // Selected command
  selectedKey: string | null;
  selectedDetail: ToolDetail | null;
  detailLoading: boolean;

  // Secrets status
  secretsStatus: Record<string, SecretStatus>;

  // Try-it panel
  formValues: Record<string, unknown>;
  expressionText: string;
  syncSource: "form" | "expression";
  validation: ValidateResponse | null;
  validating: boolean;

  // Execution
  executing: boolean;
  executeSuccess: boolean;

  // Response
  currentResponse: ExecuteResponse | null;
  currentError: ApiError | null;
  currentDurationMs: number | null;

  // History
  history: HistoryEntry[];

  // Filters
  searchQuery: string;
  categoryFilter: string | null;
}

export const initialState: AppState = {
  tools: [],
  catalogLoading: true,
  catalogError: null,
  selectedKey: null,
  selectedDetail: null,
  detailLoading: false,
  secretsStatus: {},
  formValues: {},
  expressionText: "",
  syncSource: "form",
  validation: null,
  validating: false,
  executing: false,
  executeSuccess: false,
  currentResponse: null,
  currentError: null,
  currentDurationMs: null,
  history: [],
  searchQuery: "",
  categoryFilter: null,
};

export type Action =
  | { type: "CATALOG_LOADED"; tools: ToolSummary[] }
  | { type: "CATALOG_ERROR"; error: string }
  | { type: "SELECT_COMMAND"; key: string }
  | { type: "DETAIL_LOADED"; detail: ToolDetail }
  | { type: "SECRETS_STATUS_LOADED"; status: Record<string, SecretStatus> }
  | { type: "SET_FORM_VALUE"; name: string; value: unknown }
  | { type: "SET_EXPRESSION"; text: string }
  | { type: "SYNC_EXPRESSION_FROM_FORM"; expression: string }
  | { type: "SYNC_FORM_FROM_EXPRESSION"; values: Record<string, unknown> }
  | { type: "VALIDATION_START" }
  | { type: "VALIDATION_RESULT"; result: ValidateResponse }
  | { type: "EXECUTE_START" }
  | {
      type: "EXECUTE_SUCCESS";
      response: ExecuteResponse;
      expression: string;
      durationMs: number;
    }
  | { type: "EXECUTE_ERROR"; error: ApiError; expression: string; durationMs: number }
  | { type: "EXECUTE_FLASH_DONE" }
  | { type: "LOAD_HISTORY_ENTRY"; entry: HistoryEntry }
  | { type: "SET_SEARCH_QUERY"; query: string }
  | { type: "SET_CATEGORY_FILTER"; category: string | null };

export function reducer(state: AppState, action: Action): AppState {
  switch (action.type) {
    case "CATALOG_LOADED":
      return { ...state, tools: action.tools, catalogLoading: false };

    case "CATALOG_ERROR":
      return {
        ...state,
        catalogError: action.error,
        catalogLoading: false,
      };

    case "SELECT_COMMAND":
      return {
        ...state,
        selectedKey: action.key,
        selectedDetail: null,
        detailLoading: true,
        formValues: {},
        expressionText: "",
        syncSource: "form",
        validation: null,
        currentResponse: null,
        currentError: null,
        currentDurationMs: null,
        secretsStatus: {},
      };

    case "DETAIL_LOADED": {
      const defaults: Record<string, unknown> = {};
      for (const param of action.detail.params) {
        if (param.default !== undefined) {
          defaults[param.name] = param.default;
        }
      }
      return {
        ...state,
        selectedDetail: action.detail,
        detailLoading: false,
        formValues: defaults,
        expressionText: action.detail.example_expression,
        syncSource: "form",
      };
    }

    case "SECRETS_STATUS_LOADED":
      return { ...state, secretsStatus: action.status };

    case "SET_FORM_VALUE":
      return {
        ...state,
        formValues: { ...state.formValues, [action.name]: action.value },
        syncSource: "form",
      };

    case "SET_EXPRESSION":
      return {
        ...state,
        expressionText: action.text,
        syncSource: "expression",
      };

    case "SYNC_EXPRESSION_FROM_FORM":
      return { ...state, expressionText: action.expression };

    case "SYNC_FORM_FROM_EXPRESSION":
      return { ...state, formValues: action.values };

    case "VALIDATION_START":
      return { ...state, validating: true };

    case "VALIDATION_RESULT":
      return { ...state, validation: action.result, validating: false };

    case "EXECUTE_START":
      return { ...state, executing: true, executeSuccess: false };

    case "EXECUTE_SUCCESS": {
      const entry: HistoryEntry = {
        id: crypto.randomUUID(),
        timestamp: new Date(),
        expression: action.expression,
        commandKey: state.selectedKey ?? "",
        response: action.response,
        error: null,
        durationMs: action.durationMs,
      };
      return {
        ...state,
        executing: false,
        executeSuccess: true,
        currentResponse: action.response,
        currentError: null,
        currentDurationMs: action.durationMs,
        history: [entry, ...state.history],
      };
    }

    case "EXECUTE_ERROR": {
      const entry: HistoryEntry = {
        id: crypto.randomUUID(),
        timestamp: new Date(),
        expression: action.expression,
        commandKey: state.selectedKey ?? "",
        response: null,
        error: action.error,
        durationMs: action.durationMs,
      };
      return {
        ...state,
        executing: false,
        currentResponse: null,
        currentError: action.error,
        currentDurationMs: action.durationMs,
        history: [entry, ...state.history],
      };
    }

    case "EXECUTE_FLASH_DONE":
      return { ...state, executeSuccess: false };

    case "LOAD_HISTORY_ENTRY":
      return {
        ...state,
        currentResponse: action.entry.response,
        currentError: action.entry.error,
        currentDurationMs: action.entry.durationMs,
      };

    case "SET_SEARCH_QUERY":
      return { ...state, searchQuery: action.query };

    case "SET_CATEGORY_FILTER":
      return { ...state, categoryFilter: action.category };

    default:
      return state;
  }
}
```

**Step 2: Commit**

```bash
git add web/src/lib/state.ts
git commit -m "feat(web): add app state reducer"
```

---

### Task 6: CodeBlock Component

**Files:**
- Create: `web/src/components/code-block.tsx`

**Step 1: Build the signature code block**

```tsx
// web/src/components/code-block.tsx

import { useState, useCallback } from "react";
import { CopyIcon, CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";

interface CodeBlockProps {
  children: string;
  label?: string;
  className?: string;
}

export function CodeBlock({ children, label, className }: CodeBlockProps) {
  const [copied, setCopied] = useState(false);

  const handleCopy = useCallback(() => {
    navigator.clipboard.writeText(children);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }, [children]);

  return (
    <div
      className={cn(
        "rounded-lg border border-border bg-code-surface overflow-hidden",
        className
      )}
    >
      {label !== undefined && (
        <div className="flex items-center justify-between h-7 px-3 border-b border-border">
          <span className="text-[10px] uppercase tracking-widest text-muted-foreground font-medium">
            {label}
          </span>
          <Button
            variant="ghost"
            size="icon-xs"
            onClick={handleCopy}
            aria-label="Copy to clipboard"
          >
            <span className="relative size-3">
              <CopyIcon
                className={cn(
                  "absolute inset-0 size-3 transition-opacity duration-150",
                  copied ? "opacity-0" : "opacity-100"
                )}
              />
              <CheckIcon
                className={cn(
                  "absolute inset-0 size-3 text-success transition-opacity duration-150",
                  copied ? "opacity-100" : "opacity-0"
                )}
              />
            </span>
          </Button>
        </div>
      )}
      {label === undefined && (
        <Button
          variant="ghost"
          size="icon-xs"
          onClick={handleCopy}
          aria-label="Copy to clipboard"
          className="absolute top-1.5 right-1.5"
        />
      )}
      <pre className="p-3 overflow-x-auto">
        <code className="font-mono text-[13px] leading-[1.6] text-foreground">
          {children}
        </code>
      </pre>
    </div>
  );
}
```

**Step 2: Commit**

```bash
git add web/src/components/code-block.tsx
git commit -m "feat(web): add signature CodeBlock component"
```

---

### Task 7: Expression Builder Utility

**Files:**
- Create: `web/src/lib/expression.ts`

**Step 1: Create the expression builder**

This builds an earl expression string from form values, used for Form→Expression sync.

```typescript
// web/src/lib/expression.ts

import type { ParamSpec } from "./types";

export function buildExpression(
  commandKey: string,
  params: ParamSpec[],
  values: Record<string, unknown>
): string {
  const args: string[] = [];

  for (const param of params) {
    const value = values[param.name];
    if (value === undefined || value === null || value === "") {
      continue;
    }
    // Skip values that match defaults
    if (
      param.default !== undefined &&
      JSON.stringify(value) === JSON.stringify(param.default)
    ) {
      continue;
    }
    args.push(`${param.name}=${formatValue(value)}`);
  }

  if (args.length === 0) {
    return `${commandKey}()`;
  }

  return `${commandKey}(${args.join(", ")})`;
}

function formatValue(value: unknown): string {
  if (typeof value === "string") {
    return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
  }
  if (typeof value === "number" || typeof value === "boolean") {
    return String(value);
  }
  if (value === null) {
    return "null";
  }
  // Arrays and objects
  return JSON.stringify(value);
}
```

**Step 2: Commit**

```bash
git add web/src/lib/expression.ts
git commit -m "feat(web): add expression builder utility"
```

---

### Task 8: Header Component

**Files:**
- Create: `web/src/components/header.tsx`

**Step 1: Build the header**

```tsx
// web/src/components/header.tsx

import { SearchIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";

interface HeaderProps {
  onSearchOpen: () => void;
}

export function Header({ onSearchOpen }: HeaderProps) {
  return (
    <header className="flex items-center justify-between h-12 px-4 border-b border-border shrink-0">
      <span className="text-sm font-semibold tracking-tight">earl</span>
      <Button
        variant="outline"
        size="sm"
        onClick={onSearchOpen}
        className="gap-2 text-muted-foreground"
      >
        <SearchIcon className="size-3" />
        <span className="text-xs">Search commands</span>
        <KbdGroup>
          <Kbd>⌘</Kbd>
          <Kbd>K</Kbd>
        </KbdGroup>
      </Button>
    </header>
  );
}
```

**Step 2: Commit**

```bash
git add web/src/components/header.tsx
git commit -m "feat(web): add Header component"
```

---

### Task 9: Command Palette

**Files:**
- Create: `web/src/components/command-palette.tsx`

**Step 1: Build the global command palette**

```tsx
// web/src/components/command-palette.tsx

import { useEffect } from "react";
import {
  CommandDialog,
  CommandInput,
  CommandList,
  CommandEmpty,
  CommandGroup,
  CommandItem,
} from "@/components/ui/command";
import type { ToolSummary } from "@/lib/types";

interface CommandPaletteProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  tools: ToolSummary[];
  onSelect: (key: string) => void;
}

export function CommandPalette({
  open,
  onOpenChange,
  tools,
  onSelect,
}: CommandPaletteProps) {
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        onOpenChange(!open);
      }
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [open, onOpenChange]);

  // Group tools by provider
  const providers = new Map<string, ToolSummary[]>();
  for (const tool of tools) {
    const list = providers.get(tool.provider) ?? [];
    list.push(tool);
    providers.set(tool.provider, list);
  }

  return (
    <CommandDialog
      open={open}
      onOpenChange={onOpenChange}
      title="Search commands"
      description="Find and select a command to view"
    >
      <CommandInput placeholder="Search commands..." />
      <CommandList>
        <CommandEmpty>No commands found.</CommandEmpty>
        {Array.from(providers.entries()).map(([provider, items]) => (
          <CommandGroup key={provider} heading={provider}>
            {items.map((tool) => (
              <CommandItem
                key={tool.key}
                value={`${tool.key} ${tool.title} ${tool.summary}`}
                onSelect={() => {
                  onSelect(tool.key);
                  onOpenChange(false);
                }}
              >
                <div className="flex items-center gap-2 flex-1 min-w-0">
                  <span
                    className="size-1.5 rounded-full shrink-0"
                    style={{
                      backgroundColor:
                        tool.mode === "read"
                          ? "var(--success)"
                          : "var(--warning)",
                    }}
                  />
                  <span className="truncate font-mono text-xs">
                    {tool.command}
                  </span>
                  <span className="truncate text-muted-foreground text-xs">
                    {tool.summary}
                  </span>
                </div>
              </CommandItem>
            ))}
          </CommandGroup>
        ))}
      </CommandList>
    </CommandDialog>
  );
}
```

**Step 2: Commit**

```bash
git add web/src/components/command-palette.tsx
git commit -m "feat(web): add CommandPalette component"
```

---

### Task 10: Sidebar

**Files:**
- Create: `web/src/components/app-sidebar.tsx`

**Step 1: Build the sidebar with provider tree**

```tsx
// web/src/components/app-sidebar.tsx

import { useMemo, useState } from "react";
import { ChevronRightIcon, FilterIcon } from "lucide-react";
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarGroupContent,
  SidebarHeader,
  SidebarInput,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuButton,
  SidebarMenuBadge,
  SidebarMenuSub,
  SidebarMenuSubItem,
  SidebarMenuSubButton,
  SidebarMenuSkeleton,
} from "@/components/ui/sidebar";
import {
  Collapsible,
  CollapsibleTrigger,
  CollapsibleContent,
} from "@/components/ui/collapsible";
import {
  Select,
  SelectTrigger,
  SelectContent,
  SelectItem,
  SelectValue,
} from "@/components/ui/select";
import type { ToolSummary } from "@/lib/types";

interface AppSidebarProps {
  tools: ToolSummary[];
  loading: boolean;
  selectedKey: string | null;
  searchQuery: string;
  categoryFilter: string | null;
  onSearchChange: (query: string) => void;
  onCategoryChange: (category: string | null) => void;
  onSelect: (key: string) => void;
}

export function AppSidebar({
  tools,
  loading,
  selectedKey,
  searchQuery,
  categoryFilter,
  onSearchChange,
  onCategoryChange,
  onSelect,
}: AppSidebarProps) {
  // Compute all categories
  const allCategories = useMemo(() => {
    const cats = new Set<string>();
    for (const tool of tools) {
      for (const cat of tool.categories) {
        cats.add(cat);
      }
    }
    return Array.from(cats).sort();
  }, [tools]);

  // Filter tools
  const filteredTools = useMemo(() => {
    let result = tools;
    if (searchQuery) {
      const q = searchQuery.toLowerCase();
      result = result.filter(
        (t) =>
          t.key.toLowerCase().includes(q) ||
          t.title.toLowerCase().includes(q) ||
          t.summary.toLowerCase().includes(q)
      );
    }
    if (categoryFilter) {
      result = result.filter((t) => t.categories.includes(categoryFilter));
    }
    return result;
  }, [tools, searchQuery, categoryFilter]);

  // Group by provider
  const providers = useMemo(() => {
    const map = new Map<string, ToolSummary[]>();
    for (const tool of filteredTools) {
      const list = map.get(tool.provider) ?? [];
      list.push(tool);
      map.set(tool.provider, list);
    }
    return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
  }, [filteredTools]);

  return (
    <Sidebar collapsible="none" className="border-r border-border">
      <SidebarHeader className="gap-2 p-2">
        <SidebarInput
          placeholder="Filter commands..."
          value={searchQuery}
          onChange={(e) => onSearchChange(e.target.value)}
        />
        {allCategories.length > 0 && (
          <Select
            value={categoryFilter ?? ""}
            onValueChange={(v) => onCategoryChange(v || null)}
          >
            <SelectTrigger className="h-7 text-xs">
              <div className="flex items-center gap-1.5">
                <FilterIcon className="size-3 text-muted-foreground" />
                <SelectValue placeholder="All categories" />
              </div>
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="">All categories</SelectItem>
              {allCategories.map((cat) => (
                <SelectItem key={cat} value={cat}>
                  {cat}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        )}
      </SidebarHeader>
      <SidebarContent>
        <SidebarGroup>
          <SidebarGroupContent>
            <SidebarMenu>
              {loading &&
                Array.from({ length: 8 }).map((_, i) => (
                  <SidebarMenuItem key={i}>
                    <SidebarMenuSkeleton />
                  </SidebarMenuItem>
                ))}
              {!loading &&
                providers.map(([provider, items]) => (
                  <ProviderGroup
                    key={provider}
                    provider={provider}
                    items={items}
                    selectedKey={selectedKey}
                    onSelect={onSelect}
                  />
                ))}
              {!loading && providers.length === 0 && (
                <div className="px-3 py-6 text-center text-xs text-muted-foreground">
                  No commands match your filters.
                </div>
              )}
            </SidebarMenu>
          </SidebarGroupContent>
        </SidebarGroup>
      </SidebarContent>
    </Sidebar>
  );
}

function ProviderGroup({
  provider,
  items,
  selectedKey,
  onSelect,
}: {
  provider: string;
  items: ToolSummary[];
  selectedKey: string | null;
  onSelect: (key: string) => void;
}) {
  const [open, setOpen] = useState(true);

  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <SidebarMenuItem>
        <CollapsibleTrigger asChild>
          <SidebarMenuButton className="font-medium">
            <ChevronRightIcon
              className={`size-3 shrink-0 transition-transform duration-150 ${
                open ? "rotate-90" : ""
              }`}
            />
            <span>{provider}</span>
            <SidebarMenuBadge>{items.length}</SidebarMenuBadge>
          </SidebarMenuButton>
        </CollapsibleTrigger>
        <CollapsibleContent>
          <SidebarMenuSub>
            {items.map((tool) => (
              <SidebarMenuSubItem key={tool.key}>
                <SidebarMenuSubButton
                  isActive={tool.key === selectedKey}
                  onClick={() => onSelect(tool.key)}
                  className="transition-colors duration-150"
                >
                  <span className="truncate">{tool.command}</span>
                  <span
                    className="ml-auto size-1.5 rounded-full shrink-0"
                    style={{
                      backgroundColor:
                        tool.mode === "read"
                          ? "var(--success)"
                          : "var(--warning)",
                    }}
                  />
                </SidebarMenuSubButton>
              </SidebarMenuSubItem>
            ))}
          </SidebarMenuSub>
        </CollapsibleContent>
      </SidebarMenuItem>
    </Collapsible>
  );
}
```

**Step 2: Commit**

```bash
git add web/src/components/app-sidebar.tsx
git commit -m "feat(web): add AppSidebar with provider tree and category filter"
```

---

### Task 11: Command Detail Panel

**Files:**
- Create: `web/src/components/command-detail.tsx`

**Step 1: Build the command detail panel**

```tsx
// web/src/components/command-detail.tsx

import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CodeBlock } from "@/components/code-block";
import type { ToolDetail, SecretStatus } from "@/lib/types";

interface CommandDetailProps {
  detail: ToolDetail | null;
  loading: boolean;
  secretsStatus: Record<string, SecretStatus>;
  onSelectCommand: (key: string) => void;
}

export function CommandDetail({
  detail,
  loading,
  secretsStatus,
}: CommandDetailProps) {
  if (loading) {
    return <DetailSkeleton />;
  }

  if (!detail) {
    return <EmptyState />;
  }

  return (
    <ScrollArea className="h-full">
      <div className="px-8 pt-8 pb-6">
        {/* Hero zone */}
        <h1 className="text-2xl font-semibold tracking-tight">
          {detail.title}
        </h1>
        <p className="mt-1 font-mono text-sm text-muted-foreground">
          {detail.key}
        </p>
        <div className="mt-3 flex items-center gap-2 flex-wrap">
          <Badge variant="outline" className="uppercase text-[10px]">
            {detail.protocol}
          </Badge>
          <Badge
            variant="outline"
            className="text-[10px]"
            style={{
              borderLeftWidth: 2,
              borderLeftColor:
                detail.mode === "read"
                  ? "var(--success)"
                  : "var(--warning)",
            }}
          >
            {detail.mode}
          </Badge>
          <Badge variant="outline" className="text-[10px]">
            {detail.source.scope}
          </Badge>
        </div>
        {detail.summary && (
          <p className="mt-4 text-sm text-muted-foreground leading-relaxed">
            {detail.summary}
          </p>
        )}
      </div>

      {/* Description */}
      {detail.description && (
        <div className="px-8 py-6 border-t border-border">
          <div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:font-mono prose-code:text-[12px] prose-a:text-accent prose-a:underline">
            <Markdown remarkPlugins={[remarkGfm]}>
              {detail.description}
            </Markdown>
          </div>
        </div>
      )}

      {/* Parameters */}
      {detail.params.length > 0 && (
        <div className="px-8 py-6 border-t border-border">
          <h2 className="text-[11px] uppercase tracking-wider text-muted-foreground font-medium mb-3">
            Parameters
          </h2>
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead className="text-[11px] uppercase tracking-wider font-medium">
                  Name
                </TableHead>
                <TableHead className="text-[11px] uppercase tracking-wider font-medium">
                  Type
                </TableHead>
                <TableHead className="text-[11px] uppercase tracking-wider font-medium w-4" />
                <TableHead className="text-[11px] uppercase tracking-wider font-medium">
                  Default
                </TableHead>
                <TableHead className="text-[11px] uppercase tracking-wider font-medium">
                  Description
                </TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {detail.params.map((param, i) => (
                <TableRow
                  key={param.name}
                  className={i % 2 === 0 ? "bg-muted/30" : ""}
                >
                  <TableCell className="font-mono text-[13px]">
                    {param.name}
                  </TableCell>
                  <TableCell>
                    <Badge variant="secondary" className="text-[10px]">
                      {param.type}
                    </Badge>
                  </TableCell>
                  <TableCell>
                    {param.required && (
                      <span
                        className="inline-block size-1 rounded-full"
                        style={{ backgroundColor: "var(--accent)" }}
                        title="Required"
                      />
                    )}
                  </TableCell>
                  <TableCell>
                    {param.default !== undefined && (
                      <code className="bg-muted px-1 py-0.5 rounded-sm font-mono text-[12px]">
                        {JSON.stringify(param.default)}
                      </code>
                    )}
                  </TableCell>
                  <TableCell className="text-[13px] text-muted-foreground">
                    {param.description}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </div>
      )}

      {/* Operation */}
      <div className="px-8 py-6 border-t border-border">
        <h2 className="text-[11px] uppercase tracking-wider text-muted-foreground font-medium mb-3">
          Operation
        </h2>
        <OperationCard detail={detail} secretsStatus={secretsStatus} />
      </div>

      {/* Example expression */}
      <div className="px-8 py-6 border-t border-border">
        <h2 className="text-[11px] uppercase tracking-wider text-muted-foreground font-medium mb-3">
          Example
        </h2>
        <CodeBlock label="earl expression">
          {detail.example_expression}
        </CodeBlock>
      </div>

      {/* Source footer */}
      <div className="px-8 py-4 border-t border-border">
        <p className="text-[11px] text-muted-foreground">
          Source: {detail.source.path} ({detail.source.scope})
        </p>
      </div>
    </ScrollArea>
  );
}

function OperationCard({
  detail,
  secretsStatus,
}: {
  detail: ToolDetail;
  secretsStatus: Record<string, SecretStatus>;
}) {
  const op = detail.operation as Record<string, unknown>;
  const protocol = op?.protocol as string;
  const method = (op?.method as string) ?? "";
  const url = (op?.url as string) ?? "";
  const auth = op?.auth as Record<string, unknown> | undefined;

  const methodColors: Record<string, string> = {
    GET: "text-success",
    POST: "text-accent",
    PUT: "text-warning",
    PATCH: "text-warning",
    DELETE: "text-destructive",
  };

  return (
    <Card size="sm">
      <CardContent className="space-y-2">
        {protocol === "http" || protocol === "graphql" ? (
          <>
            <div className="flex items-center gap-2">
              <span
                className={`font-mono font-bold text-sm ${
                  methodColors[method.toUpperCase()] ?? ""
                }`}
              >
                {method.toUpperCase()}
              </span>
              <span className="font-mono text-sm text-muted-foreground truncate">
                {url}
              </span>
            </div>
          </>
        ) : protocol === "cli" ? (
          <div className="font-mono text-sm">
            {(op?.cli as Record<string, unknown>)?.command as string}
          </div>
        ) : (
          <div className="font-mono text-sm">{protocol}</div>
        )}

        {auth && (
          <div className="text-xs text-muted-foreground">
            Auth: {auth.kind as string}
            {auth.secret && (
              <span className="ml-1 font-mono">({auth.secret as string})</span>
            )}
          </div>
        )}

        {detail.secrets.length > 0 && (
          <div className="flex items-center gap-2 flex-wrap">
            <span className="text-xs text-muted-foreground">Secrets:</span>
            {detail.secrets.map((s) => {
              const status = secretsStatus[s];
              return (
                <Badge
                  key={s}
                  variant="outline"
                  className="text-[10px] font-mono"
                  style={{
                    borderLeftWidth: 2,
                    borderLeftColor:
                      status?.configured
                        ? "var(--success)"
                        : "var(--destructive)",
                  }}
                >
                  {s}
                </Badge>
              );
            })}
          </div>
        )}
      </CardContent>
    </Card>
  );
}

function EmptyState() {
  return (
    <div className="flex flex-col items-center justify-center h-full gap-3">
      <span className="text-4xl font-semibold tracking-tight text-foreground/20">
        earl
      </span>
      <p className="text-sm text-muted-foreground">
        Select a command from the sidebar, or press{" "}
        <KbdGroup>
          <Kbd>⌘</Kbd>
          <Kbd>K</Kbd>
        </KbdGroup>
      </p>
    </div>
  );
}

function DetailSkeleton() {
  return (
    <div className="px-8 pt-8 pb-6 space-y-4">
      <Skeleton className="h-7 w-64" />
      <Skeleton className="h-4 w-48" />
      <div className="flex gap-2">
        <Skeleton className="h-5 w-12 rounded-full" />
        <Skeleton className="h-5 w-10 rounded-full" />
        <Skeleton className="h-5 w-12 rounded-full" />
      </div>
      <Skeleton className="h-4 w-full max-w-md" />
      <div className="pt-6 border-t border-border space-y-2">
        <Skeleton className="h-3 w-24" />
        <Skeleton className="h-4 w-full" />
        <Skeleton className="h-4 w-3/4" />
      </div>
    </div>
  );
}
```

**Step 2: Commit**

```bash
git add web/src/components/command-detail.tsx
git commit -m "feat(web): add CommandDetail panel with hero, params table, operation card"
```

---

### Task 12: Try-It Panel -- Form and Expression Modes

**Files:**
- Create: `web/src/components/try-it-panel.tsx`

**Step 1: Build the try-it panel**

```tsx
// web/src/components/try-it-panel.tsx

import { useCallback, useEffect, useRef, useState } from "react";
import {
  PlayIcon,
  HistoryIcon,
  CopyIcon,
  CheckIcon,
  AlertTriangleIcon,
  RotateCcwIcon,
  Loader2Icon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
  Collapsible,
  CollapsibleTrigger,
  CollapsibleContent,
} from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import { CodeBlock } from "@/components/code-block";
import type {
  ToolDetail,
  ParamSpec,
  SecretStatus,
  ValidateResponse,
  ExecuteResponse,
  ApiError,
  HistoryEntry,
} from "@/lib/types";

interface TryItPanelProps {
  detail: ToolDetail | null;
  secretsStatus: Record<string, SecretStatus>;
  formValues: Record<string, unknown>;
  expressionText: string;
  syncSource: "form" | "expression";
  validation: ValidateResponse | null;
  validating: boolean;
  executing: boolean;
  executeSuccess: boolean;
  currentResponse: ExecuteResponse | null;
  currentError: ApiError | null;
  currentDurationMs: number | null;
  history: HistoryEntry[];
  onFormValueChange: (name: string, value: unknown) => void;
  onExpressionChange: (text: string) => void;
  onExecute: (expression: string, confirmWrite: boolean) => void;
  onLoadHistoryEntry: (entry: HistoryEntry) => void;
  onRerun: (expression: string) => void;
}

export function TryItPanel({
  detail,
  secretsStatus,
  formValues,
  expressionText,
  validation,
  validating,
  executing,
  executeSuccess,
  currentResponse,
  currentError,
  currentDurationMs,
  history,
  onFormValueChange,
  onExpressionChange,
  onExecute,
  onLoadHistoryEntry,
  onRerun,
}: TryItPanelProps) {
  if (!detail) {
    return (
      <div className="flex items-center justify-center h-full">
        <p className="text-sm text-muted-foreground">
          Select a command to try it
        </p>
      </div>
    );
  }

  const missingSecrets = detail.secrets.filter(
    (s) => secretsStatus[s] && !secretsStatus[s].configured
  );

  return (
    <ScrollArea className="h-full">
      <div className="p-4 space-y-4">
        {/* Secrets warning */}
        {missingSecrets.length > 0 && (
          <div className="flex items-start gap-2 rounded-lg border border-warning/50 bg-warning/10 p-3">
            <AlertTriangleIcon className="size-4 text-warning shrink-0 mt-0.5" />
            <div className="text-xs space-y-1">
              <p className="font-medium">Missing secrets</p>
              {missingSecrets.map((s) => (
                <p key={s} className="font-mono text-muted-foreground">
                  earl secrets set {s}
                </p>
              ))}
            </div>
          </div>
        )}

        {/* Mode toggle */}
        <Tabs defaultValue="form">
          <TabsList variant="line" className="w-full">
            <TabsTrigger value="form" className="flex-1">
              Form
            </TabsTrigger>
            <TabsTrigger value="expression" className="flex-1">
              Expression
            </TabsTrigger>
          </TabsList>

          <TabsContent value="form" className="space-y-3 pt-3">
            {detail.params.length === 0 ? (
              <p className="text-xs text-muted-foreground">
                This command takes no parameters.
              </p>
            ) : (
              detail.params.map((param) => (
                <ParamInput
                  key={param.name}
                  param={param}
                  value={formValues[param.name]}
                  onChange={(v) => onFormValueChange(param.name, v)}
                />
              ))
            )}
          </TabsContent>

          <TabsContent value="expression" className="space-y-2 pt-3">
            <Textarea
              value={expressionText}
              onChange={(e) => onExpressionChange(e.target.value)}
              className="font-mono text-[13px] leading-[1.6] bg-code-surface min-h-24"
              placeholder="provider.command(arg=value)"
            />
            {validating && (
              <p className="text-[11px] text-muted-foreground flex items-center gap-1">
                <Loader2Icon className="size-3 animate-spin" />
                Validating...
              </p>
            )}
            {validation && !validating && (
              <p
                className={cn(
                  "text-[11px]",
                  validation.valid
                    ? "text-success"
                    : "text-destructive"
                )}
              >
                {validation.valid
                  ? `Valid: ${validation.command_key}`
                  : validation.error?.message}
              </p>
            )}
          </TabsContent>
        </Tabs>

        {/* Execute button */}
        <ExecuteButton
          detail={detail}
          executing={executing}
          executeSuccess={executeSuccess}
          onExecute={() => {
            if (detail.mode === "write") {
              // The AlertDialog handles this
              return;
            }
            onExecute(expressionText, false);
          }}
          onConfirmWrite={() => onExecute(expressionText, true)}
          expressionText={expressionText}
        />

        {/* Response viewer */}
        {(currentResponse || currentError) && (
          <ResponseViewer
            response={currentResponse}
            error={currentError}
            durationMs={currentDurationMs}
          />
        )}

        {/* No response placeholder */}
        {!currentResponse && !currentError && (
          <div className="flex items-center justify-center rounded-lg border border-dashed border-border p-8">
            <p className="text-xs text-muted-foreground">
              Response will appear here
            </p>
          </div>
        )}

        {/* History drawer */}
        {history.length > 0 && (
          <HistoryDrawer
            history={history}
            onLoad={onLoadHistoryEntry}
            onRerun={onRerun}
          />
        )}
      </div>
    </ScrollArea>
  );
}

function ParamInput({
  param,
  value,
  onChange,
}: {
  param: ParamSpec;
  value: unknown;
  onChange: (value: unknown) => void;
}) {
  return (
    <div className="space-y-1">
      <Label className="text-xs">
        {param.name}
        {param.required && <span className="text-destructive ml-0.5">*</span>}
        <Badge variant="ghost" className="ml-1.5 text-[9px]">
          {param.type}
        </Badge>
      </Label>
      {param.type === "boolean" ? (
        <div className="flex items-center gap-2">
          <Checkbox
            checked={value === true}
            onCheckedChange={(checked) => onChange(checked === true)}
          />
          <span className="text-xs text-muted-foreground">
            {value === true ? "true" : "false"}
          </span>
        </div>
      ) : param.type === "array" || param.type === "object" ? (
        <Textarea
          value={typeof value === "string" ? value : JSON.stringify(value ?? (param.type === "array" ? [] : {}), null, 2)}
          onChange={(e) => {
            try {
              onChange(JSON.parse(e.target.value));
            } catch {
              onChange(e.target.value);
            }
          }}
          className="font-mono text-[13px] min-h-16"
          placeholder={param.type === "array" ? "[]" : "{}"}
        />
      ) : param.type === "integer" || param.type === "number" ? (
        <Input
          type="number"
          value={value !== undefined && value !== null ? String(value) : ""}
          onChange={(e) => {
            const v = e.target.value;
            if (v === "") {
              onChange(undefined);
            } else if (param.type === "integer") {
              onChange(parseInt(v, 10));
            } else {
              onChange(parseFloat(v));
            }
          }}
          placeholder={param.default !== undefined ? String(param.default) : ""}
        />
      ) : (
        <Input
          value={value !== undefined && value !== null ? String(value) : ""}
          onChange={(e) => onChange(e.target.value || undefined)}
          placeholder={
            param.default !== undefined ? String(param.default) : ""
          }
        />
      )}
      {param.description && (
        <p className="text-[11px] text-muted-foreground leading-relaxed">
          {param.description}
        </p>
      )}
    </div>
  );
}

function ExecuteButton({
  detail,
  executing,
  executeSuccess,
  onExecute,
  onConfirmWrite,
  expressionText,
}: {
  detail: ToolDetail;
  executing: boolean;
  executeSuccess: boolean;
  onExecute: () => void;
  onConfirmWrite: () => void;
  expressionText: string;
}) {
  const buttonContent = executing ? (
    <>
      <Loader2Icon className="size-3.5 animate-spin" />
      Executing...
    </>
  ) : (
    <>
      <PlayIcon className="size-3.5" />
      Execute
    </>
  );

  const buttonClass = cn(
    "w-full gap-2 transition-colors duration-200",
    executeSuccess && "bg-success text-success-foreground"
  );

  if (detail.mode === "write") {
    return (
      <AlertDialog>
        <AlertDialogTrigger asChild>
          <Button disabled={executing || !expressionText} className={buttonClass}>
            {buttonContent}
          </Button>
        </AlertDialogTrigger>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Confirm write operation</AlertDialogTitle>
            <AlertDialogDescription>
              This command is a write operation and will modify data. Are you
              sure you want to proceed?
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Cancel</AlertDialogCancel>
            <AlertDialogAction onClick={onConfirmWrite}>
              Confirm and Execute
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    );
  }

  return (
    <Button
      onClick={onExecute}
      disabled={executing || !expressionText}
      className={buttonClass}
    >
      {buttonContent}
    </Button>
  );
}

function ResponseViewer({
  response,
  error,
  durationMs,
}: {
  response: ExecuteResponse | null;
  error: ApiError | null;
  durationMs: number | null;
}) {
  const [copied, setCopied] = useState(false);

  const handleCopy = useCallback((text: string) => {
    navigator.clipboard.writeText(text);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }, []);

  if (error) {
    return (
      <div className="space-y-2">
        <div className="flex items-center gap-2">
          <Badge variant="destructive" className="text-[10px]">
            Error
          </Badge>
          {durationMs !== null && (
            <span className="text-[11px] text-muted-foreground">
              {durationMs}ms
            </span>
          )}
        </div>
        <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
          <p className="text-xs font-medium text-destructive">
            {error.error.code}
          </p>
          <p className="text-xs text-muted-foreground mt-1">
            {error.error.message}
          </p>
        </div>
      </div>
    );
  }

  if (!response) return null;

  const isSuccess = response.status >= 200 && response.status < 300;

  return (
    <div className="space-y-2">
      <div className="flex items-center gap-2">
        <Badge
          variant="outline"
          className="text-[10px]"
          style={{
            borderLeftWidth: 2,
            borderLeftColor: isSuccess
              ? "var(--success)"
              : "var(--destructive)",
          }}
        >
          {response.status}
        </Badge>
        {durationMs !== null && (
          <span className="text-[11px] text-muted-foreground">
            {durationMs}ms
          </span>
        )}
      </div>

      <Tabs defaultValue="human">
        <TabsList variant="line" className="w-full">
          <TabsTrigger value="human" className="flex-1 text-[11px]">
            Human
          </TabsTrigger>
          <TabsTrigger value="json" className="flex-1 text-[11px]">
            JSON
          </TabsTrigger>
          <TabsTrigger value="decoded" className="flex-1 text-[11px]">
            Decoded
          </TabsTrigger>
        </TabsList>

        <TabsContent value="human">
          <div className="relative">
            <pre className="rounded-lg border border-border bg-code-surface p-3 font-mono text-[13px] leading-[1.6] overflow-x-auto max-h-80 whitespace-pre-wrap">
              {response.human_output}
            </pre>
            <CopyButton
              onClick={() => handleCopy(response.human_output)}
              copied={copied}
            />
          </div>
        </TabsContent>

        <TabsContent value="json">
          <div className="relative">
            <pre className="rounded-lg border border-border bg-code-surface p-3 font-mono text-[13px] leading-[1.6] overflow-x-auto max-h-80">
              {JSON.stringify(response.result, null, 2)}
            </pre>
            <CopyButton
              onClick={() =>
                handleCopy(JSON.stringify(response.result, null, 2))
              }
              copied={copied}
            />
          </div>
        </TabsContent>

        <TabsContent value="decoded">
          <div className="relative">
            <pre className="rounded-lg border border-border bg-code-surface p-3 font-mono text-[13px] leading-[1.6] overflow-x-auto max-h-80">
              {typeof response.decoded === "string"
                ? response.decoded
                : JSON.stringify(response.decoded, null, 2)}
            </pre>
            <CopyButton
              onClick={() =>
                handleCopy(
                  typeof response.decoded === "string"
                    ? response.decoded
                    : JSON.stringify(response.decoded, null, 2)
                )
              }
              copied={copied}
            />
          </div>
        </TabsContent>
      </Tabs>
    </div>
  );
}

function CopyButton({
  onClick,
  copied,
}: {
  onClick: () => void;
  copied: boolean;
}) {
  return (
    <Button
      variant="ghost"
      size="icon-xs"
      onClick={onClick}
      className="absolute top-1.5 right-1.5"
      aria-label="Copy"
    >
      <span className="relative size-3">
        <CopyIcon
          className={cn(
            "absolute inset-0 size-3 transition-opacity duration-150",
            copied ? "opacity-0" : "opacity-100"
          )}
        />
        <CheckIcon
          className={cn(
            "absolute inset-0 size-3 text-success transition-opacity duration-150",
            copied ? "opacity-100" : "opacity-0"
          )}
        />
      </span>
    </Button>
  );
}

function HistoryDrawer({
  history,
  onLoad,
  onRerun,
}: {
  history: HistoryEntry[];
  onLoad: (entry: HistoryEntry) => void;
  onRerun: (expression: string) => void;
}) {
  const [open, setOpen] = useState(false);

  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <CollapsibleTrigger asChild>
        <Button variant="ghost" size="sm" className="w-full gap-2">
          <HistoryIcon className="size-3" />
          History ({history.length})
        </Button>
      </CollapsibleTrigger>
      <CollapsibleContent>
        <div className="mt-2 space-y-1">
          {history.map((entry, i) => (
            <div
              key={entry.id}
              className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50 cursor-pointer transition-colors duration-150"
              style={{
                animationDelay: `${i * 50}ms`,
              }}
              onClick={() => onLoad(entry)}
            >
              <span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
                {entry.timestamp.toLocaleTimeString([], {
                  hour: "2-digit",
                  minute: "2-digit",
                })}
              </span>
              {entry.response ? (
                <Badge
                  variant="outline"
                  className="text-[9px] shrink-0"
                  style={{
                    borderLeftWidth: 2,
                    borderLeftColor:
                      entry.response.status >= 200 &&
                      entry.response.status < 300
                        ? "var(--success)"
                        : "var(--destructive)",
                  }}
                >
                  {entry.response.status}
                </Badge>
              ) : (
                <Badge variant="destructive" className="text-[9px] shrink-0">
                  ERR
                </Badge>
              )}
              <span className="text-[11px] font-mono truncate flex-1">
                {entry.expression}
              </span>
              <Button
                variant="ghost"
                size="icon-xs"
                onClick={(e) => {
                  e.stopPropagation();
                  onRerun(entry.expression);
                }}
                title="Re-run"
              >
                <RotateCcwIcon className="size-3" />
              </Button>
            </div>
          ))}
        </div>
      </CollapsibleContent>
    </Collapsible>
  );
}
```

**Step 2: Commit**

```bash
git add web/src/components/try-it-panel.tsx
git commit -m "feat(web): add TryItPanel with form, expression, response viewer, and history"
```

---

### Task 13: App Shell -- Wire Everything Together

**Files:**
- Modify: `web/src/app.tsx`

**Step 1: Build the main App component**

```tsx
// web/src/app.tsx

import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { SidebarProvider } from "@/components/ui/sidebar";
import {
  ResizablePanelGroup,
  ResizablePanel,
  ResizableHandle,
} from "@/components/ui/resizable";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Header } from "@/components/header";
import { AppSidebar } from "@/components/app-sidebar";
import { CommandDetail } from "@/components/command-detail";
import { TryItPanel } from "@/components/try-it-panel";
import { CommandPalette } from "@/components/command-palette";
import { api } from "@/lib/api";
import { reducer, initialState } from "@/lib/state";
import { buildExpression } from "@/lib/expression";
import type { ApiError, HistoryEntry } from "@/lib/types";

export default function App() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const [paletteOpen, setPaletteOpen] = useState(false);
  const validateTimerRef = useRef<ReturnType<typeof setTimeout>>();

  // Load catalog on mount
  useEffect(() => {
    api
      .listTools()
      .then((tools) => dispatch({ type: "CATALOG_LOADED", tools }))
      .catch((err) => {
        const message =
          (err as ApiError)?.error?.message ?? "Failed to load commands";
        dispatch({ type: "CATALOG_ERROR", error: message });
        toast.error(message);
      });
  }, []);

  // Handle hash-based routing
  useEffect(() => {
    function handleHash() {
      const hash = window.location.hash.replace(/^#\/?/, "");
      if (hash && hash !== state.selectedKey) {
        selectCommand(hash);
      }
    }
    handleHash();
    window.addEventListener("hashchange", handleHash);
    return () => window.removeEventListener("hashchange", handleHash);
  }, [state.tools]);

  // Select a command
  const selectCommand = useCallback(
    (key: string) => {
      dispatch({ type: "SELECT_COMMAND", key });
      window.location.hash = `#/${key}`;

      // Fetch detail
      api
        .getTool(key)
        .then((detail) => {
          dispatch({ type: "DETAIL_LOADED", detail });

          // Check secrets status
          if (detail.secrets.length > 0) {
            api
              .checkSecrets(detail.secrets)
              .then((status) =>
                dispatch({ type: "SECRETS_STATUS_LOADED", status })
              )
              .catch(() => {});
          }
        })
        .catch((err) => {
          const message =
            (err as ApiError)?.error?.message ?? "Failed to load command";
          toast.error(message);
        });
    },
    []
  );

  // Sync form → expression
  useEffect(() => {
    if (state.syncSource === "form" && state.selectedDetail) {
      const expr = buildExpression(
        state.selectedDetail.key,
        state.selectedDetail.params,
        state.formValues
      );
      dispatch({ type: "SYNC_EXPRESSION_FROM_FORM", expression: expr });
    }
  }, [state.formValues, state.syncSource, state.selectedDetail]);

  // Validate expression (debounced)
  useEffect(() => {
    if (!state.expressionText) return;
    clearTimeout(validateTimerRef.current);
    dispatch({ type: "VALIDATION_START" });
    validateTimerRef.current = setTimeout(() => {
      api
        .validate({ expression: state.expressionText })
        .then((result) => dispatch({ type: "VALIDATION_RESULT", result }))
        .catch(() => {});
    }, 300);
    return () => clearTimeout(validateTimerRef.current);
  }, [state.expressionText]);

  // Execute
  const handleExecute = useCallback(
    (expression: string, confirmWrite: boolean) => {
      dispatch({ type: "EXECUTE_START" });
      const start = performance.now();
      api
        .execute({ expression, confirm_write: confirmWrite })
        .then((response) => {
          const durationMs = Math.round(performance.now() - start);
          dispatch({
            type: "EXECUTE_SUCCESS",
            response,
            expression,
            durationMs,
          });
          setTimeout(() => dispatch({ type: "EXECUTE_FLASH_DONE" }), 200);
        })
        .catch((err) => {
          const durationMs = Math.round(performance.now() - start);
          dispatch({
            type: "EXECUTE_ERROR",
            error: err as ApiError,
            expression,
            durationMs,
          });
        });
    },
    []
  );

  // Keyboard shortcut: Cmd+Enter to execute
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (
        e.key === "Enter" &&
        (e.metaKey || e.ctrlKey) &&
        state.expressionText &&
        !state.executing
      ) {
        e.preventDefault();
        const confirmWrite = state.selectedDetail?.mode === "write";
        if (confirmWrite) {
          // Don't auto-execute writes, user must use the dialog
          return;
        }
        handleExecute(state.expressionText, false);
      }
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [state.expressionText, state.executing, state.selectedDetail, handleExecute]);

  const handleLoadHistory = useCallback((entry: HistoryEntry) => {
    dispatch({ type: "LOAD_HISTORY_ENTRY", entry });
  }, []);

  const handleRerun = useCallback(
    (expression: string) => {
      handleExecute(expression, false);
    },
    [handleExecute]
  );

  return (
    <SidebarProvider defaultOpen>
      <div className="flex flex-col h-screen w-screen overflow-hidden">
        <Header onSearchOpen={() => setPaletteOpen(true)} />
        <CommandPalette
          open={paletteOpen}
          onOpenChange={setPaletteOpen}
          tools={state.tools}
          onSelect={selectCommand}
        />
        <ResizablePanelGroup direction="horizontal" className="flex-1">
          <ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
            <AppSidebar
              tools={state.tools}
              loading={state.catalogLoading}
              selectedKey={state.selectedKey}
              searchQuery={state.searchQuery}
              categoryFilter={state.categoryFilter}
              onSearchChange={(q) =>
                dispatch({ type: "SET_SEARCH_QUERY", query: q })
              }
              onCategoryChange={(c) =>
                dispatch({ type: "SET_CATEGORY_FILTER", category: c })
              }
              onSelect={selectCommand}
            />
          </ResizablePanel>
          <ResizableHandle />
          <ResizablePanel defaultSize={45} minSize={30}>
            <CommandDetail
              detail={state.selectedDetail}
              loading={state.detailLoading}
              secretsStatus={state.secretsStatus}
              onSelectCommand={selectCommand}
            />
          </ResizablePanel>
          <ResizableHandle />
          <ResizablePanel defaultSize={35} minSize={25}>
            <TryItPanel
              detail={state.selectedDetail}
              secretsStatus={state.secretsStatus}
              formValues={state.formValues}
              expressionText={state.expressionText}
              syncSource={state.syncSource}
              validation={state.validation}
              validating={state.validating}
              executing={state.executing}
              executeSuccess={state.executeSuccess}
              currentResponse={state.currentResponse}
              currentError={state.currentError}
              currentDurationMs={state.currentDurationMs}
              history={state.history}
              onFormValueChange={(name, value) =>
                dispatch({ type: "SET_FORM_VALUE", name, value })
              }
              onExpressionChange={(text) =>
                dispatch({ type: "SET_EXPRESSION", text })
              }
              onExecute={handleExecute}
              onLoadHistoryEntry={handleLoadHistory}
              onRerun={handleRerun}
            />
          </ResizablePanel>
        </ResizablePanelGroup>
        <Toaster />
      </div>
    </SidebarProvider>
  );
}
```

**Step 2: Verify the build**

```bash
cd /Users/jrandolf/Sources/earl/web
pnpm build
```

Expected: Build succeeds. Fix any type errors that arise.

**Step 3: Commit**

```bash
git add web/src/app.tsx
git commit -m "feat(web): wire up App shell with all panels, state, and routing"
```

---

### Task 14: Build Verification and Smoke Test

**Step 1: Run the full Rust build**

```bash
cd /Users/jrandolf/Sources/earl
cargo build
```

Expected: Compiles successfully. The web frontend gets embedded into the binary via `include_dir!("$CARGO_MANIFEST_DIR/web/dist")`.

**Step 2: Run Rust tests**

```bash
cargo test
```

Expected: All tests pass including the web module tests.

**Step 3: Launch and smoke test**

```bash
cargo run -- web
```

Open the browser and verify:
- Three-panel layout renders
- Sidebar loads commands from `GET /api/tools`
- Clicking a command shows detail in center panel
- Try-It Panel shows form with param inputs
- Cmd+K opens the command palette
- Execute button makes API calls

**Step 4: Commit any fixes**

```bash
git add -A
git commit -m "fix: resolve build issues from integration"
```

---

### Task 15: Polish and Refinement

**Step 1: Verify the resize handle styling**

In `web/src/components/ui/resizable.tsx`, check that `ResizableHandle` uses a thin 1px hairline. If the default has a visible grip, remove the `withHandle` prop usage and ensure the handle is a subtle `bg-border` 1px line.

**Step 2: Verify dark mode works**

Add `class="dark"` to the `<html>` tag temporarily and verify all color tokens render correctly. Remove after testing.

**Step 3: Test with a large template**

Load the github.hcl template (1,080+ commands) and verify:
- Sidebar renders without jank
- Cmd+K search returns results quickly
- Category filter works across all providers

**Step 4: Final commit**

```bash
git add -A
git commit -m "chore: polish and refinement pass"
```
