a modal editor in the terminal, helix's look, typing and saving on the playhead
npx shadcn add @ag/terminal-editorAlready installed? Preview an update
npx shadcn add @ag/terminal-editor --dry-runYour 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:wis typed and answered. It plays the samecode-sessionsteps aside:<Terminal alt="…"><TerminalEditor files={…} steps={…} /></Terminal>.
also called helix, vim, neovim
shelf devices & demos
uses terminal, terminal-session, code-session, code, playback
the demo above, verbatim
"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
"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>
)
}