Skip to content
Merged
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# t3type

A typing game that looks like T3 Code. You type prompts to coding agents as
Theo, Julius and Maria. The agents take every typo literally.
Theo, Julius and Maria.

```bash
npm install
Expand All @@ -25,11 +25,13 @@ every push and pull request.

## How to play

- The next prompt shows as gray ghost text in the composer. Type over it. Enter sends, typos included.
- The next prompt shows as gray ghost text in the composer. Type over it. Wrong keys show in red
until you backspace and fix them.
- When every character is right, the prompt turns green and locks. Press Enter to send it.
- While an agent works, switch to another thread: Tab jumps to the next thread waiting on you,
Shift+Tab goes back, and Ctrl+Shift+] / Ctrl+Shift+[ (Cmd on macOS) step through the sidebar
like T3 Code's thread.next and thread.previous.
- Every prompt is its own WPM test, timed from its first key to Enter. Time spent in other
- Every prompt is its own WPM test, timed from its first key to its last. Time spent in other
threads does not count. Each sent message shows its WPM and accuracy.
- A session ends when every thread is done.

Expand Down
28 changes: 3 additions & 25 deletions src/components/EndSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import { CircleCheckIcon, ClockIcon, KeyboardIcon, RotateCcwIcon, SpellCheckIcon } from "lucide-react";
import { CircleCheckIcon, ClockIcon, KeyboardIcon, RotateCcwIcon } from "lucide-react";
import { computeStats, restart, type GameState } from "~/game/store";
import { formatDuration } from "~/lib/time";
import { ChatMarkdown } from "./chat/MessagesTimeline";
import { Button } from "./ui/button";

function verdict(stats: ReturnType<typeof computeStats>): string {
if (stats.promptsSent === 0) return "No prompts sent. The agents enjoyed the quiet.";
if (stats.typosLiteral === 0) return "Every prompt was clean. The agents had nothing to misread, which they found disappointing.";
if (stats.typosLiteral <= 3) return `The agents took ${stats.typosLiteral === 1 ? "one typo" : `${stats.typosLiteral} typos`} literally. Nightly will be fine. Probably.`;
return `The agents took ${stats.typosLiteral} typos literally and shipped all of them.`;
return `You sent ${stats.promptsSent === 1 ? "one clean prompt" : `${stats.promptsSent} clean prompts`}.`;
}

export function EndSummary({ game }: { game: GameState }) {
const stats = computeStats(game);
const literal = game.threads
.flatMap((thread) => thread.turns.flatMap((turn) => turn.diff.typos.map((typo) => ({ typo, thread }))))
.slice(0, 8);
const rows = [
{ label: "Prompts sent", value: String(stats.promptsSent), key: "prompts" },
{ label: "Average speed", value: `${stats.wpm} wpm`, key: "wpm" },
Expand Down Expand Up @@ -60,29 +55,12 @@ export function EndSummary({ game }: { game: GameState }) {
))}
</div>
</div>
{literal.length > 0 ? (
<div className="mt-4">
<p className="px-0.5 pb-1 text-xs font-medium text-muted-foreground">What the agents read</p>
{literal.map(({ typo, thread }, index) => (
<div key={index} className="flex min-h-6 min-w-0 items-center gap-1.5 px-0.5 text-sm leading-relaxed">
<span className="flex w-6 shrink-0 justify-center">
<SpellCheckIcon className="size-4 text-icon-muted" />
</span>
<span className="min-w-0 flex-1 truncate text-secondary-label">
<span className="font-mono text-[0.92em] text-error-foreground">{typo.typed}</span> instead of{" "}
<span className="font-mono text-[0.92em]">{typo.target}</span>
<span className="text-muted-foreground/70"> · {thread.def.title}</span>
</span>
</div>
))}
</div>
) : null}
<p className="mt-4 flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<KeyboardIcon className="size-3.5" /> Enter plays again
</span>
<span className="inline-flex items-center gap-1">
<ClockIcon className="size-3.5" /> Each prompt is timed from its first key to Enter
<ClockIcon className="size-3.5" /> Each prompt is timed from its first key to its last
</span>
</p>
</div>
Expand Down
40 changes: 32 additions & 8 deletions src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AArrowDownIcon, AArrowUpIcon, FolderIcon, GitBranchIcon, PaperclipIcon } from "lucide-react";
import { AArrowDownIcon, AArrowUpIcon, CircleCheckIcon, FolderIcon, GitBranchIcon, PaperclipIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { personOf, sourceAuthor, threadBranch, type PromptDef } from "~/content/prompts";
import { isWorking, nextPrompt, type GameState, type ThreadState } from "~/game/store";
import { isDraftComplete, isWorking, nextPrompt, type GameState, type ThreadState } from "~/game/store";
import {
COMPOSER_FONT_MAX,
COMPOSER_FONT_MIN,
Expand Down Expand Up @@ -56,7 +56,17 @@ function Main({ className, ...props }: ComponentProps<"div">) {
);
}

function GhostEditor({ draft, target, locked }: { draft: string; target: string; locked: boolean }) {
function GhostEditor({
draft,
target,
locked,
complete,
}: {
draft: string;
target: string;
locked: boolean;
complete: boolean;
}) {
const typed = Array.from(draft);
const goal = Array.from(target);
const fontPx = useComposerFont();
Expand All @@ -65,6 +75,7 @@ function GhostEditor({ draft, target, locked }: { draft: string; target: string;
className="relative flow-root font-sans"
style={{ fontSize: fontPx }}
data-testid="composer-editor-root"
data-complete={complete || undefined}
>
<div
role="textbox"
Expand All @@ -73,7 +84,10 @@ function GhostEditor({ draft, target, locked }: { draft: string; target: string;
aria-placeholder={target}
data-testid="composer-editor"
tabIndex={0}
className="composer-tiptap -m-1 block max-h-[8lh] min-h-[calc(2lh+--spacing(2))] overflow-y-auto p-1 whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none"
className={cn(
"composer-tiptap -m-1 block max-h-[8lh] min-h-[calc(2lh+--spacing(2))] overflow-y-auto p-1 whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed focus:outline-none",
complete ? "text-success-foreground" : "text-foreground",
)}
>
{typed.map((ch, index) => {
const ok = goal[index] === ch;
Expand All @@ -94,7 +108,7 @@ function GhostEditor({ draft, target, locked }: { draft: string; target: string;
</span>
);
})}
{locked ? null : <span className="t3type-caret" aria-hidden />}
{locked || complete ? null : <span className="t3type-caret" aria-hidden />}
<span className="text-placeholder" data-ghost>
{goal.slice(typed.length).join("")}
</span>
Expand Down Expand Up @@ -131,6 +145,15 @@ function PromptKindLabel({ thread, prompt }: { thread: ThreadState; prompt: Prom
);
}

function DoneLabel() {
return (
<span className="inline-flex min-w-0 items-center gap-1 truncate text-xs text-success-foreground" data-prompt-done>
<CircleCheckIcon className="size-3.5 shrink-0" />
Done. Press Enter to send.
</span>
);
}

function FontSizeControl() {
const fontPx = useComposerFont();
return (
Expand Down Expand Up @@ -238,6 +261,7 @@ export function ChatComposer({ game, thread }: { game: GameState; thread: Thread
const prompt = nextPrompt(thread);
const ended = game.session.status === "ended";
const locked = working || !prompt || ended;
const complete = !locked && isDraftComplete(thread);
const placeholder = ended
? "Session over. Press Enter to play again."
: working
Expand All @@ -259,7 +283,7 @@ export function ChatComposer({ game, thread }: { game: GameState; thread: Thread
<div data-chat-composer-surface="true" className="rounded-2xl transition-[background-color] duration-200">
<div data-chat-composer-body="true" className="relative px-4 pb-2 pt-3.5 sm:pt-4">
<div className="relative">
<GhostEditor draft={locked ? "" : thread.draft} target={placeholder} locked={locked} />
<GhostEditor draft={locked ? "" : thread.draft} target={placeholder} locked={locked} complete={complete} />
</div>
</div>
<div
Expand All @@ -277,7 +301,7 @@ export function ChatComposer({ game, thread }: { game: GameState; thread: Thread
>
<PaperclipIcon className="translate-y-px" />
</Button>
{prompt && !locked ? <PromptKindLabel thread={thread} prompt={prompt} /> : null}
{complete ? <DoneLabel /> : prompt && !locked ? <PromptKindLabel thread={thread} prompt={prompt} /> : null}
</div>
<div
data-chat-composer-actions="right"
Expand All @@ -290,7 +314,7 @@ export function ChatComposer({ game, thread }: { game: GameState; thread: Thread
<FontSizeControl />
<ProviderModelPicker thread={thread.def} />
</div>
{working ? <StopButton /> : <SendButton enabled={!locked && thread.draft.trim().length > 0} />}
{working ? <StopButton /> : <SendButton enabled={complete} />}
</div>
</div>
</div>
Expand Down
31 changes: 3 additions & 28 deletions src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,29 +52,6 @@ export function ChatMarkdown({ text, className }: { text: string; className?: st
);
}

function TypedWithTypos({ turn }: { turn: Turn }) {
const typoWords = new Set(turn.diff.typos.map((typo) => typo.typed.toLowerCase()));
const pieces = turn.typed.split(/(\s+)/);
return (
<>
{pieces.map((piece, index) => {
const bare = piece.replace(/^[^\p{L}\p{N}@`$/._-]+|[^\p{L}\p{N}`/_-]+$/gu, "").toLowerCase();
return typoWords.has(bare) && bare.length > 0 ? (
<span
key={index}
className="underline decoration-error-foreground/70 decoration-wavy decoration-1 underline-offset-[3px]"
title="The agent took this literally"
>
{piece}
</span>
) : (
<Fragment key={index}>{piece}</Fragment>
);
})}
</>
);
}

function PromptSource({ turn, thread }: { turn: Turn; thread: ThreadState }) {
const prompt = turn.prompt;
const person = personOf(thread.def.persona);
Expand Down Expand Up @@ -114,9 +91,7 @@ function UserMessageRow({ turn, thread }: { turn: Turn; thread: ThreadState }) {
<div className="relative min-w-0 max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">
<h3 className="sr-only select-none">You</h3>
<div className="chat-markdown w-full min-w-0 text-sm leading-relaxed whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
<p>
<TypedWithTypos turn={turn} />
</p>
<p>{turn.prompt.text}</p>
</div>
</div>
<div className="flex max-w-[80%] flex-wrap items-baseline justify-end gap-x-2">
Expand Down Expand Up @@ -479,7 +454,7 @@ function TurnRows({
) : null}
</div>
) : null}
<div className="group/assistant pb-4" data-assistant-message data-literal={turn.reply.literal || undefined}>
<div className="group/assistant pb-4" data-assistant-message>
<div className="relative min-w-0 px-1 py-0.5">
<h3 className="sr-only select-none">T3 Code</h3>
<ChatMarkdown text={turn.reply.text} />
Expand All @@ -501,7 +476,7 @@ export function MessagesTimeline({ thread, now }: { thread: ThreadState; now: nu
<div className="flex h-full flex-col items-center justify-center gap-1 pb-40">
<p className="text-sm text-muted-foreground">Send a message to start the conversation.</p>
<p className="text-xs text-muted-foreground/70">
Type the gray prompt below. Enter sends it, typos included.
Type the gray prompt below. Fix every red letter, then press Enter.
</p>
</div>
);
Expand Down
25 changes: 25 additions & 0 deletions src/game/reply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { LineCounts, ScriptedReply, ToolRow } from "~/content/prompts";

export interface AgentReply extends ScriptedReply {
files: { path: string; additions: number; deletions: number }[];
}

function filesFor(tools: ToolRow[], diff: LineCounts | null) {
if (!diff) return [];
const paths = [...new Set(tools.filter(([type]) => type === "edit").map(([, label]) => label))];
if (paths.length === 0) return [];
let addLeft = diff[0];
let delLeft = diff[1];
return paths.map((path, index) => {
const last = index === paths.length - 1;
const additions = last ? addLeft : Math.ceil(diff[0] / paths.length);
const deletions = last ? delLeft : Math.ceil(diff[1] / paths.length);
addLeft -= additions;
delLeft -= deletions;
return { path, additions: Math.max(0, additions), deletions: Math.max(0, deletions) };
});
}

export function buildReply(scripted: ScriptedReply): AgentReply {
return { ...scripted, files: filesFor(scripted.tools, scripted.diff) };
}
Loading
Loading