Skip to content
Notra logoNotra UI
Esc
↑↓navigate↵open⌘Jpreview
On this page

Codex

A full recreation of an OpenAI Codex CLI session, from the banner to the pinned prompt.

A rebuild of a session in OpenAI’s Codex CLI, composed from the Codex components. It has the banner and greeting, a banded user turn, a Ran cell with shell colors and folded output, an Explored group and a markdown answer with a list and a table. The composer is pinned under the transcript and works: type a prompt and press Enter, and Codex shows Working, runs its command, and streams the answer. Press Escape to interrupt the turn.

"use client";

import { Fragment, useEffect, useRef } from "react";

import { CodexComposer } from "../components/codex-composer";
import { CodexExec } from "../components/codex-exec";
import { CodexExplored } from "../components/codex-explored";
import { CodexHeader } from "../components/codex-header";
import { CodexCode, CodexList, CodexTable } from "../components/codex-markdown";
import { CodexMessage } from "../components/codex-message";
import { CodexTerminal } from "../components/codex-terminal";
import { CodexInterrupted, CodexWorking } from "../components/codex-working";
import { CODEX_DEMO_SESSION, CODEX_REPLIES } from "../constants/codex-demo";
import { useCodexChat } from "../hooks/use-codex-chat";
import type { CodexChatTurn } from "../types/codex";

const PARAGRAPH_SEPARATOR = "\n\n";

/** How close to the bottom still counts as following the transcript. */
const FOLLOW_THRESHOLD_PX = 48;

const CODE_PATTERN = /(`[^`]+`)/;

/** Renders one paragraph, turning `backticks` into code. */
const CodexInline = ({ text }: { text: string }) => {
  let offset = 0;
  return text.split(CODE_PATTERN).map((part) => {
    const key = `${offset}-${part}`;
    offset += part.length;
    return part.length > 2 && part.startsWith("`") && part.endsWith("`") ? (
      <CodexCode key={key}>{part.slice(1, -1)}</CodexCode>
    ) : (
      <Fragment key={key}>{part}</Fragment>
    );
  });
};

const CodexTurn = ({ turn }: { turn: CodexChatTurn }) => {
  let offset = 0;
  const paragraphs = turn.reply.split(PARAGRAPH_SEPARATOR).map((text) => {
    const key = offset;
    offset += text.length + PARAGRAPH_SEPARATOR.length;
    return { key, text };
  });

  return (
    <>
      <CodexMessage from="user">{turn.prompt}</CodexMessage>
      {turn.execs.map(({ id, ...exec }) => (
        <CodexExec key={id} {...exec} />
      ))}
      {turn.reply && (
        <CodexMessage>
          {paragraphs.map((paragraph) => (
            <p key={paragraph.key}>
              <CodexInline text={paragraph.text} />
            </p>
          ))}
        </CodexMessage>
      )}
      {turn.status === "interrupted" && <CodexInterrupted />}
    </>
  );
};

export default function CodexDemo() {
  const session = CODEX_DEMO_SESSION;
  const chat = useCodexChat(CODEX_REPLIES);
  const terminalRef = useRef<HTMLDivElement>(null);
  const followRef = useRef(false);

  // Follow new output only while the reader sits at the bottom, so
  // scrolling up to reread a turn is not undone by the next streamed word.
  // Sending always jumps back down. Scrolling the viewport directly (not
  // scrollIntoView) keeps the host page still.
  const viewportOf = () =>
    terminalRef.current?.querySelector<HTMLElement>(
      '[data-slot="scroll-area-viewport"]'
    );

  useEffect(() => {
    const viewport = viewportOf();
    if (!viewport) {
      return;
    }
    const handleScroll = () => {
      followRef.current =
        viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <
        FOLLOW_THRESHOLD_PX;
    };
    handleScroll();
    viewport.addEventListener("scroll", handleScroll, { passive: true });
    return () => viewport.removeEventListener("scroll", handleScroll);
  }, []);

  useEffect(() => {
    if (!followRef.current) {
      return;
    }
    const viewport = viewportOf();
    viewport?.scrollTo({ top: viewport.scrollHeight });
  }, [chat.turns, chat.busy]);

  const handleSend = (text: string) => {
    followRef.current = true;
    return chat.send(text);
  };

  return (
    <div className="w-full min-w-0">
      <CodexTerminal
        className="h-170"
        footer={
          <>
            {chat.busy && (
              <CodexWorking
                className="pb-[0.65em]"
                elapsed={`${chat.elapsed}s`}
              />
            )}
            <CodexComposer
              busy={chat.busy}
              cwd={session.header.cwd}
              effort={session.composer.effort}
              model={session.composer.model}
              onSend={handleSend}
              onStop={chat.stop}
              placeholder={session.composer.placeholder}
              task={session.composer.task}
              warnings={session.composer.warnings}
            />
          </>
        }
        ref={terminalRef}
        title={session.title}
      >
        <CodexHeader {...session.header} />
        <CodexMessage from="user">{session.userMessage}</CodexMessage>
        <CodexMessage>{session.intro}</CodexMessage>
        <CodexExec {...session.exec} />
        <CodexExplored
          details={session.exploredDetails}
          items={session.explored}
        />
        <CodexMessage>
          <p>{session.summary}</p>
          <CodexList
            items={session.highlights.map((highlight) => (
              <Fragment key={highlight.id}>
                <strong>{highlight.label}</strong> {highlight.text}
              </Fragment>
            ))}
          />
          <p>{session.tableIntro}</p>
          <CodexTable codeColumns={[0]} {...session.table} />
          <p>{session.followUp}</p>
        </CodexMessage>
        {chat.turns.map((turn) => (
          <CodexTurn key={turn.id} turn={turn} />
        ))}
      </CodexTerminal>
    </div>
  );
}

Installation

$ bunx --bun shadcn@latest add @notra/codex

This adds every Codex component to components/codex along with the demo session.

It also adds the useCodexChat hook that plays back the scripted replies in CODEX_REPLIES. Pass chat.busy, chat.send and chat.stop to CodexComposer as busy, onSend and onStop, and show CodexWorking above it while busy. Replace the hook with your agent’s stream to run a real session.

The components use the system monospace stack through font-codex. Point it at your own mono font to change it.

CodexTerminal

The root is a shadcn Card with the window chrome, so it takes every Card prop. title sets the text in the title bar and footer pins content under the scrolling transcript. Put CodexHeader, CodexMessage, CodexExec, CodexExplored and CodexWorking inside it in transcript order and pass CodexComposer as the footer.

Theming

The block is built on the shadcn Base UI style. It uses shadcn Button, Card, Collapsible, InputGroup, Kbd, ScrollArea and Table, and the CLI installs any you’re missing.

Its colors are --codex-* tokens that the CLI adds to your CSS, mapped to utilities like bg-codex-bg and text-codex-blue. They ship with a light and a dark terminal palette, so the window follows your app’s theme. Change them in your CSS to retheme it, or point them at your own tokens:

:root {
  --codex-bg: var(--card);
  --codex-blue: var(--primary);
}

Trademarks

OpenAI and Codex are trademarks of OpenAI OpCo, LLC. Notra is not affiliated with or endorsed by OpenAI. Logos and brand assets in the demo belong to their respective owners. See Trademarks.