termcast
termcast — Build TUIs with a Raycast-like React API
termcast is a framework for building terminal user interfaces using React. It implements the Raycast extension API (@raycast/api) but renders to the terminal via opentui. If you know Raycast, you know termcast.
bun install -g termcast
termcast new my-extension # scaffold
cd my-extension && termcast dev # hot-reload dev mode
IMPORTANT: before starting every task ALWAYS read opentui docs:
curl -s https://raw.githubusercontent.com/sst/opentui/refs/heads/main/packages/react/README.md
Imports
For new projects, import from termcast and @termcast/utils:
import { List, Detail, Action, ActionPanel, showToast, Toast, Icon, Color } from 'termcast'
import { useCachedPromise, useCachedState } from '@termcast/utils'
@raycast/api imports still work (for porting existing extensions) but termcast is preferred for new code.
Project Structure
my-extension/
package.json # must have "commands" array
src/
index.tsx # default command entry point
other-command.tsx # additional commands
package.json must declare commands:
{
"name": "my-extension",
"commands": [
{
"name": "index",
"title": "Browse Items",
"description": "Main command",
"mode": "view"
}
],
"dependencies": {
"termcast": "latest",
"@termcast/utils": "latest"
}
}
Each command file exports a default React component:
export default function Command() {
return <List>...</List>
}
For standalone scripts (examples, prototyping), use renderWithProviders:
import { renderWithProviders } from 'termcast'
await renderWithProviders(<MyComponent />, {
extensionName: 'my-app', // required for LocalStorage/Cache to work
})
1. List — The Core Component
The simplest termcast app is a searchable list:
import { List } from 'termcast'
export default function Command() {
return (
<List searchBarPlaceholder="Search items...">
<List.Item title="First Item" subtitle="A subtitle" />
<List.Item title="Second Item" accessories={[{ text: 'Badge' }]} />
<List.Item
title="Third Item"
accessories={[
{ tag: { value: 'Important', color: Color.Red } },
{ date: new Date() },
]}
/>
</List>
)
}
Key props on List:
navigationTitle— title in the top barsearchBarPlaceholder— placeholder text in searchisLoading— shows a loading indicatorisShowingDetail— enables the side detail panelspacingMode—'default'(single-line) or'relaxed'(two-line items)onSelectionChange— callback when selection movesonSearchTextChange— callback when search text changesthrottle— throttle search change events
Key props on List.Item:
title,subtitle— main texticon— emoji string or{ source: Icon.Star, tintColor: Color.Orange }accessories— array of{ text?, tag?, date?, icon? }keywords— extra search termsid— stable identifier for selection trackingdetail— side panel content (whenisShowingDetailis true)actions— ActionPanel for this item
2. Actions
Actions are what users can do. The first action triggers on Enter. All actions show in the action panel (ctrl+k).
import { List, Action, ActionPanel, showToast, Toast, Icon } from 'termcast'
<List.Item
title="My Item"
actions={
<ActionPanel>
<Action
title="Open"
icon={Icon.Eye}
onAction={() => { /* primary action on Enter */ }}
/>
<Action
title="Refresh"
icon={Icon.ArrowClockwise}
shortcut={{ modifiers: ['ctrl'], key: 'r' }}
onAction={() => { /* triggered by ctrl+r directly */ }}
/>
<Action.CopyToClipboard title="Copy Name" content="My Item" />
</ActionPanel>
}
/>
Action sections
Group related actions:
<ActionPanel>
<ActionPanel.Section title="Primary">
<Action title="Open" onAction={() => {}} />
</ActionPanel.Section>
<ActionPanel.Section title="Copy">
<Action.CopyToClipboard title="Copy ID" content={item.id} />
<Action.CopyToClipboard title="Copy Title" content={item.title} />
</ActionPanel.Section>
</ActionPanel>
Built-in action types
Action— generic action withonActionAction.Push— push a new view onto the navigation stackAction.CopyToClipboard— copy text to clipboardAction.SubmitForm— submit a form (used inside Form)
Keyboard shortcuts
Shortcuts use ctrl or alt modifiers with letter keys. cmd (hyper) does not work in terminals — the parent terminal app intercepts it.
shortcut={{ modifiers: ['ctrl'], key: 'r' }} // ctrl+r
shortcut={{ modifiers: ['ctrl', 'shift'], key: 'r' }} // ctrl+shift+r
shortcut={{ modifiers: ['alt'], key: 'd' }} // alt+d
// Also available: Keyboard.Shortcut.Common.Refresh, etc.
Note: ctrl+digit shortcuts don't work reliably. Always use letters.
3. Navigation
Push and pop views onto a navigation stack. Esc goes back.
import { useNavigation, Detail, Action, ActionPanel } from 'termcast'
function ItemDetail({ item }: { item: Item }) {
const { pop } = useNavigation()
return (
<Detail
navigationTitle={item.title}
markdown={`# ${item.title}\n\n${item.description}`}
actions={
<ActionPanel>
<Action title="Go Back" onAction={() => { pop() }} />
</ActionPanel>
}
/>
)
}
// In a list item:
function MyList() {
const { push } = useNavigation()
return (
<List>
<List.Item
title="Item A"
actions={
<ActionPanel>
<Action
title="View Detail"
onAction={() => { push(<ItemDetail item={itemA} />) }}
/>
{/* Or use Action.Push for declarative navigation */}
<Action.Push
title="View Detail"
target={<ItemDetail item={itemA} />}
/>
</ActionPanel>
}
/>
</List>
)
}
Important: props passed via push() are captured at push time and won't sync with parent state changes. If the child needs reactive parent state, use zustand or pass a zustand store via props.
4. Detail View
Full-screen markdown view with optional metadata sidebar:
import { Detail, Color } from 'termcast'
<Detail
navigationTitle="Server Status"
markdown={`# Server Status\n\nAll systems operational.\n\n| Service | Status |\n|---------|--------|\n| API | Running |\n| DB | Running |`}
metadata={
<Detail.Metadata>
<Detail.Metadata.Label title="Status" text={{ value: "Active", color: Color.Green }} />
<Detail.Metadata.Label title="Uptime" text="14d 3h" />
<Detail.Metadata.Separator />
<Detail.Metadata.Link
title="Dashboard"
target="https://example.com"
text="example.com"
/>
<Detail.Metadata.Separator />
<Detail.Metadata.TagList title="Tags">
<Detail.Metadata.TagList.Item text="production" color={Color.Green} />
<Detail.Metadata.TagList.Item text="critical" color={Color.Red} />
</Detail.Metadata.TagList>
</Detail.Metadata>
}
actions={
<ActionPanel>
<Action title="Refresh" onAction={() => {}} />
</ActionPanel>
}
/>
Metadata components
Label— key-value row.textcan be a string or{ value, color }Separator— horizontal dividerLink— clickable link (OSC 8 hyperlinks in supported terminals)TagList— row of colored tags viaTagList.Item
5. List with Side Detail Panel
Show a detail panel alongside the list. The detail updates as the user navigates items:
<List isShowingDetail={true} navigationTitle="Pokemon List">
{pokemons.map((pokemon) => (
<List.Item
key={pokemon.id}
title={pokemon.name}
subtitle={`#${pokemon.id}`}
detail={
<List.Item.Detail
markdown={`# ${pokemon.name}\n\nTypes: ${pokemon.types.join(', ')}`}
metadata={
<List.Item.Detail.Metadata>
<List.Item.Detail.Metadata.Label title="Height" text={`${pokemon.height}m`} />
<List.Item.Detail.Metadata.Label title="Weight" text={`${pokemon.weight}kg`} />
<List.Item.Detail.Metadata.Separator />
<List.Item.Detail.Metadata.TagList title="Types">
{pokemon.types.map((t) => (
<List.Item.Detail.Metadata.TagList.Item key={t} text={t} />
))}
</List.Item.Detail.Metadata.TagList>
</List.Item.Detail.Metadata>
}
/>
}
actions={
<ActionPanel>
<Action title="Toggle Detail" onAction={() => { setShowingDetail(!showingDetail) }} />
</ActionPanel>
}
/>
))}
</List>
6. Sections and Dropdowns
Sections
Group items with headers:
<List>
<List.Section title="Fruits">
<List.Item title="Apple" />
<List.Item title="Banana" />
</List.Section>
<List.Section title="Vegetables">
<List.Item title="Carrot" />
</List.Section>
</List>
Empty sections are automatically hidden.
Dropdown filter
Add a dropdown next to the search bar:
<List
searchBarAccessory={
<List.Dropdown tooltip="Category" onChange={setCategory}>
<List.Dropdown.Item title="All" value="all" />
<List.Dropdown.Section title="Types">
<List.Dropdown.Item title="Beer" value="beer" />
<List.Dropdown.Item title="Wine" value="wine" />
</List.Dropdown.Section>
</List.Dropdown>
}
>
{filteredItems.map((item) => (
<List.Item key={item.id} title={item.name} />
))}
</List>
7. Forms
Collect user input. Navigate fields with Tab/arrows. Submit with ctrl+enter or via action panel.
import { Form, Action, ActionPanel, showToast, Toast } from 'termcast'
function CreateItem() {
return (
<Form
navigationTitle="New Item"
actions={
<ActionPanel>
<Action.SubmitForm
title="Create"
onSubmit={async (values) => {
await showToast({ style: Toast.Style.Success, title: 'Created!' })
}}
/>
</ActionPanel>
}
>
<Form.TextField id="name" title="Name" placeholder="Item name" />
<Form.TextArea id="description" title="Description" placeholder="Describe..." />
<Form.Dropdown id="priority" title="Priority">
<Form.Dropdown.Item value="high" title="High" />
<Form.Dropdown.Item value="medium" title="Medium" />
<Form.Dropdown.Item value="low" title="Low" />
</Form.Dropdown>
<Form.Checkbox id="urgent" title="Flags" label="Mark as urgent" />
<Form.DatePicker id="dueDate" title="Due Date" type={Form.DatePicker.Type.Date} />
<Form.Separator />
<Form.Description title="Help" text="Tab to move between fields. ctrl+enter to submit." />
</Form>
)
}
Form field types: TextField, PasswordField, TextArea, Checkbox, Dropdown, DatePicker, TagPicker, FilePicker, Separator, Description.
8. Toasts
Show feedback to the user:
import { showToast, Toast, showFailureToast } from 'termcast'
// Success
await showToast({ style: Toast.Style.Success, title: 'Saved', message: 'Item updated' })
// Failure
await showToast({ style: Toast.Style.Failure, title: 'Error', message: 'Connection failed' })
// From a caught error (shows title + error message)
await showFailureToast(error, { title: 'Failed to fetch' })
Data Fetching
useCachedPromise
The primary hook for async data. Handles loading state, caching, revalidation, and pagination.
import { useCachedPromise } from '@termcast/utils'
function MyList() {
const { data, isLoading, revalidate } = useCachedPromise(
async (query: string) => {
const response = await fetch(`/api/search?q=${query}`)
return response.json()
},
[searchText], // re-fetches when these change
)
return (
<List isLoading={isLoading}>
{data?.map((item) => (
<List.Item key={item.id} title={item.name} />
))}
</List>
)
}
Pagination
For infinite scroll lists:
const { data, isLoading, pagination } = useCachedPromise(
(query: string) => {
return async ({ cursor }: { page: number; cursor?: string }) => {
const result = await fetchItems({ query, pageToken: cursor })
return {
data: result.items,
hasMore: !!result.nextPageToken,
cursor: result.nextPageToken,
}
}
},
[searchText],
{ keepPreviousData: true },
)
return (
<List isLoading={isLoading} pagination={pagination}>
{data?.map((item) => <List.Item key={item.id} title={item.name} />)}
</List>
)
useCachedState
Persistent UI state that survives across sessions (stored in SQLite):
import { useCachedState } from '@termcast/utils'
const [selectedAccount, setSelectedAccount] = useCachedState(
'selectedAccount', // key
'all', // default value
{ cacheNamespace: 'my-extension' },
)
const [isShowingDetail, setIsShowingDetail] = useCachedState(
'isShowingDetail',
true,
{ cacheNamespace: 'my-extension' },
)
Revalidation pattern
After mutations, call revalidate() to refresh the data:
const { data, revalidate } = useCachedPromise(fetchItems, [])
const handleDelete = async (id: string) => {
await deleteItem(id)
await showToast({ style: Toast.Style.Success, title: 'Deleted' })
revalidate() // refresh the list
}
Termcast-Exclusive Components
These components are unique to termcast — not available in Raycast. They can be placed inside Detail.Metadata, List.Item.Detail.Metadata, or used standalone in a Detail view.
Graph (line chart with braille rendering)
import { Graph, Color, Detail } from 'termcast'
<Detail
markdown="# Stock Price"
metadata={
<Graph height={15} xLabels={['Jan', 'Apr', 'Jul', 'Oct']} yTicks={6}>
<Graph.Line data={[150, 162, 175, 190, 201]} color={Color.Orange} title="AAPL" />
<Graph.Line data={[120, 135, 140, 155, 160]} color={Color.Blue} title="MSFT" />
</Graph>
}
/>
Variants: 'area' (default), 'filled', 'striped'. Set via the variant prop on Graph.
BarGraph (vertical stacked bars)
import { BarGraph } from 'termcast'
<BarGraph height={10} labels={['Mon', 'Tue', 'Wed', 'Thu', 'Fri']}>
<BarGraph.Series data={[40, 30, 25, 15, 50]} title="Direct" />
<BarGraph.Series data={[30, 35, 15, 20, 35]} title="Organic" />
<BarGraph.Series data={[20, 25, 10, 10, 25]} title="Referral" />
</BarGraph>
BarChart (horizontal stacked bars)
import { BarChart } from 'termcast'
<BarChart
segments={[
{ title: 'Used', value: 75 },
{ title: 'Free', value: 25 },
]}
/>
CalendarHeatmap
GitHub-style contribution grid:
import { CalendarHeatmap, Color } from 'termcast'
import type { CalendarHeatmapData } from 'termcast'
const data: CalendarHeatmapData[] = days.map((date) => ({
date: new Date(date),
value: Math.floor(Math.random() * 8),
}))
<CalendarHeatmap data={data} color={Color.Green} />
<CalendarHeatmap data={data} color={Color.Blue} emptyColor={Color.Purple} />
Table
Borderless table with header background and alternating row stripes:
import { Table } from 'termcast'
<Table
headers={['Region', 'Latency', 'Status']}
rows={[
['us-east-1', '**12ms**', 'OK'],
['eu-west-1', '*45ms*', 'OK'],
['ap-south-1', '`89ms`', 'Degraded'],
]}
/>
Cells support inline markdown: **bold**, *italic*, `code`, ~~strikethrough~~, [links](url).
ProgressBar
Usage/progress display:
import { ProgressBar } from 'termcast'
<ProgressBar title="Current session" value={37} percentageSuffix="used" label="Resets 9pm" />
<ProgressBar title="Weekly quota" value={82} percentageSuffix="used" label="Resets Mar 1" />
Row (side-by-side layout)
Place any components side by side:
import { Row, Graph, BarGraph, Table, Color } from 'termcast'
<Row>
<Graph height={10} xLabels={['Mon', 'Fri']}>
<Graph.Line data={cpuData} color={Color.Orange} title="CPU" />
</Graph>
<Graph height={10} xLabels={['Mon', 'Fri']}>
<Graph.Line data={memData} color={Color.Blue} title="Memory" />
</Graph>
</Row>
<Row>
<Table headers={['Region', 'Latency']} rows={[['us-east', '12ms']]} />
<Table headers={['Endpoint', 'RPS']} rows={[['/api/auth', '1200']]} />
</Row>
Markdown (standalone block in metadata)
Render markdown anywhere inside metadata:
import { Markdown, CalendarHeatmap, Color, Detail } from 'termcast'
<Detail.Metadata>
<Markdown content="**Long history** — 5 years of daily data in purple." />
<CalendarHeatmap data={longData} color={Color.Purple} />
<Markdown content="**Recent** — last 150 days in red." />
<CalendarHeatmap data={recentData} color={Color.Red} />
</Detail.Metadata>
Combining components in metadata
All termcast-exclusive components compose freely inside metadata:
<Detail
markdown="# Dashboard"
metadata={
<Detail.Metadata>
<Detail.Metadata.Label title="Status" text={{ value: "Active", color: Color.Green }} />
<Detail.Metadata.Separator />
<Graph height={12} xLabels={['6h', '12h', '18h', '24h']}>
<Graph.Line data={requestsPerHour} color={Color.Orange} title="RPS" />
</Graph>
<Row>
<BarGraph height={8} labels={['Mon', 'Tue', 'Wed']}>
<BarGraph.Series data={[100, 150, 120]} title="2xx" />
<BarGraph.Series data={[5, 8, 3]} title="5xx" />
</BarGraph>
<Table
headers={['Endpoint', 'p99']}
rows={[['/api/auth', '45ms'], ['/api/data', '120ms']]}
/>
</Row>
<ProgressBar title="Rate limit" value={62} percentageSuffix="used" />
<CalendarHeatmap data={uptimeData} color={Color.Green} />
<Detail.Metadata.TagList title="Regions">
<Detail.Metadata.TagList.Item text="us-east" color={Color.Blue} />
<Detail.Metadata.TagList.Item text="eu-west" color={Color.Green} />
</Detail.Metadata.TagList>
</Detail.Metadata>
}
/>
Real-World Patterns
These patterns are drawn from a production termcast extension (a Gmail TUI wrapping an existing CLI tool).
Gluing a CLI tool with a TUI
The pattern: import your existing business logic, wrap it with termcast components.
┌─────────────────────────────────────────────┐
│ mail-tui.tsx (termcast UI) │
│ - List, Detail, Form, ActionPanel │
│ - useCachedPromise for data fetching │
│ - useCachedState for persistent prefs │
├─────────────────────────────────────────────┤
│ auth.ts / gmail-client.ts (business logic) │
│ - OAuth, API calls, data models │
│ - Pure TypeScript, no React dependencies │
└─────────────────────────────────────────────┘
The TUI file only handles rendering. All API calls, auth, and data processing live in separate files that work independently of the UI.
Multi-account dropdown
function AccountDropdown({ accounts, value, onChange }: {
accounts: { email: string }[]
value: string
onChange: (value: string) => void
}) {
return (
<List.Dropdown tooltip="Account" value={value} onChange={onChange}>
<List.Dropdown.Item title="All Accounts" value="all" icon={Icon.Globe} />
<List.Dropdown.Section title="Accounts">
{accounts.map((a) => (
<List.Dropdown.Item key={a.email} title={a.email} value={a.email} />
))}
</List.Dropdown.Section>
</List.Dropdown>
)
}
// Usage:
<List searchBarAccessory={
<AccountDropdown accounts={accounts} value={selected} onChange={setSelected} />
}>
Date-based section grouping
function dateSection(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today.getTime() - 86400000)
if (date >= today) return 'Today'
if (date >= yesterday) return 'Yesterday'
return 'Older'
}
const sections = useMemo(() => {
const groups = new Map<string, Item[]>()
for (const item of items) {
const section = dateSection(item.date)
const list = groups.get(section) ?? []
list.push(item)
groups.set(section, list)
}
return [...groups.entries()].map(([name, items]) => ({ name, items }))
}, [items])
return (
<List>
{sections.map((section) => (
<List.Section key={section.name} title={section.name}>
{section.items.map((item) => (
<List.Item key={item.id} title={item.title} />
))}
</List.Section>
))}
</List>
)
Mutations with loading state
const [activeMutations, setActiveMutations] = useState(0)
const isMutating = activeMutations > 0
const withMutation = async <T,>(fn: () => Promise<T>): Promise<T> => {
setActiveMutations((n) => n + 1)
try { return await fn() }
finally { setActiveMutations((n) => n - 1) }
}
// Usage in an action:
<Action
title="Archive"
onAction={() => withMutation(async () => {
await archiveItem(item.id)
await showToast({ style: Toast.Style.Success, title: 'Archived' })
revalidate()
})}
/>
<List isLoading={isLoading || isMutating}>
Compose forms via Action.Push
<ActionPanel.Section title="Reply & Forward">
<Action.Push
title="Reply"
icon={Icon.Reply}
shortcut={{ modifiers: ['ctrl'], key: 'r' }}
target={
<ComposeForm
mode={{ type: 'reply', threadId: thread.id }}
onSent={revalidate}
/>
}
/>
<Action.Push
title="Forward"
icon={Icon.Forward}
shortcut={{ modifiers: ['ctrl'], key: 'f' }}
target={
<ComposeForm
mode={{ type: 'forward', threadId: thread.id }}
onSent={revalidate}
/>
}
/>
</ActionPanel.Section>
Porting from Raycast
If you're converting an existing Raycast extension:
- Change imports:
@raycast/api->termcast,@raycast/utils->@termcast/utils - Keyboard modifiers:
cmddoesn't work in terminals. Replace withctrloralt - Enter key: named
returnin opentui key events - Images: no pixel rendering in terminals. Emoji and text fallbacks are used
- Everything else works the same: List, Detail, Form, Action, Toast, Navigation, LocalStorage, Cache, Clipboard, OAuth
The compound component patterns are identical:
List.Item,List.Section,List.Dropdown,List.Dropdown.ItemDetail.Metadata,Detail.Metadata.Label,Detail.Metadata.TagListForm.TextField,Form.Dropdown,Form.Dropdown.ItemActionPanel.Section
Gotchas
- Use
logger.loginstead ofconsole.log— logs go toapp.login the extension directory - Never use
setTimeoutfor scheduling React state updates - Never pass functions to
useEffectdependencies — causes infinite loops - Minimize
useState— compute derived state inline when possible - Always use
.tsxextension for files with JSX useEffectis discouraged — colocate logic in event handlers when possible- Never use
as any— find proper types, import them, or use@ts-expect-errorwith explanation - Shortcuts: use
ctrl/alt+ letter keys only (not digits) showFailureToast(error, { title })is the standard way to handle errors in actionsrevalidate()after every mutation to refresh data
Running and Testing Extensions
Running with termcast dev
The primary way to develop and try out an extension:
cd my-extension
termcast dev
This launches the TUI with hot-reload. File changes rebuild and refresh automatically. This is the fast iteration loop for development.
Interactive experimentation with tuistory CLI
tuistory is a CLI tool for driving terminal applications from the shell — like Playwright but for TUIs. Use it to launch your extension, interact with it, and take snapshots without manual intervention.
Always run tuistory --help first to see the latest commands and options.
# Launch the extension in a managed terminal session
tuistory launch "termcast dev" -s my-ext --cols 120 --rows 36
# See current terminal state
tuistory -s my-ext snapshot --trim
# Interact
tuistory -s my-ext type "search query"
tuistory -s my-ext press enter
tuistory -s my-ext press ctrl k # open action panel
tuistory -s my-ext press tab # next form field
tuistory -s my-ext press esc # go back
# Take a screenshot as image
tuistory -s my-ext screenshot -o ./tmp/screenshot.jpg --pixel-ratio 2
# Observe after each action
tuistory -s my-ext snapshot --trim
# Cleanup
tuistory -s my-ext close
Automated tests with vitest + tuistory JS API
tuistory provides a Playwright-style JS API for writing automated TUI tests. The workflow is observe-act-observe: take a snapshot, interact, take another snapshot.
import { test, expect } from 'vitest'
import { launchTerminal } from 'tuistory'
test('extension shows items and navigates to detail', async () => {
const session = await launchTerminal({
command: 'termcast',
args: ['dev'],
cols: 120,
rows: 36,
cwd: '/path/to/my-extension',
})
// Wait for the list to render
await session.waitForText('Search', { timeout: 10000 })
// Observe initial state
const initial = await session.text({ trimEnd: true })
expect(initial).toMatchInlineSnapshot()
// Type a search query
await session.type('project')
const filtered = await session.text({ trimEnd: true })
expect(filtered).toMatchInlineSnapshot()
// Press Enter to trigger primary action
await session.press('enter')
await session.waitForText('Detail', { timeout: 5000 })
const detail = await session.text({ trimEnd: true })
expect(detail).toMatchInlineSnapshot()
// Go back
await session.press('esc')
session.close()
}, 30000)
Run with:
vitest --run -u # fill in snapshots
vitest --run # verify snapshots match
Always leave toMatchInlineSnapshot() empty the first time, run with -u to fill them, then read back the test file to verify the captured output is correct.