Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,18 +171,59 @@ On startup we scan `PATH` (including `~/.local/bin`, `~/.bun/bin`, `/opt/homebre
| Agent | Detection binary | Invocation |
|---|---|---|
| **Claude Code** | `claude` | `claude -p --output-format stream-json` |
| **OpenClaw** | `openclaw` | `openclaw agent --local --json --agent <id> --message <prompt>` |
| **OpenAI Codex** | `codex` | `codex exec --json --sandbox workspace-write` |
| **Cursor Agent** | `cursor-agent` | `cursor-agent --print --output-format stream-json --force --trust` |
| **Gemini CLI** | `gemini` | `gemini --output-format stream-json --yolo` |
| **GitHub Copilot CLI** | `copilot` | `copilot --allow-all-tools --output-format json` |
| **OpenCode** | `opencode-cli` / `opencode` | `opencode run --format json --dangerously-skip-permissions -` |
| **Qwen Coder** | `qwen` | `qwen --yolo -` |
| **Qoder CLI** | `qodercli` | `qodercli -p --output-format stream-json --yolo` |
| **DeepSeek TUI** | `deepseek` | `deepseek exec --auto <prompt>` |
| **Aider** | `aider` | `aider --no-pretty --no-stream --yes-always --message-file -` |

> The detection strategy and per-CLI adapter shape are borrowed directly from [`nexu-io/open-design`](https://github.com/nexu-io/open-design) and [`multica-ai/multica`](https://github.com/multica-ai/multica): one privileged process spawns CLIs, JSON-line is the wire protocol, every CLI gets a thin adapter in [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts).

If you've already done `claude login` / `cursor login` / `gemini auth` in your terminal, HTML Anything reuses that session. **No second copy of the API key required.**

### Advanced agent / model configuration

HTML Anything keeps a curated model picker per agent, but you can extend it without editing source:

```bash
# Add models globally, or only for one agent. Use "id=Label" for prettier names.
HTML_ANYTHING_MODELS="my-fast-model=My Fast Model,provider/custom-model"
HTML_ANYTHING_MODELS_CODEX="gpt-5.2,gpt-5.3-codex=Codex 5.3"

# Add fallback binary names for an existing adapter.
HTML_ANYTHING_BINS_CODEX="codex-nightly,openai-codex"

# Override a binary. Values can be absolute paths or names resolvable on PATH.
HTML_ANYTHING_BIN_CODEX="/opt/homebrew/bin/codex"
CODEX_BIN="codex-next"

# Optional proxy for agent subprocesses. It is never enabled by default.
HTML_ANYTHING_AGENT_PROXY="http://127.0.0.1:7890"
HTML_ANYTHING_AGENT_PROXY_CODEX="none" # explicitly strip proxy env for Codex
```

You can also register a custom agent that reuses an existing adapter's invocation/parser:

```bash
HTML_ANYTHING_EXTRA_AGENTS='[
{
"id": "codex-nightly",
"label": "Codex Nightly",
"vendor": "OpenAI",
"bin": "codex-nightly",
"adapter": "codex",
"models": ["default", "gpt-5.3-codex=GPT-5.3 Codex"]
}
]'
```

`adapter` must point at one of the implemented adapters (`claude`, `openclaw`, `codex`, `cursor-agent`, `gemini`, `copilot`, `opencode`, `qwen`, `qoder`, `deepseek`, `aider`). ACP / pi-rpc agents are detected for visibility, but are not invokable until their protocol adapters land.

## Skills

**75 skills under [`src/lib/templates/skills/`](src/lib/templates/skills/)**, each a folder following the Claude Code [`SKILL.md`](https://docs.anthropic.com/en/docs/claude-code/skills) convention plus an extended frontmatter (`mode` · `scenario` · `surface` · `preview` · `design_system`).
Expand Down
9 changes: 8 additions & 1 deletion src/app/api/convert/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,14 @@ export async function POST(req: NextRequest) {
format,
});
} else {
prompt = assemblePrompt({ body: skill.body, content, format });
prompt = assemblePrompt({
body: skill.body,
content,
format,
exampleHtml: skill.exampleHtml,
exampleName: skill.example?.name,
templateName: skill.zhName,
});
}
const abortCtl = new AbortController();
req.signal?.addEventListener("abort", () => abortCtl.abort(), { once: true });
Expand Down
Binary file added src/app/fonts/inter-latin.woff2
Binary file not shown.
Binary file added src/app/fonts/inter-tight-latin.woff2
Binary file not shown.
Binary file added src/app/fonts/jetbrains-mono-latin.woff2
Binary file not shown.
Binary file not shown.
Binary file added src/app/fonts/playfair-display-latin.woff2
Binary file not shown.
2 changes: 1 addition & 1 deletion src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@

html, body {
height: 100%;
font-family: var(--font-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: var(--font-sans), -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "Noto Sans SC", "PingFang SC", sans-serif;
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
Expand Down
41 changes: 27 additions & 14 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,43 @@
import type { Metadata } from "next";
import { Inter, Inter_Tight, Playfair_Display, JetBrains_Mono } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";

const inter = Inter({
const inter = localFont({
src: "./fonts/inter-latin.woff2",
variable: "--font-sans",
subsets: ["latin"],
weight: ["300", "400", "500", "600"],
weight: "300 600",
display: "swap",
});

const interTight = Inter_Tight({
const interTight = localFont({
src: "./fonts/inter-tight-latin.woff2",
variable: "--font-display",
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800", "900"],
weight: "400 900",
display: "swap",
});

const playfair = Playfair_Display({
const playfair = localFont({
src: [
{
path: "./fonts/playfair-display-latin.woff2",
weight: "400 700",
style: "normal",
},
{
path: "./fonts/playfair-display-italic-latin.woff2",
weight: "400 700",
style: "italic",
},
],
variable: "--font-serif",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
style: ["italic", "normal"],
display: "swap",
});

const mono = JetBrains_Mono({
const mono = localFont({
src: "./fonts/jetbrains-mono-latin.woff2",
variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500"],
weight: "400 500",
display: "swap",
});

export const metadata: Metadata = {
Expand Down
124 changes: 121 additions & 3 deletions src/components/preview-pane.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore, selectActiveTask, type LogEntry, type RunStats } from "@/lib/store";
import { useStore, selectActiveTask, type LogEntry, type RunStats, type ResultSnapshot } from "@/lib/store";
import { useT, type DictKey } from "@/lib/i18n";
import { previewHtml, extractHtml } from "@/lib/extract-html";
import { isDeck } from "@/lib/deck";
import { DeckViewer } from "./deck-viewer";

type PreviewTab = "preview" | "deck" | "code" | "log";
type PreviewTab = "preview" | "deck" | "code" | "log" | "history";

const PREVIEW_TAB_KEY: Record<PreviewTab, DictKey> = {
preview: "preview.tab.preview",
deck: "preview.tab.deck",
code: "preview.tab.code",
log: "preview.tab.log",
history: "preview.tab.history",
};

const STATUS_KEY: Record<string, DictKey> = {
Expand All @@ -37,6 +38,7 @@ const CHIP_KEYS: DictKey[] = [
// stable references so the selector doesn't force re-render when active task is missing
const EMPTY_LOG: LogEntry[] = [];
const EMPTY_STATS: RunStats = { outputBytes: 0, deltaCount: 0 };
const EMPTY_RESULTS: ResultSnapshot[] = [];

export function PreviewPane({
iframeRef,
Expand All @@ -47,9 +49,12 @@ export function PreviewPane({
const status = useStore((s) => selectActiveTask(s)?.status ?? "idle");
const log = useStore((s) => selectActiveTask(s)?.log ?? EMPTY_LOG);
const stats = useStore((s) => selectActiveTask(s)?.stats ?? EMPTY_STATS);
const results = useStore((s) => selectActiveTask(s)?.results ?? EMPTY_RESULTS);
const activeTaskId = useStore((s) => s.activeTaskId);
const templateId = useStore((s) => selectActiveTask(s)?.templateId);
const setHtmlFor = useStore((s) => s.setHtmlFor);
const restoreResultFor = useStore((s) => s.restoreResultFor);
const deleteResultFor = useStore((s) => s.deleteResultFor);

// Template example HTML — fetched lazily when no task.html exists yet, so
// switching templates in the picker shows that template's pre-shipped
Expand Down Expand Up @@ -212,7 +217,7 @@ export function PreviewPane({
style={{ borderBottom: "1px solid var(--line-faint)", background: "var(--surface)" }}
>
<div className="flex gap-1">
{(["preview", ...(deckMode ? (["deck"] as const) : []), "code", "log"] as const).map((id) => (
{(["preview", ...(deckMode ? (["deck"] as const) : []), "code", "log", "history"] as const).map((id) => (
<button
key={id}
onClick={() => setTab(id)}
Expand All @@ -231,6 +236,17 @@ export function PreviewPane({
{log.length}
</span>
)}
{id === "history" && results.length > 0 && (
<span
className="ml-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold"
style={{
background: tab === "history" ? "rgba(255,255,255,0.18)" : "rgba(31,122,58,0.12)",
color: tab === "history" ? "#fff" : "var(--green)",
}}
>
{results.length}
</span>
)}
</button>
))}
</div>
Expand Down Expand Up @@ -340,6 +356,17 @@ export function PreviewPane({
{tab === "log" && (
<LogPanel logRef={logRef} log={log} />
)}
{tab === "history" && (
<HistoryPanel
results={results}
currentHtml={html}
onRestore={(resultId) => {
restoreResultFor(activeTaskId, resultId);
setTab("preview");
}}
onDelete={(resultId) => deleteResultFor(activeTaskId, resultId)}
/>
)}

{/* Fullscreen exit chip — only shown when this pane owns the fullscreen.
Deck mode is excluded because DeckViewer manages its own fullscreen
Expand Down Expand Up @@ -509,6 +536,97 @@ function LogPanel({
);
}

function HistoryPanel({
results,
currentHtml,
onRestore,
onDelete,
}: {
results: ResultSnapshot[];
currentHtml: string;
onRestore: (resultId: string) => void;
onDelete: (resultId: string) => void;
}) {
const t = useT();
if (results.length === 0) {
return (
<div className="flex h-full items-center justify-center p-6 text-center text-[12px] leading-relaxed text-[var(--ink-faint)]" style={{ background: "var(--paper)" }}>
<div className="max-w-sm">{t("preview.history.empty")}</div>
</div>
);
}
return (
<div className="h-full overflow-auto p-3" style={{ background: "var(--paper)" }}>
<div className="space-y-2">
{results.map((result) => {
const isCurrent = currentHtml.trim() === result.html.trim();
const size = `${(result.html.length / 1024).toFixed(1)} KB`;
const date = new Date(result.createdAt).toLocaleString();
return (
<div
key={result.id}
className="rounded-xl p-3"
style={{
background: "var(--surface)",
border: isCurrent ? "1px solid rgba(31,122,58,0.40)" : "1px solid var(--line-faint)",
}}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<div className="truncate text-[12.5px] font-semibold text-[var(--ink)]">{result.name}</div>
{isCurrent && (
<span className="rounded-full px-1.5 py-px text-[9px] font-semibold uppercase tracking-[0.12em]" style={{ background: "rgba(31,122,58,0.12)", color: "var(--green)" }}>
{t("preview.history.current")}
</span>
)}
</div>
<div className="mt-1 text-[10.5px] text-[var(--ink-faint)]">
{t("preview.history.meta", {
agent: result.model ? `${result.agent} / ${result.model}` : result.agent,
size,
date,
})}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => onRestore(result.id)}
className="rounded-full px-2 py-1 text-[10.5px] font-medium"
style={{
background: "var(--coral-soft)",
color: "var(--coral)",
border: "1px solid rgba(207,96,60,0.18)",
}}
>
{t("preview.history.restore")}
</button>
<button
onClick={() => onDelete(result.id)}
className="grid h-6 w-6 place-items-center rounded-full text-[12px]"
title={t("preview.history.delete")}
aria-label={t("preview.history.delete")}
style={{
background: "transparent",
color: "var(--ink-faint)",
border: "1px solid var(--line)",
}}
>
×
</button>
</div>
</div>
<pre className="mt-2 line-clamp-3 whitespace-pre-wrap break-words rounded-lg p-2 text-[10px] leading-snug text-[var(--ink-mute)]" style={{ background: "rgba(21,20,15,0.035)" }}>
{result.content.replace(/\s+/g, " ").trim() || result.html.slice(0, 180)}
</pre>
</div>
);
})}
</div>
</div>
);
}

function LogLine({ entry }: { entry: LogEntry }) {
const colorMap: Record<string, { tag: string; bg: string; fg: string; text: string }> = {
info: { tag: "INFO", bg: "rgba(35,72,184,0.10)", fg: "var(--blue)", text: "var(--ink)" },
Expand Down
11 changes: 11 additions & 0 deletions src/components/tasks-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export function TasksSidebar() {
? tasks.filter((task) => {
if (task.name.toLowerCase().includes(trimmed)) return true;
if (task.content.toLowerCase().includes(trimmed)) return true;
if (task.html.toLowerCase().includes(trimmed)) return true;
if ((task.results ?? []).some((result) => result.html.toLowerCase().includes(trimmed))) return true;
const tpl = templates?.find((x) => x.id === task.templateId);
if (tpl) {
if (tpl.zhName.toLowerCase().includes(trimmed)) return true;
Expand Down Expand Up @@ -283,6 +285,7 @@ function TaskCard({

const preview = task.content.replace(/\s+/g, " ").trim().slice(0, 64);
const sizeKB = task.html ? `${(task.html.length / 1024).toFixed(1)} KB` : null;
const resultCount = task.results?.length ?? 0;

return (
<div
Expand Down Expand Up @@ -343,6 +346,14 @@ function TaskCard({
<span className="font-mono tabular-nums text-[var(--ink-faint)]">{sizeKB}</span>
</>
)}
{resultCount > 0 && (
<>
<span className="text-[var(--ink-faint)]">·</span>
<span className="rounded-full px-1.5 py-px text-[9.5px] font-medium text-[var(--green)]" style={{ background: "rgba(31,122,58,0.10)" }}>
{t("tasks.resultCount", { n: resultCount })}
</span>
</>
)}
</div>
<div className="mt-1 line-clamp-2 text-[11px] leading-snug text-[var(--ink-mute)]">
{preview || <span className="italic text-[var(--ink-faint)]">{t("tasks.emptyContent")}</span>}
Expand Down
Loading