400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
}
|