Files
cassady-web/src/components/TerminalDemo.tsx
T
2026-06-26 17:50:39 -05:00

400 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
type BlockKind = "user" | "assistant" | "tool" | "toolDone" | "status";
interface Block {
kind: BlockKind;
title: string;
content: string[];
summary?: string;
}
type FooterState = "idle" | "running" | "approval";
interface Footer {
mode: string;
state: FooterState;
model: string;
cwd: string;
id: string;
effort: string;
extra?: string;
}
const DEFAULT_FOOTER: Footer = {
mode: "workspace-edit",
state: "idle",
model: "gpt-5.5",
cwd: "~/cassady",
id: "a1b2c3…d4e5",
effort: "medium",
};
type Event =
| { ev: "wait"; ms: number }
| { ev: "footer"; state?: FooterState; extra?: string }
| { ev: "busy"; busy: boolean }
| { ev: "type"; text: string }
| { ev: "send" }
| { ev: "block"; block: Block }
| { ev: "stream"; text: string }
| { ev: "doneTool"; title: string; summary: string }
| { ev: "resolveApproval" }
| { ev: "print"; text: string }
| { ev: "reset" };
const session: Event[] = [
{ ev: "wait", ms: 600 },
{ ev: "type", text: "add a debounce hook to src/hooks/use-search.ts" },
{ ev: "wait", ms: 350 },
{ ev: "send" },
{ ev: "busy", busy: true },
{ ev: "footer", state: "running" },
{ ev: "wait", ms: 300 },
{ ev: "block", block: { kind: "assistant", title: "cass", content: [""] } },
{ ev: "stream", text: "I'll read the file first, then add the hook." },
{ ev: "block", block: { kind: "tool", title: "read … (call_1)", content: ["file: src/hooks/use-search.ts"] } },
{ ev: "wait", ms: 650 },
{ ev: "doneTool", title: "read ✓ (call_1)", summary: "14 lines · 0.4 KB · tool output hidden" },
{ ev: "block", block: { kind: "tool", title: "edit … (call_2)", content: ["file: src/hooks/use-search.ts", "edits: 1"] } },
{ ev: "wait", ms: 650 },
{ ev: "doneTool", title: "edit ✓ (call_2)", summary: "6 lines · 0.2 KB · tool output hidden" },
{ ev: "block", block: { kind: "assistant", title: "cass", content: [""] } },
{ ev: "stream", text: "Done. Added a 250ms debounce to the search query." },
{ ev: "block", block: { kind: "tool", title: "shell … (call_3)", content: ["command: npm test -- use-search"] } },
{ ev: "block", block: { kind: "status", title: "approval required (call_3)", content: ["shell requires approval before execution.", "", "Press y to approve, n or Esc to deny."] } },
{ ev: "footer", state: "approval", extra: "approval required: press y to approve, n to deny" },
{ ev: "wait", ms: 900 },
{ ev: "resolveApproval" },
{ ev: "footer", state: "running", extra: "approval accepted" },
{ ev: "wait", ms: 700 },
{ ev: "doneTool", title: "shell ✓ (call_3)", summary: "4 lines · 0.2 KB · tool output hidden" },
{ ev: "block", block: { kind: "assistant", title: "cass", content: [""] } },
{ ev: "stream", text: "Tests pass — 3 passed in 0.42s." },
{ ev: "busy", busy: false },
{ ev: "footer", state: "idle", extra: "turn finished" },
{ ev: "wait", ms: 900 },
{ ev: "wait", ms: 3200 },
{ ev: "reset" },
];
export function TerminalDemo() {
const [blocks, setBlocks] = useState<Block[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [footer, setFooter] = useState<Footer>(DEFAULT_FOOTER);
const [printed, setPrinted] = useState<string[]>([]);
const [typing, setTyping] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const live = { current: true };
let timer: ReturnType<typeof setTimeout>;
const delay = (ms: number) =>
new Promise<void>((resolve) => {
timer = setTimeout(() => resolve(), ms);
});
const refs = {
blocks: [] as Block[],
input: "",
busy: false,
footer: { ...DEFAULT_FOOTER },
printed: [] as string[],
};
const commit = () => {
if (!live.current) return;
setBlocks(refs.blocks.slice());
setInput(refs.input);
setBusy(refs.busy);
setFooter({ ...refs.footer });
setPrinted(refs.printed.slice());
};
const appendStream = (text: string) => {
const last = refs.blocks[refs.blocks.length - 1];
if (last && last.kind === "assistant") {
const lines = text.split("\n");
last.content[0] = (last.content[0] || "") + lines[0];
for (let i = 1; i < lines.length; i++) last.content.push(lines[i]);
}
};
const process = async (ev: Event) => {
if (!live.current) return;
switch (ev.ev) {
case "wait":
await delay(ev.ms);
break;
case "footer":
if (ev.state) refs.footer.state = ev.state;
refs.footer.extra = ev.extra;
commit();
break;
case "busy":
refs.busy = ev.busy;
commit();
break;
case "type":
setTyping(true);
for (const ch of ev.text) {
if (!live.current) return;
refs.input += ch;
commit();
await delay(38 + Math.random() * 52);
}
setTyping(false);
break;
case "send":
refs.blocks.push({
kind: "user",
title: "› you",
content: [refs.input],
});
refs.input = "";
commit();
break;
case "block":
refs.blocks.push({ ...ev.block, content: [...ev.block.content] });
commit();
break;
case "stream":
for (let i = 0; i < ev.text.length; i++) {
if (!live.current) return;
appendStream(ev.text[i]);
commit();
await delay(14 + Math.random() * 22);
}
await delay(160);
break;
case "doneTool":
if (refs.blocks.length) {
const last = refs.blocks[refs.blocks.length - 1];
if (last && (last.kind === "tool" || last.kind === "status")) {
refs.blocks[refs.blocks.length - 1] = {
kind: "toolDone",
title: ev.title,
content: [],
summary: ev.summary,
};
}
}
commit();
break;
case "resolveApproval":
// drop a trailing status (approval) block if present
const last = refs.blocks[refs.blocks.length - 1];
if (last && last.kind === "status") refs.blocks.pop();
commit();
break;
case "print":
refs.printed.push(ev.text);
commit();
break;
case "reset":
refs.blocks = [];
refs.input = "";
refs.busy = false;
refs.footer = { ...DEFAULT_FOOTER };
refs.printed = [];
commit();
await delay(400);
break;
}
};
const run = async () => {
while (live.current) {
for (const ev of session) {
if (!live.current) return;
await process(ev);
}
}
};
run();
return () => {
live.current = false;
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
}
}, [blocks, input, busy, footer, printed, typing]);
const stateColor =
footer.state === "running"
? "text-[var(--color-accent)]"
: footer.state === "approval"
? "text-[var(--color-amber)]"
: "text-[var(--color-fg-dim)]";
const stateLabel =
footer.state === "approval" ? "approval required" : footer.state;
return (
<motion.section
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="mx-auto max-w-4xl px-6 py-16"
>
<div className="terminal-shadow overflow-hidden rounded-md border border-[var(--color-line)] bg-[var(--color-bg)]/95">
<div className="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-bg-soft)]/80 px-4 py-2.5">
<span className="h-2.5 w-2.5 rounded-full bg-[#3a3f47]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#3a3f47]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#3a3f47]" />
<span className="ml-3 font-mono text-xs text-[var(--color-fg-dim)]">
cass — workspace-edit — 96×28
</span>
</div>
<div
ref={bodyRef}
className="relative h-[380px] overflow-y-auto px-5 py-4 font-mono text-[13px] leading-relaxed"
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"repeating-linear-gradient(to bottom, transparent 0, transparent 2px, #fff 2px, #fff 3px)",
}}
/>
{blocks.length === 0 && printed.length === 0 && (
<div className="text-[var(--color-fg-dim)]">
Ask a question or request a code change.
</div>
)}
{blocks.map((b, i) => (
<BlockView key={i} block={b} />
))}
{printed.map((p, i) => (
<div key={`p-${i}`} className="mt-2 text-[var(--color-fg-muted)]">
{p}
</div>
))}
<div className="mt-1 flex items-start gap-1">
<span className={cn("shrink-0", busy ? "text-[var(--color-accent)]" : "text-[var(--color-accent)]")}>
{busy ? "…" : "›"}
</span>
<span className="text-[var(--color-fg)]">
{input}
{typing && (
<span className="cursor-blink ml-px inline-block h-[1.05em] w-[0.55em] translate-y-[0.18em] bg-[var(--color-accent)]" />
)}
</span>
{!typing && !busy && blocks.length > 0 && (
<span className="cursor-blink ml-px inline-block h-[1.05em] w-[0.55em] translate-y-[0.18em] bg-[var(--color-fg-dim)]" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 border-t border-[var(--color-line)] bg-[var(--color-bg-soft)]/60 px-5 py-2 font-mono text-[11px] text-[var(--color-fg-dim)]">
<span>cass</span>
<span>·</span>
<span>{footer.mode}</span>
<span>·</span>
<span className={stateColor}>{stateLabel}</span>
<span>·</span>
<span>{footer.model}</span>
<span>·</span>
<span>{footer.cwd}</span>
<span>·</span>
<span>{footer.id}</span>
<span>·</span>
<span>reasoning:{footer.effort}</span>
{footer.extra && (
<>
<span>·</span>
<span className="text-[var(--color-fg-dim)]">{footer.extra}</span>
</>
)}
</div>
</div>
<p className="mt-4 text-center font-mono text-xs text-[var(--color-fg-dim)]">
a real Cassady session — animated, loops on replay
</p>
</motion.section>
);
}
function BlockView({ block }: { block: Block }) {
switch (block.kind) {
case "user":
return (
<div className="mb-2">
<div className="text-[var(--color-fg)]">
<span className="text-[var(--color-accent)]">›</span> you
</div>
{block.content.map((line, i) => (
<div key={i} className="whitespace-pre-wrap text-[var(--color-fg-muted)]">
{" "}{line}
</div>
))}
</div>
);
case "assistant":
return (
<div className="mb-2">
<div className="text-[var(--color-accent)]">cass</div>
{block.content.map((line, i) => (
<div key={i} className="whitespace-pre-wrap text-[var(--color-fg)]">
{" "}{line}
</div>
))}
</div>
);
case "tool":
return (
<div className="mb-2">
<div className="text-[var(--color-fg-dim)]">· {block.title}</div>
{block.content.map((line, i) => (
<div key={i} className="whitespace-pre-wrap text-[var(--color-fg-dim)]">
{" "}{line}
</div>
))}
</div>
);
case "toolDone": {
const title = block.title;
const checkIdx = title.indexOf("✓");
return (
<div className="mb-2">
<div className="text-[var(--color-fg-dim)]">
· {title.slice(0, checkIdx)}
<span className="text-[var(--color-accent)]">✓</span>
{title.slice(checkIdx + 1)}
<span> · {block.summary}</span>
</div>
</div>
);
}
case "status":
return (
<div className="mb-2">
<div className="text-[var(--color-amber)]">· {block.title}</div>
{block.content.map((line, i) => (
<div key={i} className="whitespace-pre-wrap text-[var(--color-fg-dim)]">
{" "}{line}
</div>
))}
</div>
);
}
}