
Termcast
- 1 installs
- 334 repo stars
- Updated July 18, 2026
- remorses/termcast
Helps with ai & agent building tasks during AI-assisted development.
About
termcast is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- termcast
- AI & Agent Building
- AI-coding skill
Termcast by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,102 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/remorses/termcast --skill termcastAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 334 |
| Last updated | July 18, 2026 |
| Repository | remorses/termcast ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
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 modeIMPORTANT: before starting every task ALWAYS read opentui docs:
curl -s https://raw.githubusercontent.com/sst/opentui/refs/heads/main/packages/react/README.mdImports
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 commandspackage.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`.
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:
1. Change imports: @raycast/api -> termcast, @raycast/utils -> @termcast/utils 2. Keyboard modifiers: cmd doesn't work in terminals. Replace with ctrl or alt 3. Enter key: named return in opentui key events 4. Images: no pixel rendering in terminals. Emoji and text fallbacks are used 5. 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.log` instead of
console.log— logs go toapp.login the extension directory - Never use `setTimeout` for scheduling React state updates
- Never pass functions to
useEffectdependencies — causes infinite loops - Minimize `useState` — compute derived state inline when possible
- Always use `.tsx` extension for files with JSX
- `useEffect` is 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 actions
- `revalidate()` 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 devThis 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 closeAutomated 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 matchAlways 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.
{
"$schema": "https://unpkg.com/@changesets/config@1.4.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [],
"access": "public",
"baseBranch": "main",
"privatePackages": false,
"githubRelease": true,
"updateInternalDependencies": "patch",
"ignore": []
}
name: CI
on:
push:
tags-ignore:
- '**'
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: recursive
- name: Install native build dependencies
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
- name: Install dependencies
run: bun install --verbose || bun install --verbose
# Build submodules
- name: Build ghostty-opentui native binaries
run: bun scripts/build.ts linux-x64
working-directory: ghostty-opentui
- name: Build ghostty-opentui TypeScript
run: bun run build
working-directory: ghostty-opentui
- name: Build tuistory
run: bun run build
working-directory: tuistory
# scripts
- run: bun run test || bun run test
working-directory: termcast
name: opencode
on:
issues:
types: [opened]
issue_comment:
types: [created]
jobs:
opencode:
if: |
(github.event_name == 'issues' && contains(github.event.issue.body, '/opencode')) ||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/opencode'))
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: true
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Write auth.json
run: |
printf '{"anthropic":{"type":"oauth","refresh":"%s","access":"%s"}}' \
"${{ secrets.ANTHROPIC_REFRESH_TOKEN }}" \
"${{ secrets.ANTHROPIC_ACCESS_TOKEN }}" \
| install -D /dev/stdin ~/.local/share/opencode/auth.json
- name: Run opencode
uses: aemr3/opencode/github@dev
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
model: anthropic/claude-opus-4-1-20250805
# share: true
# github_token: xxxx
node_modules
dist
esm
.DS_Store
*.tsbuildinfo
.ultra.cache.json
coverage
build
*.env
.wrangler
*.zip
*.log
tmp
.build/**
termcasttmp
.env*
.termcast-bundle-test
# opensrc - source code for packages
opensrc/
tmp_test
[submodule "tuistory"]
path = tuistory
url = https://github.com/remorses/tuistory
[submodule "ghostty-opentui"]
path = ghostty-opentui
url = https://github.com/remorses/ghostty-opentui
[submodule "raycast-utils"]
path = raycast-utils
url = https://github.com/remorses/raycast-utils
{
"arrowParens": "always",
"jsxSingleQuote": true,
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"trailingComma": "all"
}
<!-- This AGENTS.md file is generated. Look for an agents.md package.json script to see what files to update instead. -->
Project Coding Guidelines
NOTICE: AGENTS.md is generated using bun agents.md and should NEVER be manually updated. only update PREFIX.md
ALWAYS use bun to install dependencies
---
termcast specific rules
Porting @raycast/api components and hooks to termcast
ALWAYS use termcast to import things, instead of relative imports. This is possible thanks to exports in package.json. for example:
import {List} from 'termcast'
ALWAYS use .tsx extension for every new file.
NEVER use mocks in vitest tests
When running the e2e vitest suite, ALWAYS use the repo scripts (bun e2e, bun e2e <file>, bun e2e -u). NEVER run vitest directly. e2e already passes -u so no need to pass again.
after running e2e see git diff to make sure we don't see unexpected changes in snapshots
prefer object args instead of positional args. as a way to implement named arguments, put the typescript definition inline
see files in the repo
use git ls-files | tree --fromfile to see files in the repo. this command will ignore files ignored by git
Goal
This project ports @raycast/api components and apis to use @opentui/react and other Bun APIs
We are basically implementing the package @raycast/api from scratch. DO NOT implement functions exported by @raycast/utils
This should be done one piece at a time, one hook and component at a time
Porting a new Raycast component or feature
Here is the process to follow to implement each API:
- decide which component or hook or function we are porting
- read the .d.ts of the @raycast/api package for the component or hook
- generate a new file or decide to which file to add this new API in src folder
- start by adding a signature without any actual implementation. Only a function or class or constant without any actual implementation
- try typechecking with
bun run tsc. fix any errors that is not related to the missing implementation (like missing returns) - then think, is the signature the same as Raycast?
- start implementing the component or function, before doing this
- decide on what @opentui/react components to use
- do so by reading opentui .d.ts files and see available components
- read .d.ts to understand available styling options and attributes
- typecheck
- if the added feature is a component or adds support for a new prop for a component, add an example usage component in the src/examples directory. create a descriptive name for it in the file. use simple-{component-name} for basic implementations examples
- if the implemented feature is function or other API, add an action in the file examples/miscellaneus.tsx, add a list item for the new feature, for example "show a error toast" if we are implementing toasts
- do not add an example if our feature is already covered by other example files
- DO NOT run the examples then. instead ask me to do it. do not add these as scripts in package.json
- typecheck to make sure the example is correct
Rules
- for return type of React components just use any
- keep types as close as possible to rayacst
- DO NOT use as any. instead try to understand how to fix the types in other ways
- to implement compound components like
List.Itemfirst define the type of List, using a interface, then use : to implement it and add compound components later using . and omitting the props types given they are already typed by the interface, here is an example - DO NOT use console.log. only use logger.log instead
- <input> uses onInput not onChange. it is passed a simple string value and not an event object
- to render examples components use renderWithProviders not render
- ALWAYS bind all class methods to
thisin the constructor. This ensures methods work correctly when called in any context (callbacks, event handlers, etc). Example:
constructor(options: Options) {
// Initialize properties
this.prop = options.prop
// Bind all methods to this instance
this.method1 = this.method1.bind(this)
this.method2 = this.method2.bind(this)
this.privateMethod = this.privateMethod.bind(this)
}interface ListType {
(props: ListProps): any
Item: (props: ListItemProps) => any
Section: (props: ListSectionProps) => any
}
const List: ListType = (props) => {
// implementation
}
List.Item = (props) => {
// implementation
}
List.Section = (props) => {
// implementation
}keeping the implementation compatible with raycast
the goal of this project is to use same props and api as @racyast/api so try to follow raycast types and behaviour exactly
to understand behaviour (not covered by .d.ts) you MUST read the racyast docs using commands like this one, that reads the List component docs:
curl -s https://developers.raycast.com/api-reference/user-interface/list.md
IMPORTANT! Add the ending .md to fetch markdown! Or it will return html!
You can see the full list of raycast docs pages using
curl -s https://developers.raycast.com/sitemap-pages.xml
NEVER import @raycast/api to reuse their types. we are porting that package into this repo, you cannot import it, instead implement it again
todos
if you cannot port a real implementation for some raycast APIs and instead simulate a "fake" response, always add // TODO comments so i can easily find these later and implement them
zustand
NEVER add zustand state setter methods. instead use useStore.setState to set state.
NEVER do useStore((state) => ({something: state.currentCommandName})). it will trigger an infinite render loop. instead only return scalar values and not objects in zustand state selectors
you can use zustand state from @state.tsx also outside of React using useStore.getState()
NEVER do useStore((state) => ({something: state.currentCommandName})). it will trigger an infinite render loop. instead only return scalar values and not objects in zustand state selectors
zustand already merges new partial state with the previous state. NEVER DO useStore.setState({ ...useStore.getInitialState(), ... }) unless for resetting state
adding new core extensions
when adding core extensions like a store extension that installs other extensions you should carefully manage @state.tsx state, setting it appropriately when navigating to another extension or command
strings with new lines
to create strings with new lines use the dedent package so it is more readable
examples
NEVER run examples yourself with bun src/examples/etc
These will hang. These are made for real people
focus
when you handle key presses with
import { useIsInFocus } from 'termcast/src/internal/focus-context'
const inFocus = useIsInFocus()
useKeyboard((evt) => {
if (!inFocus) return
// ...
// notice that enter is called return in evt.name
})useKeyboard has evt.stopPropagation() you can use to trap focus in specific cases. Handlers dispatch in useEffect registration order: siblings fire in JSX order, children fire before parents (React useEffect is bottom-up). stopPropagation prevents all handlers registered after the current one from firing.
descendants pattern and map.current
Why the descendants pattern is useful
The descendants pattern is essential for building compound components (like List with List.Item, Form with Form.TextField, etc.) because it solves a fundamental React challenge: parent components need to know about and coordinate their children dynamically.
In traditional React, parent components cannot easily:
1. Track which children are rendered and in what order 2. Implement keyboard navigation across children 3. Manage selection state across dynamic children 4. Handle filtering/searching while maintaining correct indexes
The descendants pattern solves this by:
- Automatic indexing: Each child component registers itself and gets a unique index automatically
- Dynamic tracking: Children can be added, removed, or reordered, and the parent stays in sync
- Decoupled state management: Parent manages navigation/selection state without tightly coupling to children
- Composition friendly: Works with any level of nesting and conditional rendering
This is why Raycast components like List, Form, and Grid use this pattern - it enables rich keyboard navigation and selection across dynamically rendered items without requiring explicit index props or brittle parent-child contracts.
useDescendant return values
The useDescendant hook returns { index, descendantId }:
index: The current position of the item in the rendered list (changes when items are filtered/reordered)descendantId: A stable unique ID for the item (remains constant for the component's lifetime)
IMPORTANT: Always use descendantId (not index) for tracking item-specific state like:
- Selection state (which items are selected)
- Expanded/collapsed state
- Item-specific data
Using index for state tracking is incorrect because when items are conditionally rendered or filtered, a single index can be associated with different items at different times. The descendantId provides a stable identity that persists across re-renders and filtering.
Example from the descendants example:
// CORRECT: Using descendantId for selection tracking
const isSelected = selectedIds.has(descendant.descendantId)
// WRONG: Using index for selection tracking
// const isSelected = selectedIndexes.has(descendant.index)Important implementation notes
IMPORTANT: When using the descendants pattern from src/descendants.tsx, the map.current from useDescendants() is NOT reactive and CANNOT be used during render. It can only be accessed inside:
- useEffect or useLayoutEffect to handle effects
- Event handlers (useKeyboard, onChange, etc)
.map.current CANNOT be called inside render or useMemo!
Example of WRONG usage (accessing map.current during render):
// WRONG - this will not update when descendants change
const items = Object.values(descendantsContext.map.current)Example of CORRECT usage (accessing map.current inside an event handler, such as with useKeyboard, see @src/examples/internal/descendants.tsx):
import { useKeyboard } from '@opentui/react'
import { useDescendants } from 'termcast/src/descendants'
const { map } = useDescendants()
useKeyboard((evt) => {
// Access map.current during useEffect or event handlers, NOT during render
const items = Object.values(map.current)
.filter((item) => item.index !== -1)
.sort((a, b) => a.index - b.index)
.map((item) => item.props)
// Handle your logic with items, e.g. navigating with up/down
})You CANNOT use .map.current to render items of a list for example. Instead move the rendering in the items themselves! To handle filtering render null in the item component and pass the search query via context
read file @src/examples/internal/descendants.tsx for a real usage example with selection, navigation, pagination, submit support.
tuistory
tuistory is used for e2e tests. After any change to tuistory source files, you must rebuild it:
cd tuistory && bun run buildnode-pty version requirement
tuistory uses node-pty for PTY spawning. Use node-pty version 0.10.1 - newer versions (like 1.1.0) cause posix_spawnp failed errors in vitest. If e2e tests fail with spawn errors, check tuistory/package.json and ensure node-pty is pinned to 0.10.1:
"optionalDependencies": {
"node-pty": "0.10.1"
}After changing the version, run bun install in the tuistory folder and rebuild.
testing
bun must be used to write tests
inline snapshots with .toMatchInlineSnapshots or other snapshots are the preferred way to test things. NO MOCKS.
never update inline snapshots manually, instead always use bun test -u to update snapshots. No need to reset snapshots before updating them with -u
some tests in src/examples end with .vitest.tsx. to run these you will need to use bun e2e -u
for example bun e2e src/examples/form-dropdown.vitest.tsx
these tests are for ensuring the examples work correctly
important: when esc is pressed when there is no navigation stack or toast it will exit the process of the tui. make sure to not do this in tests
fixing bugs in termcast
when you are trying to fix an issue identify first the issue in an existing .vitest.tsx test file. by looking if the existing snapshots already exhibit the issue. if not add a new test case for the issue.
then iterate to
- try to fix the issue by changing code in src
- run tests again
- read back the test snapshot. if not fixed repeat
- try to keep changes minimal to fix the issue
adding a test for an example in src/examples
To see an example of a test see @src/examples/list-with-sections.vitest.tsx
you should first understand what the example file does and which key sequences should be used to test it
then create a file ending with .vitest.tsx with same basename as the example.
then add empty .toMatchInlineSnapshot() calls for every expected output
run bun tsc to make sure it typechecks. if some keys you are trying to press are missing add them in the e2e-node.tsx file as methods.
then run bun test -u to update the snapshots
read back the inline snapshots and make sure they are what you expect
after validating snapshots are correct, add 1-2 expect(text).toContain('keyword') assertions to verify key behavior. use shortest unique string, no whitespace. example:
expect(beforeEnter).toContain('[Undo')
expect(afterEnter).toContain('Undone')notice that await driver.text() already waits for the pty to render so no need to add waitIdle everywhere. only add one if the test seems flakymake sure to pass an adeguate timeout in the test, passing a number as second arg of test
npm diffs
you can see diffs for different npm packages versions using
curl -fs https://npmdiff.dev/%40opentui%2Fcore/0.1.11/0.1.13/
NOTICE the need for using url encoded strings in the path!
this is helpful when an update breaks our code
reading .d.ts for node_modules
you should read the .d.ts for the packages you want to use to discover their API. for opentui you must also read the web guide fetching the .md file.
if you are inside the termcast/termcast folder (the termcast package) you will usually find node modules in the parent folder: ../node_modules/@opentui/core
react code guidelines
- NEVER set state inside a setTimeout. this has no effect and just makes the code more difficult to debug or understand
- NEVER pass children to useEffect depependencies! it makes no sense!
- Try to use as little useEffect or useLayoutEffect as possible. instead put the code directly in the relevant event handlers
- Keep as little useState as possible. computed state should be a simple expression in render if possible
- any useEffect that calls setState for visible UI state (selection, detail content, dialog open) MUST be useLayoutEffect to avoid single-frame flash. see
termcast/docs/flash-debugging.mdfor the full guide - NEVER use flushSync followed by a separate setState for state that should update in the same frame. use useLayoutEffect instead to batch both updates before paint
rendering colored areas in opentui (backgroundColor gotchas)
opentui boxes with backgroundColor but no text children will render visually but produce NO visible characters in session.text() snapshots. The terminal cells exist but ghostty-opentui only reports cells with actual text content.
To make colored areas visible in both visual rendering and text snapshots:
1. Fill with █ block characters using fg={sameColor} so the text matches the background 2. Use position="absolute" on the text wrapper so it doesn't affect flex layout 3. Use overflow="hidden" on the parent to clip the text to the box bounds
<box flexGrow={value} backgroundColor={color} overflow="hidden">
<box position="absolute" width="100%" height="100%" overflow="hidden">
<text fg={color}>{'█'.repeat(200)}</text>
</box>
</box>Without position="absolute", wrapping text drives the box height and overrides flexGrow proportions. The absolute positioning removes the text from flex layout, keeping the parent height purely from flexGrow.
chart components naming
Graph— line chart (braille/block chars, custom Renderable, with axes)BarChart— horizontal stacked bar (flexbox, no axes, proportional segments)BarGraph— vertical stacked bar chart (flexbox with█fill, gaps between bars, x-axis labels, compact legend)
All three use the same getThemePalette() color order: accent, info, success, warning, error, secondary, primary.
form components styling
- NEVER make text bold on focus in components. This causes layout shifts when focusing/unfocusing fields. Always maintain consistent text weight regardless of focus state. Instead change background or color or add an unicode character before or after focused text for selection like List does.
important reminders
- never update snapshots yourself. if you want to test something you must read the snapshots yourself after running the tests
- if you run examples use a short timeout. these will hang the process but you will still be able to see the initial output in case you need that. using vitest tests is preferred because you can set cold and rows precisely and see the output after some input keys via tomatchinlinesnapshot
Hooks
hooks, functions starting with use, CANNOT be called inside callbacks or other functions. only in the component scope level!
this code is invalid:
<Controller
name={props.id}
control={control}
defaultValue={props.defaultValue || props.value || ''}
render={({ field, fieldState, formState }) => {
// Store selected title for display
// ❌ INVALID: React hooks like useState cannot be called inside render props or callbacks
// Instead, move hooks to the top-level of your component, not inside the render prop
// The below is incorrect usage and will cause React errors
const [selectedTitle, setSelectedTitle] = React.useState<string>('')
const [dropdownItems, setDropdownItems] = React.useState<FormDropdownItemDescendant[]>([])
// ...rest of render logic
return (
/* JSX goes here */
)
}}
/>To resolve this issue you can create a different component to pass in render:
function MyRenderComponent({ field, fieldState, formState }) {
const [selectedTitle, setSelectedTitle] = React.useState<string>('')
const [dropdownItems, setDropdownItems] = React.useState<FormDropdownItemDescendant[]>([])
// ...rest of render logic
return (
/* JSX goes here */
)
}
// ...
<Controller
name={props.id}
control={control}
defaultValue={props.defaultValue || props.value || ''}
render={(args) => <MyRenderComponent {...args} />}
/>Or lift hooks in component scope
NEVER use setTimeout
setTimeout must never be used to schedule React updates after some time. This strategy is stupid and never makes sense.
---
Submodules
the folders tuistory and ghostty-opentui are submodules. they should always stay in branch main and not be detached. do not commit unless asked.
tuistory
this is a package to test tui interfaces.
if there are issues with ANSI sequences in the snapshots the problem is probably in the package ghostty-opentui. which is where most of terminal rendering logic is
The following folders are git submodules:
tuistory/- Package for testing TUI interfacesghostty-opentui/- Zig/Ghostty terminal emulation library
Submodule Detached HEAD Issue
Git submodules frequently end up in a "detached HEAD" state. This happens because:
1. Submodules track commits, not branches - The parent repo stores a specific commit SHA, not a branch name like "main" 2. `git submodule update` checks out commits - Running git submodule update or cloning with --recurse-submodules checks out that specific SHA, putting you in detached HEAD 3. No branch tracking by default - .gitmodules doesn't specify a branch to follow
Fixing detached HEAD while keeping changes
If you made commits on the detached HEAD:
cd <submodule>
git checkout main
git cherry-pick <commit-sha>... # cherry-pick your commits onto mainOr if no divergence from main:
cd <submodule>
git checkout mainPrevention
After any submodule update, cd into submodules and run git checkout main before making changes.
Submodule Rules
- Submodules should always stay on branch
main, never detached - Do not commit submodule changes unless explicitly asked
- Each submodule has its own AGENTS.md with package-specific guidelines
OAuth System
Termcast uses an OAuth proxy hosted on termcast.app to handle OAuth for Raycast extensions. This allows extensions to authenticate with providers like GitHub, Linear, Slack, etc. without needing their own OAuth apps.
Architecture
Extension calls OAuthService.github()
↓
Opens browser: https://termcast.app/oauth/github/authorize
↓
termcast.app redirects to GitHub OAuth
↓
User authenticates on GitHub
↓
GitHub redirects to: https://termcast.app/oauth/github/callback
↓
termcast.app redirects to: http://localhost:8989/oauth/callback?code=XXX
↓
Termcast CLI receives code, calls: POST https://termcast.app/oauth/github/token
↓
termcast.app exchanges code for token (using client_secret stored server-side)
↓
Termcast CLI receives and stores access_tokenKey Files
website/src/routes/oauth.$provider.*.tsx- OAuth proxy routes (generic for all providers)website/src/lib/oauth-providers.ts- Provider configuration (URLs, extra params)raycast-utils/- Forked @raycast/utils with termcast.app URLs (branch:termcast-oauth-proxy)termcast/src/apis/oauth.tsx- PKCEClient handles authorization code flowtermcast/src/preload.tsx- Redirects @raycast/utils imports to our fork
Adding a New OAuth Provider
1. Add provider config to website/src/lib/oauth-providers.ts:
export const OAUTH_PROVIDERS = {
// ...
newprovider: {
authorizeUrl: 'https://newprovider.com/oauth/authorize',
tokenUrl: 'https://newprovider.com/oauth/token',
},
}2. Register OAuth app with the provider, set callback URL to: https://termcast.app/oauth/newprovider/callback
3. Set environment variables on website deployment:
NEWPROVIDER_OAUTH_CLIENT_ID=...
NEWPROVIDER_OAUTH_CLIENT_SECRET=...4. If needed, add the provider to raycast-utils/src/oauth/OAuthService.ts
Environment Variables
The website needs these env vars for each provider:
{PROVIDER}_OAUTH_CLIENT_ID- OAuth app client ID{PROVIDER}_OAUTH_CLIENT_SECRET- OAuth app client secret (kept server-side)
Supported providers: github, linear, slack, asana, google, jira, zoom, notion, spotify, dropbox
termcast forms
- tab is used to change focused input
- shift tab goes to the previous focused input
- arrows change selected item inside the focused input. for example in a dropdown
- ctrl p will show the actions available for the form. or ctrl enter to submit it
publish termcast
to publish termcast
- bump termcast/package.json version. never a major bump
- update termcast/CHANGELOG.md with changes that were made. see pas commits if you do not know
- commit
- create a tag with termcast@0.0.0 where 0.0.0 is new version
- push with tags (never trigger release with gh workflow run)
- release script should publish the npm version. and also the binary in gh releases.
- see gh ci for in progress script and make sure they are successful
navigation push() limitation: props will not sync
when rendering an element with push the props passed will not be dynamic. instead if you need the child pushed element to react on parent state changes you must use zustand state. if this state is local you can create the zustand state inside useMemo() or const [store] = useState(() => create<StateType>({})) and pass it down via props.
Extension Execution Modes
termcast supports two ways to run extensions: dev mode and compiled.
Storage Paths
| Mode | Extension Path | SQLite Database |
|---|---|---|
| Dev | Local folder (e.g. ~/my-extension) | {extensionPath}/.termcast-bundle/data.db |
| Compiled | N/A (embedded in binary) | ~/.termcast/{extensionName}/data.db |
For dev mode, the database path is determined by extensionPath in state. For compiled mode, no filesystem path exists - data is stored in user's home directory.
Dev Mode
Entry: startDevMode({ extensionPath })
1. Reads package.json from local extensionPath 2. Builds commands with esbuild (ESM format, bun target) 3. Sets state: extensionPath, extensionPackageJson 4. Shows command list, imports bundled files with cache-busting query param on each rebuild 5. Watches for changes and triggers triggerRebuild()
Compiled Mode
Entry: startCompiledExtension({ packageJson, compiledCommands })
1. Commands are pre-compiled and passed as Component functions 2. packageJson is embedded directly into the binary at compile time (no filesystem reads) 3. Sets state: extensionPackageJson (no extensionPath needed) 4. No build step needed - components are already bundled 5. Binary is fully portable - no hardcoded paths
Preferences
Preferences are stored in SQLite with keys:
- Extension-level:
preferences.{extensionName} - Command-level:
preferences.{extensionName}.{commandName}
The ExtensionPreferences component loads preference definitions from package.json at the extension path.
logs
logs that happen during extension execution are output in a local app.log file, in the cwd where the extension was run
Testing extensions
See TESTING_RAYCAST_EXTENSIONS.md for detailed instructions on testing extensions, including how to skip tests in CI when the extension folder doesn't exist.
opentui
opentui is the framework used to render the tui, using react.
IMPORTANT! before starting every task ALWAYS read opentui docs with curl -s https://raw.githubusercontent.com/sst/opentui/refs/heads/main/packages/react/README.md
do this every time you have to edit .tsx files in the project.
React
NEVER NEVER use forwardRef. it is not needed. instead just use a ref prop like React 19 best practice
NEVER pass function or callbacks as dependencies of useEffect, this will very easily cause infinite loops if you forget to use useCallback
NEVER use useCallback other than for ref callbacks. it is useless if we never pass functions in useEffect dependencies
Try to never use useEffect if possible. usually you can move logic directly in event handlers instead
This is not a plain react project, instead it is a project using opentui renderer, which supports box, group, textarea, etc
Styles are implemented via Yoga. there is a style prop to pass an object or you can also pass styles using a prop for each style (which is preferred)
Not all CSS and react style props are implemented. Only flexbox one.
To understand how to use these components read other files in the project. try to use the theme.tsx file for colors.
text wrapping
text elements wrap by default. to disable this pass wrapMode="none"
researching opentui patterns
you can read more examples of opentui react code using gitchamber by listing and reading files from the correct endpoint: https://gitchamber.com/repos/sst/opentui/main/files?glob=packages/react/examples/**
or for example to see how to use the <code> opentui element: https://gitchamber.com/repos/sst/opentui/main/search/<code?glob=\\
do something like this for every new element you want to use and not know about, for exampel <scrollbox>, to see examples
keys
cmd modifier (named hyper in opentui) cannot be intercepted in opentui. because parent terminal app will not forward it. instead use alt or ctrl
enter key is named return in opentui. alt is option.
overlapping text in boxes
if you see text elements too close to each other the issues is probably that the content does not fit in the box row so elements shrink and gaps or paddings are no longer respected.
to fix this issue add flexShrink={0} to all elements inside the row
this common when using wrapMode none.
flushSync
flushSync is exported by @opentui/react, same for createPortal
core guidelines
when summarizing changes at the end of the message, be super short, a few words and in bullet points, use bold text to highlight important keywords. use markdown.
please ask questions and confirm assumptions before generating complex architecture code.
NEVER run commands with & at the end to run them in the background. this is leaky and harmful! instead ask me to run commands in the background using tmux if needed.
NEVER commit yourself unless asked to do so. I will commit the code myself.
NEVER use git to revert files to previous state if you did not create those files yourself! there can be user changes in files you touched, if you revert those changes the user will be very upset!
files
always use kebab case for new filenames. never use uppercase letters in filenames
never write temporary files to /tmp. instead write them to a local ./tmp folder instead. make sure it is in .gitignore too
see files in the repo
use git ls-files | tree --fromfile to see files in the repo. this command will ignore files ignored by git
handling unexpected file contents after a read or write
if you find code that was not there since the last time you read the file it means the user or another agent edited the file. do not revert the changes that were added. instead keep them and integrate them with your new changes
IMPORTANT: NEVER commit your changes unless clearly and specifically asked to!
opening me files in zed to show me a specific portion of code
you can open files when i ask me "open in zed the line where ..." using the command zed path/to/file:line
Use tmux to run long-lived background commands as background “tasks” like vite dev servers, commands with watch mode. Each task should be a tmux session that the agent can start, inspect, and stop via CLI.
ALWAYS give long and descriptive names for the sessions, so other agents know what they are for.
Run a background task (e.g. Vite dev server) without blocking:
tmux new-session -d -s project-name-vite-dev-port-8034 'cd /path/to/project && npm run dev --port 8034'Every time you are about to start a new session, first check if there is one already.
List all background tasks (sessions):
tmux lsYou can assume sessions that do not have names were not started by you or agents so you can ignore them
Kill a background task:
tmux kill-session -t vite-devNever attach to a session. You are inside a non TTY terminal, meaning you instead will have to read the latest n logs instead.
Fetch the last N log lines for a task without attaching (returns immediately):
tmux capture-pane -t vite-dev:0 -S -100 -pExample pattern for a coding agent:
1. Start a task:
tmux new-session -d -s build 'cd /repo && npm run build'2. Poll logs:
tmux capture-pane -t build:0 -S -80 -p3. List all running tasks:
tmux ls4. Stop a task when done:
tmux kill-session -t buildgithub
you can use the gh cli to do operations on github for the current repository. For example: open issues, open PRs, check actions status, read workflow logs, etc.
creating issues and pull requests
when opening issues and pull requests with gh cli, never use markdown headings or sections. instead just use simple paragraphs, lists and code examples. be as short as possible while remaining clear and using good English.
example:
gh issue create --title "Fix login timeout" --body "The login form times out after 5 seconds on slow connections. This affects users on mobile networks.
Steps to reproduce:
1. Open login page on 3G connection
2. Enter credentials
3. Click submit
Expected: Login completes within 30 seconds
Actual: Request times out after 5 seconds
Error in console:
\`\`\`bash
Error: Request timeout at /api/auth/login
\`\`\`"get current github repo
git config --get remote.origin.url
checking status of latest github actions workflow run
gh run list # lists latest actions runs
gh run watch <id> --exit-status # if workflow is in progress, wait for the run to complete. the actions run is finished when this command exits. Set a tiemout of at least 10 minutes when running this command
gh pr checks --watch --fail-fast # watch for current branch pr ci checks to finish
gh run view <id> --log-failed | tail -n 300 # read the logs for failed steps in the actions run
gh run view <id> --log | tail -n 300 # read all logs for a github actions runresponding to PR reviews and comments (gh-pr-review extension)
# view reviews and get thread IDs
gh pr-review review view 42 -R owner/repo --unresolved
# reply to a review comment
gh pr-review comments reply 42 -R owner/repo \
--thread-id PRRT_kwDOAAABbcdEFG12 \
--body "Fixed in latest commit"
# resolve a thread
gh pr-review threads resolve 42 -R owner/repo --thread-id PRRT_kwDOAAABbcdEFG12reading github repos source code
opensrc zod # npm package name
# Using github: prefix
opensrc github:owner/repo
# Using owner/repo shorthand
opensrc facebook/react
# Using full GitHub URL
opensrc https://github.com/colinhacks/zod
# Fetch a specific branch or tag
opensrc owner/repo@v1.0.0
opensrc owner/repo#main
# Mix packages and reposThis will download the source code in ./opensrc. which should be put in .gitignore
typescript
- ALWAYS use normal imports instead of dynamic imports, unless there is an issue with es module only packages and you are in a commonjs package (this is rare).
- when throwing errors always use clause instead of error inside message:
new Error("wrapping error", { cause: e })instead ofnew Error(\wrapping error ${e}\)
- use a single object argument instead of multiple positional args: use object arguments for new typescript functions if the function would accept more than one argument, so it is more readable, ({a,b,c}) instead of (a,b,c). this way you can use the object as a sort of named argument feature, where order of arguments does not matter and it's easier to discover parameters.
- always add the {} block body in arrow functions: arrow functions should never be written as
onClick={(x) => setState('')}. NEVER. instead you should ALWAYS writeonClick={() => {setState('')}}. this way it's easy to add new statements in the arrow function without refactoring it.
- in array operations .map, .filter, .reduce and .flatMap are preferred over .forEach and for of loops. For example prefer doing
.push(...array.map(x => x.items))over mutating array variables inside for loops. Always think of how to turn for loops into expressions using .map, .filter or .flatMap if you ever are about to write a for loop.
- if you encounter typescript errors like "undefined | T is not assignable to T" after .filter(Boolean) operations: use a guarded function instead of Boolean:
.filter(isTruthy). implemented asfunction isTruthy<T>(value: T): value is NonNullable<T> { return Boolean(value) }
- minimize useless comments: do not add useless comments if the code is self descriptive. only add comments if requested or if this was a change that i asked for, meaning it is not obvious code and needs some inline documentation. if a comment is required because the part of the code was result of difficult back and forth with me, keep it very short.
- ALWAYS add all information encapsulated in my prompt to comments: when my prompt is super detailed and in depth, all this information should be added to comments in your code. this is because if the prompt is very detailed it must be the fruit of a lot of research. all this information would be lost if you don't put it in the code. next LLM calls would misinterpret the code and miss context.
- NEVER write comments that reference changes between previous and old code generated between iterations of our conversation. do that in prompt instead. comments should be used for information of the current code. code that is deleted does not matter.
- use early returns (and breaks in loops): do not nest code too much. follow the go best practice of if statements: avoid else, nest as little as possible, use top level ifs. minimize nesting. instead of doing
if (x) { if (b) {} }you should doif (x && b) {};for example. you can always convert multiple nested ifs or elses into many linear ifs at one nesting level. use the @think tool for this if necessary.
- typecheck after updating code: after any change to typescript code ALWAYS run the
pnpm typecheckscript of that package, or if there is no typecheck script runpnpm tscyourself
- do not use any: you must NEVER use any. if you find yourself using
as anyor:any, use the @think tool to think hard if there are types you can import instead. do even a search in the project for what the type could be. any should be used as a last resort.
- NEVER do
(x as any).fieldor'field' in xbefore checking if the code compiles first without it. the code probably doesn't need any or the in check. even if it does not compile, use think tool first! before adding (x as any).something, ALWAYS read the .d.ts to understand the types
- do not declare uninitialized variables that are defined later in the flow. instead use an IIFE with returns. this way there is less state. also define the type of the variable before the iife. here is an example:
- use || over in: avoid 'x' in obj checks. prefer doing
obj?.x || ''over doing'x' in obj ? obj.x : ''. only use the in operator if that field causes problems in typescript checks because typescript thinks the field is missing, as a last resort.
- when creating urls from a path and a base url, prefer using
new URL(path, baseUrl).toString()instead of normal string interpolation. use type-safe react-routerhrefor spiceflowthis.safePath(available inside routes) if possible
- for node built-in imports, never import singular exported names. instead do
import fs from 'node:fs', same for path, os, etc.
- NEVER start the development server with pnpm dev yourself. there is no reason to do so, even with &
- When creating classes do not add setters and getters for a simple private field. instead make the field public directly so user can get it or set it himself without abstractions on top
- if you encounter typescript lint errors for an npm package, read the node_modules/package/\*.d.ts files to understand the typescript types of the package. if you cannot understand them, ask me to help you with it.
- NEVER silently suppress errors in catch {} blocks if they contain more than one function call
// BAD. DO NOT DO THIS
let favicon: string | undefined;
if (docsConfig?.favicon) {
if (typeof docsConfig.favicon === "string") {
favicon = docsConfig.favicon;
} else if (docsConfig.favicon?.light) {
// Use light favicon as default, could be enhanced with theme detection
favicon = docsConfig.favicon.light;
}
}
// DO THIS. use an iife. Immediately Invoked Function Expression
const favicon: string = (() => {
if (!docsConfig?.favicon) {
return "";
}
if (typeof docsConfig.favicon === "string") {
return docsConfig.favicon;
}
if (docsConfig.favicon?.light) {
// Use light favicon as default, could be enhanced with theme detection
return docsConfig.favicon.light;
}
return "";
})();
// if you already know the type use it:
const favicon: string = () => {
// ...
};- when a package has to import files from another packages in the workspace never add a new tsconfig path, instead add that package as a workspace dependency using
pnpm i "package@workspace:*"
NEVER use require. always esm imports
always try to use non-relative imports. each package has an absolute import with the package name, you can find it in the tsconfig.json paths section. for example, paths inside website can be imported from website. notice these paths also need to include the src directory.
this is preferable to other aliases like @/ because i can easily move the code from one package to another without changing the import paths. this way you can even move a file and import paths do not change much.
always specify the type when creating arrays, especially for empty arrays. if you don't, typescript will infer the type as never[], which can cause type errors when adding elements later.
Example:
// BAD: Type will be never[]
const items = [];
// GOOD: Specify the expected type
const items: string[] = [];
const numbers: number[] = [];
const users: User[] = [];remember to always add the explicit type to avoid unexpected type inference.
- when using nodejs APIs like fs always import the module and not the named exports. I prefer hacing nodejs APIs accessed on the module namspace like fs, os, path, etc.
DO import fs from 'fs'; fs.writeFileSync(...) DO NOT import { writeFileSync } from 'fs';
- NEVER pass a string to abortController.abort(). instead if you want to pass a reason always pass an Error instance. like
controller.abort(new Error('reason')). This way catch blocks receive an Error instance and not something else.
github
you can use the gh cli to do operations on github for the current repository. For example: open issues, open PRs, check actions status, read workflow logs, etc.
creating issues and pull requests
when opening issues and pull requests with gh cli, never use markdown headings or sections. instead just use simple paragraphs, lists and code examples. be as short as possible while remaining clear and using good English.
example:
gh issue create --title "Fix login timeout" --body "The login form times out after 5 seconds on slow connections. This affects users on mobile networks.
Steps to reproduce:
1. Open login page on 3G connection
2. Enter credentials
3. Click submit
Expected: Login completes within 30 seconds
Actual: Request times out after 5 seconds
Error in console:
\`\`\`bash
Error: Request timeout at /api/auth/login
\`\`\`"get current github repo
git config --get remote.origin.url
checking status of latest github actions workflow run
gh run list # lists latest actions runs
gh run watch <id> --exit-status # if workflow is in progress, wait for the run to complete. the actions run is finished when this command exits. Set a tiemout of at least 10 minutes when running this command
gh pr checks --watch --fail-fast # watch for current branch pr ci checks to finish
gh run view <id> --log-failed | tail -n 300 # read the logs for failed steps in the actions run
gh run view <id> --log | tail -n 300 # read all logs for a github actions runresponding to PR reviews and comments (gh-pr-review extension)
# view reviews and get thread IDs
gh pr-review review view 42 -R owner/repo --unresolved
# reply to a review comment
gh pr-review comments reply 42 -R owner/repo \
--thread-id PRRT_kwDOAAABbcdEFG12 \
--body "Fixed in latest commit"
# resolve a thread
gh pr-review threads resolve 42 -R owner/repo --thread-id PRRT_kwDOAAABbcdEFG12reading github repos source code
opensrc zod # npm package name
# Using github: prefix
opensrc github:owner/repo
# Using owner/repo shorthand
opensrc facebook/react
# Using full GitHub URL
opensrc https://github.com/colinhacks/zod
# Fetch a specific branch or tag
opensrc owner/repo@v1.0.0
opensrc owner/repo#main
# Mix packages and reposThis will download the source code in ./opensrc. which should be put in .gitignore
react
- never test react code. instead put as much code as possible in react-agnostic functions or classes and test those if needed.
- hooks, all functions that start with use, MUST ALWAYS be called in the component render scope, never inside other closures in the component or event handlers. follow react rules of hooks.
- always put all hooks at the start of component functions. put hooks that are bigger and longer later if possible. all other non-hooks logic should go after hooks section, things like conditionals, expressions, etc
react code
useEffectis bad: the use of useEffect is discouraged. please do not use it unless strictly necessary. before using useEffect call the @think tool to make sure that there are no other options. usually you can colocate code that runs inside useEffect to the functions that call that useEffect dependencies setState instead
- too many
useStatecalls are bad. if some piece of state is dependent on other state just compute it as an expression in render. do not add new state unless strictly necessary. before adding a new useState to a component, use @think tool to think hard if you can instead: use expression with already existing local state, use expression with some global state, use expression with loader data, use expression with some other existing variable instead. for example if you need to show a popover when there is an error you should use the error as open state for the popover instead of adding new useState hook
useCallbackis bad. it should be always avoided unless for ref props. ref props ALWAYS need to be passed memoized functions or the component could remount on ever render!
- NEVER pass functions to useEffect or useMemo dependencies. when you start passing functions to hook dependencies you need to add useCallback everywhere in the code, useCallback is a virus that infects the codebase and should be ALWAYS avoided.
- custom hooks are bad. NEVER add custom hooks unless asked to do so by me. instead of creating hooks create generic react-independent functions. every time you find yourself creating a custom hook call @think and think hard if you can just create a normal function instead, or just inline the expression in the component if small enough
- minimize number of props. do not use props if you can use zustand state instead. the app has global zustand state that lets you get a piece of state down from the component tree by using something like
useStore(x => x.something)oruseLoaderData<typeof loader>()or even useRouteLoaderData if you are deep in the react component tree
- do not consider local state truthful when interacting with server. when interacting with the server with rpc or api calls never use state from the render function as input for the api call. this state can easily become stale or not get updated in the closure context. instead prefer using zustand
useStore.getState().stateValue. notice that useLoaderData or useParams should be fine in this case.
- when using useRef with a generic type always add undefined in the call, for example
useRef<number>(undefined). this is required by the react types definitions
- when using && in jsx make sure that the result type is not of type number. in that case add Boolean() wrapper. this way jsx will not show zeros when the value is falsy.
components
- place new components in the src/components folder. shadcn components will go to the src/components/ui folder, usually they are not manually updated but added with the shadcn cli (which is preferred to be run without npx, either with pnpm or globally just shadcn)
- component filenames should follow kebab case structure
- do not create a new component file if this new code will only be used in another component file. only create a component file if the component is used by multiple components or routes. colocate related components in the same file.
- non component code should be put in the src/lib folder.
- hooks should be put in the src/hooks.tsx file. do not create a new file for each new hook. also notice that you should never create custom hooks, only do it if asked for.
zustand
zustand is the preferred way to created global React state. put it in files like state.ts or x-state.ts where x is something that describe a portion of app state in case of multiple global states or multiple apps
- NEVER add zustand state setter methods. instead use useStore.setState to set state. For example never add a method
setVariablein the state type. Instead callsetStatedirectly
- zustand already merges new partial state with the previous state. NEVER DO
useStore.setState({ ...useStore.getInitialState(), ... })unless for resetting state
non controlled input components
some components do not have a value prop to set the value via React state. these are called uncontrolled components. Instead they usually let you get the current input value via ref. something like ref.current.value. They usually also have an onChange prop that let you know when the value changes
these usually have a initialValue or defaultValue to programmatically set the initial value of the input
when using these components you SHOULD not track their state via React: instead you should programmatically set their value and read their value via refs in event handlers
tracking uncontrolled inputs via React state means that you will need to add useEffect to programmatically change their value when our state changes. this is an anti pattern. instead you MUST keep in mind the uncontrolled input manages its own state and we interface with it via refs and initialValue prop.
using React state in these cases is only necessary if you have to show the input value during render. if that is not the case you can just use inputRef.current.value instead and set the value via inputRef.current.value = something
testing
.toMatchInlineSnapshot is the preferred way to write tests. leave them empty the first time, update them with -u. check git diff for the test file every time you update them with -u
never use timeouts longer than 5 seconds for expects and other statements timeouts. increase timeouts for tests if required, up to 1 minute
do not create dumb tests that test nothing. do not write tests if there is not already a test file or describe block for that function or module.
if the inputs for the tests is an array of repetitive fields and long content, generate this input data programmatically instead of hardcoding everything. only hardcode the important parts and generate other repetitive fields in a .map or .reduce
tests should validate complex and non-obvious logic. if a test looks like a placeholder, do not add it.
use vitest or bun test to run tests. tests should be run from the current package directory and not root. try using the test script instead of vitest directly. additional vitest flags can be added at the end, like --run to disable watch mode or -u to update snapshots.
to understand how the code you are writing works, you should add inline snapshots in the test files with expect().toMatchInlineSnapshot(), then run the test with pnpm test -u --run or pnpm vitest -u --run to update the snapshot in the file, then read the file again to inspect the result. if the result is not expected, update the code and repeat until the snapshot matches your expectations. never write the inline snapshots in test files yourself. just leave them empty and run pnpm test -u --run to update them.
always callpnpm vitestorpnpm testwith--runor they will hang forever waiting for changes!
ALWAYS read back the test if you use the -u option to make sure the inline snapshots are as you expect.- NEVER write the snapshots content yourself in
toMatchInlineSnapshot. instead leave it as is and callpnpm test -uto fill in snapshots content. the first time you calltoMatchInlineSnapshot()you can leave it empty
- when updating implementation and
toMatchInlineSnapshotshould change, DO NOT remove the inline snapshots yourself, just runpnpm test -uinstead! This will replace contents of the snapshots without wasting time doing it yourself.
- for very long snapshots you should use
toMatchFileSnapshot(filename)instead oftoMatchInlineSnapshot(). put the snapshot files in a snapshots/ directory and use the appropriate extension for the file based on the content
never test client react components. only React and browser independent code.
most tests should be simple calls to functions with some expect calls, no mocks. test files should be called the same as the file where the tested function is being exported from.
NEVER use mocks. the database does not need to be mocked, just use it. simply do not test functions that mutate the database if not asked.
tests should strive to be as simple as possible. the best test is a simple .toMatchInlineSnapshot() call. these can be easily evaluated by reading the test file after the run passing the -u option. you can clearly see from the inline snapshot if the function behaves as expected or not.
try to use only describe and test in your tests. do not use beforeAll, before, etc if not strictly required.
NEVER write tests for react components or react hooks. NEVER write tests for react components. you will be fired if you do.
sometimes tests work directly on database data, using prisma. to run these tests you have to use the package.json script, which will call doppler run -- vitest or similar. never run doppler cli yourself as you could delete or update production data. tests generally use a staging database instead.
never write tests yourself that call prisma or interact with database or emails. for these, ask the user to write them for you.
changelogs.md
writing docs
when generating a .md or .mdx file to document things, always add a frontmatter with title and description. also add a prompt field with the exact prompt used to generate the doc. use @ to reference files and urls and provide any context necessary to be able to recreate this file from scratch using a model. if you used urls also reference them. reference all files you had to read to create the doc. use yaml | syntax to add this prompt and never go over the column width of 80 goke.md
zod
when you need to create a complex type that comes from a prisma table, do not create a new schema that tries to recreate the prisma table structure. instead just use z.any() as ZodType<PrismaTable>) to get type safety but leave any in the schema. this gets most of the benefits of zod without having to define a new zod schema that can easily go out of sync.
converting zod schema to jsonschema
you MUST use the built in zod v4 toJSONSchema and not the npm package zod-to-json-schema which is outdated and does not support zod v4.
import { toJSONSchema } from "zod";
const mySchema = z.object({
id: z.string().uuid(),
name: z.string().min(3).max(100),
age: z.number().min(0).optional(),
});
const jsonSchema = toJSONSchema(mySchema, {
removeAdditionalStrategy: "strict",
});Scrollbox with Descendants Pattern
How to add scrollbox support to opentui components using the descendants pattern.
Overview
1. Store element refs in descendant props 2. Track selected index in parent 3. On selection change, scroll so the top of the item is centered in the viewport
Implementation
1. Add elementRef to descendant type
interface ItemDescendant {
title: string
elementRef?: BoxRenderable | null
}
const { DescendantsProvider, useDescendants, useDescendant } =
createDescendants<ItemDescendant>()2. Parent: scrollToItem function
const scrollBoxRef = React.useRef<any>(null)
const scrollToItem = (item: { props?: ItemDescendant }) => {
const scrollBox = scrollBoxRef.current
const elementRef = item.props?.elementRef
if (!scrollBox || !elementRef) return
const contentY = scrollBox.content?.y || 0
const viewportHeight = scrollBox.viewport?.height || 10
// Calculate item position relative to content
const itemTop = elementRef.y - contentY
// Scroll so the top of the item is centered in the viewport
const targetScrollTop = itemTop - viewportHeight / 2
scrollBox.scrollTo(Math.max(0, targetScrollTop))
}3. Parent: call scrollToItem on move
const move = (direction: -1 | 1) => {
const items = Object.values(context.map.current)
.filter((item) => item.index !== -1)
.sort((a, b) => a.index - b.index)
let nextIndex = selectedIndex + direction
// wrap around
if (nextIndex < 0) nextIndex = items.length - 1
if (nextIndex >= items.length) nextIndex = 0
const nextItem = items[nextIndex]
if (nextItem) {
setSelectedIndex(nextIndex)
scrollToItem(nextItem)
}
}4. Item: capture ref and pass to descendant
function Item(props: { title: string; isSelected: boolean }) {
const elementRef = React.useRef<BoxRenderable>(null)
useDescendant({
title: props.title,
elementRef: elementRef.current,
})
return (
<box ref={elementRef}>
<text>{props.isSelected ? '›' : ' '}{props.title}</text>
</box>
)
}Full Example
See src/examples/internal/scrollbox-with-descendants.tsx
<!-- opensrc:start -->
Source Code Reference
Source code for dependencies is available in opensrc/ for deeper understanding of implementation details.
See opensrc/sources.json for the list of available packages and their versions.
Use this source code when you need to understand how a package works internally, not just its types/interface.
Fetching Additional Source Code
To fetch source code for a package or repository you need to understand, run:
npx opensrc <package> # npm package (e.g., npx opensrc zod)
npx opensrc pypi:<package> # Python package (e.g., npx opensrc pypi:requests)
npx opensrc crates:<package> # Rust crate (e.g., npx opensrc crates:serde)
npx opensrc <owner>/<repo> # GitHub repo (e.g., npx opensrc vercel/ai)<!-- opensrc:end -->
AGENTS.md
Contributing to Termcast
Termcast is a terminal-based implementation of the Raycast API, allowing Raycast extensions to run natively in the terminal using React and OpenTUI.
Quick Start
# Clone and setup
git clone https://github.com/remorses/termcast
cd termcast/termcast
bun install
# Run examples
bun --watch src/examples/simple-list.tsx
# Run tests
bun test
bun e2e src/examples/simple-list.vitest.tsx -u # -u to update snapshotsProject Structure
Core Files
src/
├── cli.tsx # Main CLI entry point (termcast command)
├── index.tsx # Public API exports (termcast)
├── state.tsx # Global state management (Zustand)
├── descendants.tsx # Component hierarchy management pattern
├── utils.tsx # Utilities + extension store management
└── logger.tsx # Logging utilities
src/components/ # Raycast API components
├── list.tsx # List component with search, sections, items
├── form/ # Form components (TextField, Dropdown, etc.)
├── actions.tsx # Action system (Ctrl+K menus)
├── detail.tsx # Detail view with markdown
└── alert.tsx # Alert dialogs
src/apis/ # Raycast APIs (non-component)
├── ai.tsx # AI API for language models
├── cache.tsx # Persistent cache storage
├── clipboard.tsx # Clipboard operations
├── environment.tsx # Environment info (theme, paths, etc.)
├── localstorage.tsx # Key-value storage
├── oauth.tsx # OAuth authentication flows
├── preferences.tsx # Extension preferences management
├── toast.tsx # Toast notifications
└── window.tsx # Window management
src/internal/ # Internal framework utilities
├── navigation.tsx # Stack-based navigation (push/pop views)
├── dialog.tsx # Overlay system (action panels, dropdowns)
├── focus-context.tsx # Focus management for keyboard events
└── providers.tsx # React context providers
src/extensions/ # Built-in extensions
├── store.tsx # Extension store (browse/install from Raycast)
├── home.tsx # Home screen showing installed extensions
└── dev.tsx # Development mode UI (termcast dev)
src/examples/ # Usage examples (also serve as tests)
├── *.tsx # Component examples
└── *.vitest.tsx # E2E tests for examplesKey Concepts
Raycast Components & APIs
- Components (
src/components/) - React components for UI (List, Form, Detail, etc.) - APIs (
src/apis/) - Non-component Raycast APIs for functionality: - Storage APIs:
cache,localstorage,preferences - System APIs:
clipboard,environment,window - Integration APIs:
oauth,ai - Notification:
toast
Descendants Pattern (src/descendants.tsx)
- Manages parent-child relationships (List.Item, Form.TextField)
- Enables keyboard navigation across dynamic children
- Access
map.currentonly in event handlers, never during render
Focus Management (src/internal/focus-context.tsx)
- Essential for keyboard handling in terminal environment
- Always check
useIsInFocus()before handling keyboard events
Navigation (src/internal/navigation.tsx)
- Stack-based navigation similar to Raycast
- Use
push()to show new views,pop()to go back
Extensions
- Store (
src/extensions/store.tsx) - Browse and install from Raycast store - Home (
src/extensions/home.tsx) - Default screen showing installed extensions - Dev (
src/extensions/dev.tsx) - Development mode for local extensions
Development Workflow
Creating Components
1. Read Raycast API docs:
curl -s https://developers.raycast.com/api-reference/user-interface/list.md2. Read OpenTUI docs (required):
curl -s https://raw.githubusercontent.com/sst/opentui/refs/heads/main/packages/react/README.md3. Create component matching Raycast API 4. Add example in src/examples/ 5. Test with bun e2e
Testing
# Unit tests
bun test
# E2E tests
bun e2e # Run all
bun e2e src/examples/list.vitest.tsx -u # Update snapshots
# Type checking
bun run tscCode Style
- Always use
termcastimports (not relative paths) - Use
.tsxextension for all files - No
console.log- uselogger.loginstead - Prefer object arguments for functions with 2+ parameters
- Minimize
useEffect- prefer event handlers
Common Commands
# Development
termcast dev <path> # Develop extension locally
termcast build <path> # Build extension
termcast install <path> # Install to local store
# Usage
termcast # Open home screen
termcast store # Browse extension storeHappy contributing! 🚀
╱╱╱╱╱╱ Charm™ v0.10.0 ╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱
╱╱╱╱╱╱ ▄▀▀▀▀ █▀▀▀▀▀▀▀▀▀▀▄ █ █ ▄▀▀▀▀ █ █ ╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱
╱╱╱╱╱╱ █ █▀▀▀▀▀▀▀▀▀▀▄ █ █ ▀▀▀▀█ █▀▀▀█ ╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱
╱╱╱╱╱╱ ╭────────────────────────────────────────────────────────────────────╮╱╱╱╱
│ Commands ╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱ │
~/Docum│ │
│ > Type to filter │
◇ GPT-4│ │
│ New Session ctrl+n │
LSPs │ Switch Session ctrl+s │
│ Switch Model │
None │ Toggle Yolo Mode │
│ Toggle Help ctrl+g │
│ Initialize Project │
│ Quit ctrl+c │
│ │
│ tab switch selection • ↑↓ choose • enter confirm • esc cancel │
╰────────────────────────────────────────────────────────────────────╯
> Ready!
:::
:::
ctrl+p commands • shift+enter newline • ctrl+c quit • ctrl+g more
{
"name": "onecode",
"private": true,
"type": "module",
"scripts": {
"scripts": "bun run src/run.tsx"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.3"
}
}
// Runs a Claude Agent SDK query and logs every event.
import { query } from "@anthropic-ai/claude-agent-sdk";
const prompt = "Please run the command `ls --color=always` and return the output.";
async function main(): Promise<void> {
const stream = query({
prompt,
options: {
cwd: process.cwd(),
tools: { type: "preset", preset: "claude_code" },
allowedTools: ["Bash"],
canUseTool: async () => {
return { behavior: "allow" };
},
},
});
for await (const event of stream) {
console.log(event);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
{
"name": "root",
"private": true,
"scripts": {
"format": "prettier --write .",
"agents.md": "agentsdotmd ./PREFIX.md ./termcast/EXTENSIONS.md opentui.md core.md tmux.md github.md typescript.md github.md react.md vitest.md changelog.md docs-writing.md cac.md zod.md ./termcast/RAYCAST_SCROLLING.md",
"release": "cd termcast && bun run release",
"e2e": "cd termcast && bun e2e"
},
"devDependencies": {
"@changesets/cli": "^2.29.6",
"@types/figlet": "^1.7.0",
"@types/react": "^19.2.14",
"prettier": "^3.6.2",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vite": "^7.3.1",
"vitest": "^4.0.18"
},
"repository": "https://github.com/remorses/",
"author": "remorses <beats.by.morse@gmail.com>",
"license": "",
"workspaces": [
"./*"
],
"packageManager": "bun@1.3.3",
"trustedDependencies": [
"@parcel/watcher",
"@tailwindcss/oxide",
"@xmorse/rfd",
"esbuild",
"node-pty",
"node-pty-prebuilt-multiarch",
"node-window-manager",
"extract-file-icon",
"node-gyp-build"
],
"dependencies": {
"@parcel/watcher": "^2.5.6",
"@parcel/watcher-darwin-arm64": "^2.5.6",
"bun-types": "^1.3.9",
"figlet": "^1.9.4"
}
}
Project Coding Guidelines
NOTICE: AGENTS.md is generated using bun agents.md and should NEVER be manually updated. only update PREFIX.md
ALWAYS use bun to install dependencies
---
termcast specific rules
Porting @raycast/api components and hooks to termcast
ALWAYS use termcast to import things, instead of relative imports. This is possible thanks to exports in package.json. for example:
import {List} from 'termcast'
ALWAYS use .tsx extension for every new file.
NEVER use mocks in vitest tests
When running the e2e vitest suite, ALWAYS use the repo scripts (bun e2e, bun e2e <file>, bun e2e -u). NEVER run vitest directly.
prefer object args instead of positional args. as a way to implement named arguments, put the typescript definition inline
see files in the repo
use git ls-files | tree --fromfile to see files in the repo. this command will ignore files ignored by git
Goal
This project ports @raycast/api components and apis to use @opentui/react and other Bun APIs
We are basically implementing the package @raycast/api from scratch. DO NOT implement functions exported by @raycast/utils
This should be done one piece at a time, one hook and component at a time
Porting a new Raycast component or feature
Here is the process to follow to implement each API:
- decide which component or hook or function we are porting
- read the .d.ts of the @raycast/api package for the component or hook
- generate a new file or decide to which file to add this new API in src folder
- start by adding a signature without any actual implementation. Only a function or class or constant without any actual implementation
- try typechecking with
bun run tsc. fix any errors that is not related to the missing implementation (like missing returns) - then think, is the signature the same as Raycast?
- start implementing the component or function, before doing this
- decide on what @opentui/react components to use
- do so by reading opentui .d.ts files and see available components
- read .d.ts to understand available styling options and attributes
- typecheck
- if the added feature is a component or adds support for a new prop for a component, add an example usage component in the src/examples directory. create a descriptive name for it in the file. use simple-{component-name} for basic implementations examples
- if the implemented feature is function or other API, add an action in the file examples/miscellaneus.tsx, add a list item for the new feature, for example "show a error toast" if we are implementing toasts
- do not add an example if our feature is already covered by other example files
- DO NOT run the examples then. instead ask me to do it. do not add these as scripts in package.json
- typecheck to make sure the example is correct
Rules
- for return type of React components just use any
- keep types as close as possible to rayacst
- DO NOT use as any. instead try to understand how to fix the types in other ways
- to implement compound components like
List.Itemfirst define the type of List, using a interface, then use : to implement it and add compound components later using . and omitting the props types given they are already typed by the interface, here is an example - DO NOT use console.log. only use logger.log instead
- <input> uses onInput not onChange. it is passed a simple string value and not an event object
- to render examples components use renderWithProviders not render
- ALWAYS bind all class methods to
thisin the constructor. This ensures methods work correctly when called in any context (callbacks, event handlers, etc). Example:
constructor(options: Options) {
// Initialize properties
this.prop = options.prop
// Bind all methods to this instance
this.method1 = this.method1.bind(this)
this.method2 = this.method2.bind(this)
this.privateMethod = this.privateMethod.bind(this)
}interface ListType {
(props: ListProps): any
Item: (props: ListItemProps) => any
Section: (props: ListSectionProps) => any
}
const List: ListType = (props) => {
// implementation
}
List.Item = (props) => {
// implementation
}
List.Section = (props) => {
// implementation
}keeping the implementation compatible with raycast
the goal of this project is to use same props and api as @racyast/api so try to follow raycast types and behaviour exactly
to understand behaviour (not covered by .d.ts) you MUST read the racyast docs using commands like this one, that reads the List component docs:
curl -s https://developers.raycast.com/api-reference/user-interface/list.md
IMPORTANT! Add the ending .md to fetch markdown! Or it will return html!
You can see the full list of raycast docs pages using
curl -s https://developers.raycast.com/sitemap-pages.xml
NEVER import @raycast/api to reuse their types. we are porting that package into this repo, you cannot import it, instead implement it again
todos
if you cannot port a real implementation for some raycast APIs and instead simulate a "fake" response, always add // TODO comments so i can easily find these later and implement them
zustand
NEVER add zustand state setter methods. instead use useStore.setState to set state.
NEVER do useStore((state) => ({something: state.currentCommandName})). it will trigger an infinite render loop. instead only return scalar values and not objects in zustand state selectors
you can use zustand state from @state.tsx also outside of React using useStore.getState()
NEVER do useStore((state) => ({something: state.currentCommandName})). it will trigger an infinite render loop. instead only return scalar values and not objects in zustand state selectors
zustand already merges new partial state with the previous state. NEVER DO useStore.setState({ ...useStore.getInitialState(), ... }) unless for resetting state
adding new core extensions
when adding core extensions like a store extension that installs other extensions you should carefully manage @state.tsx state, setting it appropriately when navigating to another extension or command
strings with new lines
to create strings with new lines use the dedent package so it is more readable
examples
NEVER run examples yourself with bun src/examples/etc
These will hang. These are made for real people
focus
when you handle key presses with
import { useIsInFocus } from 'termcast/src/internal/focus-context'
const inFocus = useIsInFocus()
useKeyboard((evt) => {
if (!inFocus) return
// ...
// notice that enter is called return in evt.name
})useKeyboard has evt.stopPropagation() you can use to trap focus in specific cases. Handlers dispatch in useEffect registration order: siblings fire in JSX order, children fire before parents (React useEffect is bottom-up). stopPropagation prevents all handlers registered after the current one from firing.
descendants pattern and map.current
Why the descendants pattern is useful
The descendants pattern is essential for building compound components (like List with List.Item, Form with Form.TextField, etc.) because it solves a fundamental React challenge: parent components need to know about and coordinate their children dynamically.
In traditional React, parent components cannot easily:
1. Track which children are rendered and in what order 2. Implement keyboard navigation across children 3. Manage selection state across dynamic children 4. Handle filtering/searching while maintaining correct indexes
The descendants pattern solves this by:
- Automatic indexing: Each child component registers itself and gets a unique index automatically
- Dynamic tracking: Children can be added, removed, or reordered, and the parent stays in sync
- Decoupled state management: Parent manages navigation/selection state without tightly coupling to children
- Composition friendly: Works with any level of nesting and conditional rendering
This is why Raycast components like List, Form, and Grid use this pattern - it enables rich keyboard navigation and selection across dynamically rendered items without requiring explicit index props or brittle parent-child contracts.
useDescendant return values
The useDescendant hook returns { index, descendantId }:
index: The current position of the item in the rendered list (changes when items are filtered/reordered)descendantId: A stable unique ID for the item (remains constant for the component's lifetime)
IMPORTANT: Always use descendantId (not index) for tracking item-specific state like:
- Selection state (which items are selected)
- Expanded/collapsed state
- Item-specific data
Using index for state tracking is incorrect because when items are conditionally rendered or filtered, a single index can be associated with different items at different times. The descendantId provides a stable identity that persists across re-renders and filtering.
Example from the descendants example:
// CORRECT: Using descendantId for selection tracking
const isSelected = selectedIds.has(descendant.descendantId)
// WRONG: Using index for selection tracking
// const isSelected = selectedIndexes.has(descendant.index)Important implementation notes
IMPORTANT: When using the descendants pattern from src/descendants.tsx, the map.current from useDescendants() is NOT reactive and CANNOT be used during render. It can only be accessed inside:
- useEffect or useLayoutEffect to handle effects
- Event handlers (useKeyboard, onChange, etc)
.map.current CANNOT be called inside render or useMemo!
Example of WRONG usage (accessing map.current during render):
// WRONG - this will not update when descendants change
const items = Object.values(descendantsContext.map.current)Example of CORRECT usage (accessing map.current inside an event handler, such as with useKeyboard, see @src/examples/internal/descendants.tsx):
import { useKeyboard } from '@opentui/react'
import { useDescendants } from 'termcast/src/descendants'
const { map } = useDescendants()
useKeyboard((evt) => {
// Access map.current during useEffect or event handlers, NOT during render
const items = Object.values(map.current)
.filter((item) => item.index !== -1)
.sort((a, b) => a.index - b.index)
.map((item) => item.props)
// Handle your logic with items, e.g. navigating with up/down
})You CANNOT use .map.current to render items of a list for example. Instead move the rendering in the items themselves! To handle filtering render null in the item component and pass the search query via context
read file @src/examples/internal/descendants.tsx for a real usage example with selection, navigation, pagination, submit support.
tuistory
tuistory is used for e2e tests. After any change to tuistory source files, you must rebuild it:
cd tuistory && bun run buildnode-pty version requirement
tuistory uses node-pty for PTY spawning. Use node-pty version 0.10.1 - newer versions (like 1.1.0) cause posix_spawnp failed errors in vitest. If e2e tests fail with spawn errors, check tuistory/package.json and ensure node-pty is pinned to 0.10.1:
"optionalDependencies": {
"node-pty": "0.10.1"
}After changing the version, run bun install in the tuistory folder and rebuild.
testing
bun must be used to write tests
inline snapshots with .toMatchInlineSnapshots or other snapshots are the preferred way to test things. NO MOCKS.
never update inline snapshots manually, instead always use bun test -u to update snapshots. No need to reset snapshots before updating them with -u
some tests in src/examples end with .vitest.tsx. to run these you will need to use bun e2e -u
for example bun e2e src/examples/form-dropdown.vitest.tsx
these tests are for ensuring the examples work correctly
important: when esc is pressed when there is no navigation stack or toast it will exit the process of the tui. make sure to not do this in tests
fixing bugs in termcast
when you are trying to fix an issue identify first the issue in an existing .vitest.tsx test file. by looking if the existing snapshots already exhibit the issue. if not add a new test case for the issue.
then iterate to
- try to fix the issue by changing code in src
- run tests again
- read back the test snapshot. if not fixed repeat
- try to keep changes minimal to fix the issue
adding a test for an example in src/examples
To see an example of a test see @src/examples/list-with-sections.vitest.tsx
you should first understand what the example file does and which key sequences should be used to test it
then create a file ending with .vitest.tsx with same basename as the example.
then add empty .toMatchInlineSnapshot() calls for every expected output
run bun tsc to make sure it typechecks. if some keys you are trying to press are missing add them in the e2e-node.tsx file as methods.
then run bun test -u to update the snapshots
read back the inline snapshots and make sure they are what you expect
after validating snapshots are correct, add 1-2 expect(text).toContain('keyword') assertions to verify key behavior. use shortest unique string, no whitespace. example:
expect(beforeEnter).toContain('[Undo')
expect(afterEnter).toContain('Undone')notice that await driver.text() already waits for the pty to render so no need to add waitIdle everywhere. only add one if the test seems flakymake sure to pass an adeguate timeout in the test, passing a number as second arg of test
npm diffs
you can see diffs for different npm packages versions using
curl -fs https://npmdiff.dev/%40opentui%2Fcore/0.1.11/0.1.13/
NOTICE the need for using url encoded strings in the path!
this is helpful when an update breaks our code
reading .d.ts for node_modules
you should read the .d.ts for the packages you want to use to discover their API. for opentui you must also read the web guide fetching the .md file.
if you are inside the termcast/termcast folder (the termcast package) you will usually find node modules in the parent folder: ../node_modules/@opentui/core
react code guidelines
- NEVER set state inside a setTimeout. this has no effect and just makes the code more difficult to debug or understand
- NEVER pass children to useEffect depependencies! it makes no sense!
- Try to use as little useEffect or useLayoutEffect as possible. instead put the code directly in the relevant event handlers
- Keep as little useState as possible. computed state should be a simple expression in render if possible
- any useEffect that calls setState for visible UI state (selection, detail content, dialog open) MUST be useLayoutEffect to avoid single-frame flash. see
termcast/docs/flash-debugging.mdfor the full guide - NEVER use flushSync followed by a separate setState for state that should update in the same frame. use useLayoutEffect instead to batch both updates before paint
rendering colored areas in opentui (backgroundColor gotchas)
opentui boxes with backgroundColor but no text children will render visually but produce NO visible characters in session.text() snapshots. The terminal cells exist but ghostty-opentui only reports cells with actual text content.
To make colored areas visible in both visual rendering and text snapshots:
1. Fill with █ block characters using fg={sameColor} so the text matches the background 2. Use position="absolute" on the text wrapper so it doesn't affect flex layout 3. Use overflow="hidden" on the parent to clip the text to the box bounds
<box flexGrow={value} backgroundColor={color} overflow="hidden">
<box position="absolute" width="100%" height="100%" overflow="hidden">
<text fg={color}>{'█'.repeat(200)}</text>
</box>
</box>Without position="absolute", wrapping text drives the box height and overrides flexGrow proportions. The absolute positioning removes the text from flex layout, keeping the parent height purely from flexGrow.
chart components naming
Graph— line chart (braille/block chars, custom Renderable, with axes)BarChart— horizontal stacked bar (flexbox, no axes, proportional segments)BarGraph— vertical stacked bar chart (flexbox with█fill, gaps between bars, x-axis labels, compact legend)
All three use the same getThemePalette() color order: accent, info, success, warning, error, secondary, primary.
form components styling
- NEVER make text bold on focus in components. This causes layout shifts when focusing/unfocusing fields. Always maintain consistent text weight regardless of focus state. Instead change background or color or add an unicode character before or after focused text for selection like List does.
important reminders
- never update snapshots yourself. if you want to test something you must read the snapshots yourself after running the tests
- if you run examples use a short timeout. these will hang the process but you will still be able to see the initial output in case you need that. using vitest tests is preferred because you can set cold and rows precisely and see the output after some input keys via tomatchinlinesnapshot
Hooks
hooks, functions starting with use, CANNOT be called inside callbacks or other functions. only in the component scope level!
this code is invalid:
<Controller
name={props.id}
control={control}
defaultValue={props.defaultValue || props.value || ''}
render={({ field, fieldState, formState }) => {
// Store selected title for display
// ❌ INVALID: React hooks like useState cannot be called inside render props or callbacks
// Instead, move hooks to the top-level of your component, not inside the render prop
// The below is incorrect usage and will cause React errors
const [selectedTitle, setSelectedTitle] = React.useState<string>('')
const [dropdownItems, setDropdownItems] = React.useState<FormDropdownItemDescendant[]>([])
// ...rest of render logic
return (
/* JSX goes here */
)
}}
/>To resolve this issue you can create a different component to pass in render:
function MyRenderComponent({ field, fieldState, formState }) {
const [selectedTitle, setSelectedTitle] = React.useState<string>('')
const [dropdownItems, setDropdownItems] = React.useState<FormDropdownItemDescendant[]>([])
// ...rest of render logic
return (
/* JSX goes here */
)
}
// ...
<Controller
name={props.id}
control={control}
defaultValue={props.defaultValue || props.value || ''}
render={(args) => <MyRenderComponent {...args} />}
/>Or lift hooks in component scope
NEVER use setTimeout
setTimeout must never be used to schedule React updates after some time. This strategy is stupid and never makes sense.
---
Submodules
the folders tuistory and ghostty-opentui are submodules. they should always stay in branch main and not be detached. do not commit unless asked.
tuistory
this is a package to test tui interfaces.
if there are issues with ANSI sequences in the snapshots the problem is probably in the package ghostty-opentui. which is where most of terminal rendering logic is
The following folders are git submodules:
tuistory/- Package for testing TUI interfacesghostty-opentui/- Zig/Ghostty terminal emulation library
Submodule Detached HEAD Issue
Git submodules frequently end up in a "detached HEAD" state. This happens because:
1. Submodules track commits, not branches - The parent repo stores a specific commit SHA, not a branch name like "main" 2. `git submodule update` checks out commits - Running git submodule update or cloning with --recurse-submodules checks out that specific SHA, putting you in detached HEAD 3. No branch tracking by default - .gitmodules doesn't specify a branch to follow
Fixing detached HEAD while keeping changes
If you made commits on the detached HEAD:
cd <submodule>
git checkout main
git cherry-pick <commit-sha>... # cherry-pick your commits onto mainOr if no divergence from main:
cd <submodule>
git checkout mainPrevention
After any submodule update, cd into submodules and run git checkout main before making changes.
Submodule Rules
- Submodules should always stay on branch
main, never detached - Do not commit submodule changes unless explicitly asked
- Each submodule has its own AGENTS.md with package-specific guidelines
OAuth System
Termcast uses an OAuth proxy hosted on termcast.app to handle OAuth for Raycast extensions. This allows extensions to authenticate with providers like GitHub, Linear, Slack, etc. without needing their own OAuth apps.
Architecture
Extension calls OAuthService.github()
↓
Opens browser: https://termcast.app/oauth/github/authorize
↓
termcast.app redirects to GitHub OAuth
↓
User authenticates on GitHub
↓
GitHub redirects to: https://termcast.app/oauth/github/callback
↓
termcast.app redirects to: http://localhost:8989/oauth/callback?code=XXX
↓
Termcast CLI receives code, calls: POST https://termcast.app/oauth/github/token
↓
termcast.app exchanges code for token (using client_secret stored server-side)
↓
Termcast CLI receives and stores access_tokenKey Files
website/src/routes/oauth.$provider.*.tsx- OAuth proxy routes (generic for all providers)website/src/lib/oauth-providers.ts- Provider configuration (URLs, extra params)raycast-utils/- Forked @raycast/utils with termcast.app URLs (branch:termcast-oauth-proxy)termcast/src/apis/oauth.tsx- PKCEClient handles authorization code flowtermcast/src/preload.tsx- Redirects @raycast/utils imports to our fork
Adding a New OAuth Provider
1. Add provider config to website/src/lib/oauth-providers.ts:
export const OAUTH_PROVIDERS = {
// ...
newprovider: {
authorizeUrl: 'https://newprovider.com/oauth/authorize',
tokenUrl: 'https://newprovider.com/oauth/token',
},
}2. Register OAuth app with the provider, set callback URL to: https://termcast.app/oauth/newprovider/callback
3. Set environment variables on website deployment:
NEWPROVIDER_OAUTH_CLIENT_ID=...
NEWPROVIDER_OAUTH_CLIENT_SECRET=...4. If needed, add the provider to raycast-utils/src/oauth/OAuthService.ts
Environment Variables
The website needs these env vars for each provider:
{PROVIDER}_OAUTH_CLIENT_ID- OAuth app client ID{PROVIDER}_OAUTH_CLIENT_SECRET- OAuth app client secret (kept server-side)
Supported providers: github, linear, slack, asana, google, jira, zoom, notion, spotify, dropbox
termcast forms
- tab is used to change focused input
- shift tab goes to the previous focused input
- arrows change selected item inside the focused input. for example in a dropdown
- ctrl p will show the actions available for the form. or ctrl enter to submit it
publish termcast
to publish termcast
- bump termcast/package.json version. never a major bump
- update termcast/CHANGELOG.md with changes that were made. see pas commits if you do not know
- commit
- create a tag with termcast@0.0.0 where 0.0.0 is new version
- push with tags (never trigger release with gh workflow run)
- release script should publish the npm version. and also the binary in gh releases.
- see gh ci for in progress script and make sure they are successful
navigation push() limitation: props will not sync
when rendering an element with push the props passed will not be dynamic. instead if you need the child pushed element to react on parent state changes you must use zustand state. if this state is local you can create the zustand state inside useMemo() or const [store] = useState(() => create<StateType>({})) and pass it down via props.
<div align='center'> <br/> <br/> <h3>termcast</h3> <p>Build terminal user interfaces with React and a Raycast APIs</p> <br/> <br/> </div>
Termcast is a framework for building terminal user interfaces (TUIs) using React and an API inspired by Raycast. It's designed for developers who want to create TUI applications, especially those who already have Raycast extensions and want to port them to the terminal.
Install
# IMPORTANT! this package requires Bun. does not work in Node.js
bun install -g termcast What is Termcast?
Termcast provides a Raycast-like API for building terminal applications. If you're familiar with Raycast extension development, you can use that knowledge to create TUIs that run anywhere—including Linux and remote servers.
This is not a way to run arbitrary Raycast extensions in the terminal. Instead, it's a tool for developers who want to:
- Build TUIs using a familiar, React-based API
- Port existing Raycast extension code to the terminal
- Create standalone CLI tools that can be distributed independently
- Take advantage of terminal-native capabilities
Quick Start
Create a new extension and start developing:
termcast new my-extension
cd my-extension
termcast devUsage
New
Create a new extension from the template:
termcast new <name>Development
Run your extension in dev mode with hot reloading:
termcast devThis watches for file changes and rebuilds automatically.
Compile
Build a standalone executable:
termcast compileRelease
Build and publish to GitHub releases for all platforms:
termcast releaseThis creates binaries for macOS (arm64, x64), Linux (arm64, x64), and Windows, then uploads them to a GitHub release. After release, you'll get an install script URL:
Install script:
curl -sf https://termcast.app/owner/repo/install | bashShare this URL so others can install your TUI with a single command.
Library Usage
You can use termcast as a library to render TUI components programmatically, without the CLI or extension system. Use renderWithProviders to mount any React component with all termcast infrastructure (navigation, dialogs, storage, query cache, theme):
import { renderWithProviders, List, Action, ActionPanel } from 'termcast'
function MyApp() {
return (
<List>
<List.Item
title="Hello World"
actions={
<ActionPanel>
<Action title="Greet" onAction={() => console.log('hi')} />
</ActionPanel>
}
/>
</List>
)
}
await renderWithProviders(<MyApp />, {
extensionName: 'my-app',
})Options:
| Option | Default | Description |
|---|---|---|
extensionName | 'termcast-app' | Used to derive storage paths and extension metadata |
extensionPath | ~/.termcast/compiled/{extensionName} | Where LocalStorage, Cache, and data.db are stored |
packageJson | { name, title, description: '', commands: [] } | Extension metadata for preferences, environment, etc. |
All options are optional. Without any options, storage goes to ~/.termcast/compiled/termcast-app/.
Pass extensionName to isolate storage between different apps. Pass packageJson if you need preferences or command metadata.
Why Termcast?
Raycast extensions are limited to macOS and the Raycast app. Termcast lets you use similar patterns to build terminal applications that work cross-platform.
Termcast is a superset of the Raycast API—it supports what makes sense in a terminal context, while also enabling terminal-native features that Raycast can't provide:
- Current working directory — your TUI knows where it was invoked from
- Command-line arguments — accept input directly from the command line
- stdin — pipe data into your application
- Environment context — full access to the terminal environment
These capabilities make Termcast ideal for building developer tools that integrate naturally with terminal workflows.
Use Case
If your team already has a Raycast extension, you can use Termcast to create a terminal version that shares code with your existing extension. For example:
- A deployment tool that works both as a Raycast extension and a CLI
- An internal tool that needs to run on Linux servers
- A utility you want to distribute without requiring users to install Raycast
Differences from Raycast
- Uses Bun instead of Node
- Renders in a terminal instead of a macOS app
- Cross-platform (macOS, Linux)
- No store—distribute your TUI however you want
- Best-effort API compatibility (not a drop-in replacement)
bin
*.db*
.termcast-bundle/**
.termcast-bundle
/extensions/
tmp/
# bunfig.toml
preload = ["./src/preload.tsx"]
[install]
auto = "disable" # don't auto-install at runtime
[test]
pathFilter = ["!dist/**/*.test.*", "!esm/**/*.test.*"]
Extension Execution Modes
termcast supports two ways to run extensions: dev mode and compiled.
Storage Paths
| Mode | Extension Path | SQLite Database |
|---|---|---|
| Dev | Local folder (e.g. ~/my-extension) | {extensionPath}/.termcast-bundle/data.db |
| Compiled | N/A (embedded in binary) | ~/.termcast/{extensionName}/data.db |
For dev mode, the database path is determined by extensionPath in state. For compiled mode, no filesystem path exists - data is stored in user's home directory.
Dev Mode
Entry: startDevMode({ extensionPath })
1. Reads package.json from local extensionPath 2. Builds commands with esbuild (ESM format, bun target) 3. Sets state: extensionPath, extensionPackageJson 4. Shows command list, imports bundled files with cache-busting query param on each rebuild 5. Watches for changes and triggers triggerRebuild()
Compiled Mode
Entry: startCompiledExtension({ packageJson, compiledCommands })
1. Commands are pre-compiled and passed as Component functions 2. packageJson is embedded directly into the binary at compile time (no filesystem reads) 3. Sets state: extensionPackageJson (no extensionPath needed) 4. No build step needed - components are already bundled 5. Binary is fully portable - no hardcoded paths
Preferences
Preferences are stored in SQLite with keys:
- Extension-level:
preferences.{extensionName} - Command-level:
preferences.{extensionName}.{commandName}
The ExtensionPreferences component loads preference definitions from package.json at the extension path.
logs
logs that happen during extension execution are output in a local app.log file, in the cwd where the extension was run
Testing extensions
See TESTING_RAYCAST_EXTENSIONS.md for detailed instructions on testing extensions, including how to skip tests in CI when the extension folder doesn't exist.
{
"name": "hot-reload-extension",
"version": "0.0.1",
"title": "Hot Reload Test",
"description": "A simple extension for testing hot reload",
"icon": "command-icon.png",
"author": "test",
"categories": ["Productivity"],
"license": "MIT",
"commands": [
{
"name": "detail-view",
"title": "Detail View",
"description": "Shows a detail view with a marker",
"mode": "view"
},
{
"name": "list-with-navigation",
"title": "List With Navigation",
"description": "A list that pushes to detail views",
"mode": "view"
},
{
"name": "placeholder",
"title": "Placeholder",
"description": "Placeholder command to prevent auto-run",
"mode": "view"
}
]
}
import { Detail } from '@raycast/api'
export default function DetailView() {
return <Detail markdown="# Hello World\n\nMarker: MARKER_VALUE" />
}
import { Detail } from '@raycast/api'
export default function Placeholder() {
return <Detail markdown='Placeholder command' />
}
import { showHUD, Clipboard } from '@raycast/api'
export default async function QuickAction() {
const timestamp = new Date().toISOString()
await Clipboard.copy(timestamp)
await showHUD(`Copied: ${timestamp}`)
}
import { useStore, Detail, ActionPanel, Action } from 'termcast'
export default function ShowState() {
const state = useStore()
const { devElement, packageJson, ...filteredState } = state
const stateJson = JSON.stringify(filteredState, null, 2)
return (
<Detail
markdown={`\`\`\`json\n${stateJson}\n\`\`\``}
actions={
<ActionPanel>
<Action.CopyToClipboard
content={stateJson}
title='Copy state as JSON'
/>
</ActionPanel>
}
/>
)
}