← ui

terminal-editor

ui

a modal editor in the terminal, helix's look, typing and saving on the playhead

npx shadcn add @ag/terminal-editor
Already installed? Preview an update
npx shadcn add @ag/terminal-editor --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-15
  • New: a modal editor in terminal's window, helix's look: line numbers, the code highlighted, a block caret in normal mode and a bar in insert, the status line with the mode (NOR, INS), the file and [+] while unsaved, the command line where :w is typed and answered. It plays the same code-session steps as ide: <Terminal alt="…"><TerminalEditor files={…} steps={…} /></Terminal>.
Updated 2026-10-15 · release notes

also called helix, vim, neovim

shelf devices & demos

uses terminal, terminal-session, code-session, code, playback

the demo above, verbatim

terminal-editor.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import {
  type BrowserStep,
  browserTimeline,
} from "@/registry/base-nova/lib/browser-session"
import type { Clip } from "@/registry/base-nova/lib/clip"
import {
  type CodeFile,
  type CodeStep,
  codeTimeline,
} from "@/registry/base-nova/lib/code-session"
import type { Art, Timeline } from "@/registry/base-nova/lib/macos-session"
import { BrowserSession } from "@/registry/base-nova/ui/browser"
import { Macos } from "@/registry/base-nova/ui/macos"
import { usePlayhead } from "@/registry/base-nova/ui/playback"
import { Player } from "@/registry/base-nova/ui/player"
import { Terminal } from "@/registry/base-nova/ui/terminal"
import { TerminalEditor } from "@/registry/base-nova/ui/terminal-editor"

// #region edit
// The file before, and what is done to it: the same steps `ide` plays as VS Code.
// Here each `type` is insert mode (INS) and the save is `:w`.
const FILES: CodeFile[] = [
  {
    path: "index.html",
    text: `<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>example</title>
  </head>
  <body>
    <h1>hello</h1>
  </body>
</html>
`,
  },
]
const EDIT: CodeStep[] = [
  { kind: "open", path: "index.html" },
  {
    kind: "type",
    line: 5,
    text: '\n    <script type="module" src="https://e-id.to/embed.js"></script>',
    delay: 700,
  },
  {
    kind: "type",
    line: 9,
    text: '\n    <e-id-card handle="adriangalilea"></e-id-card>',
    delay: 500,
  },
  { kind: "save", delay: 400 },
]
const CLIP = codeTimeline(FILES, EDIT).clip
// #endregion

// #region desktop
// The browser reloads once `:w` has landed; the editor holds its last frame to the
// visit's end, so one clock runs both.
const SAVED = codeTimeline(FILES, EDIT).ends.at(-1) as number
const VISIT: BrowserStep[] = [{ kind: "reload", delay: SAVED + 300 }]
const VISIT_TIMELINE = browserTimeline("example.com", VISIT)
const CLOCK = VISIT_TIMELINE.total
const BOTH: Clip = {
  duration: CLOCK,
  chapters: [
    ...CLIP.chapters,
    { start: VISIT_TIMELINE.starts[0] as number, title: "reload" },
  ],
}
/** The page as the browser draws unstyled HTML, the card once the file has it. */
function Served({ card }: { card: boolean }) {
  return (
    <div className="min-h-full bg-white p-2 font-serif text-black">
      <h1 className="my-[0.67em] font-bold text-[2em]">hello</h1>
      {card && (
        <div className="max-w-[22rem] rounded-2xl border border-zinc-200 p-4 font-sans">
          <p className="font-semibold text-[15px]">Adrian Galilea</p>
          <p className="text-[13px] text-zinc-500">e-id.to/adriangalilea</p>
        </div>
      )}
    </div>
  )
}
const PAGES = {
  "example.com": (serving: number) => <Served card={serving > 0} />,
}
const DESKTOP: Timeline = { app: "Terminal", chord: "", steps: [] }
const NO_ART: Art = { icon: "", glyphs: {} }

function Desktop() {
  const ms = usePlayhead(undefined).at * CLOCK
  const browsing = ms >= (VISIT_TIMELINE.starts[0] as number)
  return (
    <Macos
      timeline={DESKTOP}
      art={NO_ART}
      front={browsing ? "Safari" : "Terminal"}
      device="macbook"
      captions={false}
      alt="A Mac with a terminal and Safari side by side: the embed's script and card typed into index.html in helix, :w, and Safari reloading example.com to show the card"
    >
      <Terminal
        alt=""
        accent="#e7a13c"
        size="11px"
        className="absolute top-[40px] left-[16px] w-[590px]"
      >
        <TerminalEditor files={FILES} steps={EDIT} duration={CLOCK} rows={25} />
      </Terminal>
      <BrowserSession
        start="example.com"
        steps={VISIT}
        pages={PAGES}
        alt=""
        className="absolute top-[40px] left-[614px] aspect-auto h-[540px] w-[330px]"
      />
    </Macos>
  )
}
// #endregion

export default function TerminalEditorDemo() {
  return (
    <div className="space-y-16">
      <Sample
        name="helix"
        glance="large"
        title="helix, played"
        description="insert mode while keys land, [+] while unsaved, :w answered"
        with="edit"
      >
        <Player clip={CLIP} label="an edit of index.html in helix">
          <Terminal
            alt="helix in a terminal: the embed's script typed into index.html's head, the card into its body, then :w"
            accent="#e7a13c"
          >
            <TerminalEditor files={FILES} steps={EDIT} />
          </Terminal>
        </Player>
      </Sample>

      <Sample
        name="phosphor"
        title="still, on the default phosphor"
        description="a fixed progress: mid-word, in insert mode"
        with="edit"
      >
        <Terminal alt="helix mid-edit, in insert mode">
          <TerminalEditor
            files={FILES}
            steps={EDIT}
            progress={0.55}
            rows={12}
          />
        </Terminal>
      </Sample>

      <Sample
        name="mac"
        title="on a mac, with the result"
        description="the edit and the reload on one playhead: the browser waits for :w"
        with="edit desktop"
      >
        <Player clip={BOTH} label="an edit in helix, then the page reloaded">
          <Desktop />
        </Player>
      </Sample>
    </div>
  )
}

the source, every file an install writes

terminal-editor.tsx
"use client"

// A MODAL EDITOR IN THE TERMINAL, helix's look: line numbers, the code highlighted,
// the caret a block in normal mode and a bar in insert, the status line with the
// mode (NOR, INS), the file and whether it is unsaved, and the command line where
// `:w` is typed and answered. It plays an edit on the playhead (lib/code-session,
// the same steps `ide` plays as VS Code: open, type at a place, save), and draws
// inside `terminal`'s window as the full-screen program it is:
//
//   <Terminal alt="…"><TerminalEditor files={…} steps={…} /></Terminal>
//
// It wears the terminal's phosphor (`useTerminalPalette`) for everything but the
// code, which takes code's dark theme (lib/code-tokens): a terminal is dark whatever
// the page is. Display-only, like the terminal around it.

import * as React from "react"
import {
  type CodeFile,
  type CodeStep,
  codeFrameAt,
  codeTimeline,
  placeOf,
} from "@/registry/base-nova/lib/code-session"
import { langOf, runsOf } from "@/registry/base-nova/lib/code-tokens"
import { LINE_HEIGHT } from "@/registry/base-nova/lib/terminal-session"
import { usePlayhead } from "@/registry/base-nova/ui/playback"
import { useTerminalPalette } from "@/registry/base-nova/ui/terminal"
import { useCodeColours } from "@/registry/base-nova/ui/use-code-colours"

export interface TerminalEditorProps {
  /** The files the editor can open (`/` between folders). */
  files: CodeFile[]
  steps: CodeStep[]
  /** The instant drawn, 0..1 of the edit, for a still. Inside a driver (a Player)
   *  omit it: the driver provides it. */
  progress?: number
  /** The clock's length when the edit shares it with something longer, ms: the
   *  last frame holds to it (lib/code-session `codeTimeline`). */
  duration?: number
  /** Rows of text the screen shows, the status and command lines under them. The
   *  view follows the caret, a few rows short of the foot, as helix scrolls. */
  rows?: number
}

/** How the status line names each mode, helix's three letters. */
const MODES = { normal: "NOR", insert: "INS", command: "NOR" } as const
/** Rows helix keeps between the caret and the screen's foot. */
const SCROLL_OFF = 3

/** An edit played in the terminal: the frame at the instant drawn as helix. Its
 *  clip, for the driver: `codeTimeline(files, steps).clip`. */
export function TerminalEditor({
  files,
  steps,
  progress,
  duration,
  rows = 14,
}: TerminalEditorProps) {
  const palette = useTerminalPalette()
  const timeline = React.useMemo(
    () => codeTimeline(files, steps, duration),
    [files, steps, duration],
  )
  const ms = usePlayhead(progress).at * timeline.total
  const f = codeFrameAt(timeline, ms)
  const path = f.active ?? "[scratch]"
  const colours = useCodeColours(
    f.active ? (timeline.final[f.active] as string) : "",
    langOf(path),
  )
  const lines = runsOf(f.text, colours, f.origin)
  const place = placeOf(f.text, f.caret)
  const top = Math.max(0, place.line - rows + SCROLL_OFF)
  const digits = Math.max(3, String(lines.length).length)
  const insert = f.mode === "insert"
  // The command line: what is typed into it, else helix's answer to the last save
  // (`'index.html' written, 12L 380B`) while that file is in front.
  const message = f.command
    ? f.command
    : f.saved && f.saved.path === f.active
      ? `'${f.saved.path}' written, ${f.text.replace(/\n$/, "").split("\n").length}L ${new TextEncoder().encode(f.text).length}B`
      : ""
  return (
    <div
      data-slot="terminal-editor"
      className="whitespace-pre"
      style={{ color: palette.command }}
    >
      <div data-slot="terminal-editor-text" style={{ paddingTop: "0.5em" }}>
        {Array.from({ length: rows }, (_, r) => {
          const i = top + r
          const runs = lines[i]
          const here = i + 1 === place.line
          return (
            <div
              // biome-ignore lint/suspicious/noArrayIndexKey: a screen row
              key={r}
              data-slot="terminal-editor-line"
              className="flex overflow-hidden"
              style={{ height: `${LINE_HEIGHT}em` }}
            >
              <span
                data-slot="terminal-editor-number"
                className="shrink-0 pr-[1ch] text-right"
                style={{
                  width: `${digits + 2}ch`,
                  color: here ? palette.accent : palette.prompt,
                }}
              >
                {runs ? i + 1 : ""}
              </span>
              <span className="relative min-w-0 flex-1">
                {runs?.map((run, k) => (
                  <span
                    // biome-ignore lint/suspicious/noArrayIndexKey: runs are positional
                    key={k}
                    style={
                      run.ink
                        ? {
                            color: run.ink.dark,
                            fontStyle: run.ink.italic ? "italic" : undefined,
                            fontWeight: run.ink.bold ? 700 : undefined,
                          }
                        : undefined
                    }
                  >
                    {run.text}
                  </span>
                ))}
                {here && (
                  <span
                    data-slot="terminal-editor-caret"
                    className="absolute top-0 h-full"
                    style={{
                      left: `${place.column - 1}ch`,
                      width: insert ? "2px" : "1ch",
                      background: palette.accent,
                      mixBlendMode: insert ? undefined : "difference",
                    }}
                  />
                )}
              </span>
            </div>
          )
        })}
      </div>
      <div
        data-slot="terminal-editor-status"
        className="flex gap-[1ch]"
        style={{ background: palette.bar }}
      >
        <span
          data-slot="terminal-editor-mode"
          className="px-[1ch] font-bold"
          style={{
            background: insert ? palette.command : palette.accent,
            color: palette.bg,
          }}
        >
          {MODES[f.mode]}
        </span>
        <span className="min-w-0 flex-1 truncate">
          {path}
          {f.active && f.dirty.includes(f.active) ? " [+]" : ""}
        </span>
        <span className="pr-[1ch]" style={{ color: palette.muted }}>
          1 sel {place.line}:{place.column}
        </span>
      </div>
      <div
        data-slot="terminal-editor-command"
        className="px-[1ch]"
        style={{ color: palette.muted }}
      >
        {message || " "}
      </div>
    </div>
  )
}