The fastest way to build terminal apps. React components, Raycast-compatible API,
compile to a single binary. Already have a Raycast extension? Port it.
1pnpm install -g termcast
Requires Bun as a runtime. Does not work with Node.js.
package.json and React components. Termcast handles dev server, compilation, and distribution.1234my-app/ package.json # name, commands, preferences src/ index.tsx # default export is a React component
12345678910111213141516171819202122// src/index.tsx import { List, Action, ActionPanel, showToast, Toast } from 'termcast' export default function Command() { return ( <List searchBarPlaceholder="Search items..."> <List.Item title="Hello World" actions={ <ActionPanel> <Action title="Greet" onAction={async () => { await showToast({ style: Toast.Style.Success, title: 'Hi!' }) }} /> </ActionPanel> } /> </List> ) }
1termcast dev
@raycast/api and @raycast/utils work out of the box. Termcast aliases them at build time, so existing code runs without changes. For new code, import from termcast and @termcast/utils instead.renderWithProviders when you need a TUI screen.12345678910111213141516171819202122232425262728293031323334353637383940// src/cli.ts import { goke } from 'goke' const cli = goke('myapp') cli.command('list', 'List items as text').action(async () => { console.log('item 1\nitem 2\nitem 3') }) cli.command('', 'Browse items in TUI').action(async () => { // Termcast requires Bun. When the CLI runs under Node, // re-spawn the same script with bun so the TUI works. const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined' if (!isBun) { const { spawnSync } = await import('node:child_process') const { fileURLToPath } = await import('node:url') const __filename = fileURLToPath(import.meta.url) const result = spawnSync('bun', [__filename, ...process.argv.slice(2)], { stdio: 'inherit', env: process.env, }) if (result.error) { console.error('The TUI requires Bun. Install: curl -fsSL https://bun.sh/install | bash') process.exit(1) } if (result.signal) { process.kill(process.pid, result.signal); return } process.exit(result.status ?? 1) return } const { renderWithProviders } = await import('termcast') const { default: BrowseItems } = await import('./browse.js') const React = await import('react') await renderWithProviders(React.createElement(BrowseItems), { extensionName: 'myapp', }) }) cli.help() cli.parse()
123456789101112131415161718// src/browse.tsx import { List, Detail, Action, ActionPanel, useNavigation } from 'termcast' import { useCachedPromise } from '@termcast/utils' export default function BrowseItems() { const { data, isLoading } = useCachedPromise(async () => { const res = await fetch('https://api.example.com/items') return res.json() }, []) return ( <List isLoading={isLoading} searchBarPlaceholder="Search..."> {data?.map((item) => ( <List.Item key={item.id} title={item.name} subtitle={item.status} /> ))} </List> ) }
123termcast new my-extension cd my-extension termcast dev
termcast new scaffolds an extension from a template with a List, Form, actions, and search. termcast dev starts the TUI with hot module reloading: edit your code and the UI updates instantly without restarting.termcast dev [path]12termcast dev # current directory termcast dev ./my-app # specific path
termcast dev uses React Refresh to update components in-place when you save a file. State is preserved, no full restart. The flow:.tsx file@parcel/watcher detects the changereactFastRefresh: truerenderWithProviders), you need a commands array in package.json so termcast dev knows which files to build:123456789101112{ "name": "my-cli", "bin": "./dist/cli.js", "commands": [ { "name": "browse", "title": "Browse Items", "description": "TUI for browsing items", "mode": "view" } ] }
src/browse.tsx (or browse.tsx at root), which must default-export a React component:123456// src/browse.tsx import { List } from 'termcast' export default function BrowseItems() { return <List><List.Item title="Hello" /></List> }
termcast dev from the project root. It builds and renders the component directly, bypassing your CLI entry point. Your CLI still uses renderWithProviders for production; termcast dev is just for development with HMR.termcast dev shows a picker. If there's only one, it runs immediately. You can also specify a command by name:12termcast dev # picker or auto-run if single command termcast dev browse # run the "browse" command directly
termcast compile [path]12termcast compile termcast compile -o ./bin/myapp
termcast release [path]1termcast release
12Install script: curl -sf https://termcast.app/owner/repo/install | bash
1curl -sf https://termcast.app/r/tuitube | bash
termcast app build [path].app, Linux, Windows) with a bundled terminal emulator. No terminal needed to run the app.123termcast app build termcast app build --name "My App" --icon ./icon.png termcast app build --release # upload to GitHub release
--font, --font-size, --theme, --bundle-id, --platform, --arch.termcast new [name]1termcast new my-extension
1234567891011121314151617181920212223242526272829303132333435363738394041424344import { List, Action, ActionPanel, Icon, Color } from 'termcast' function Repos() { return ( <List isShowingDetail={true} searchBarPlaceholder="Search repos..." navigationTitle="My Repos" > <List.Section title="Active"> <List.Item title="termcast" subtitle="TUI framework" icon={Icon.Star} accessories={[ { tag: { value: 'TypeScript', color: Color.Blue } }, { text: '2 days ago' }, ]} detail={ <List.Item.Detail markdown="# termcast\n\nBuild terminal apps with React." metadata={ <List.Item.Detail.Metadata> <List.Item.Detail.Metadata.Label title="Stars" text="420" /> <List.Item.Detail.Metadata.Separator /> <List.Item.Detail.Metadata.TagList title="Topics"> <List.Item.Detail.Metadata.TagList.Item text="react" color={Color.Blue} /> <List.Item.Detail.Metadata.TagList.Item text="tui" color={Color.Green} /> </List.Item.Detail.Metadata.TagList> </List.Item.Detail.Metadata> } /> } actions={ <ActionPanel> <Action title="Open" onAction={() => {}} /> <Action.CopyToClipboard title="Copy URL" content="https://github.com/..." /> </ActionPanel> } /> </List.Section> </List> ) }
List.Dropdown), detail panel with markdown + metadata, accessories (tags, text, icons), keyboard navigation, infinite scroll pagination.accessoryTagsLayout prop fixes this by assigning each accessory position a fixed character width, turning your list into a table-like layout.padEnd (right-padded with spaces). Accessories beyond the array length render with their natural width.1234567891011121314151617181920212223242526272829303132333435363738394041import { List, Color } from 'termcast' function Issues() { return ( // Column widths: service=12, count=4, status=11, time=7 <List accessoryTagsLayout={[12, 4, 11, 7]}> <List.Item title="Fix login timeout" accessories={[ { tag: { value: 'api-server', color: Color.Blue } }, { tag: { value: '15', color: Color.Orange } }, { tag: { value: 'Open', color: Color.Green } }, { text: '7h ago' }, ]} /> <List.Item title="Add dark mode support" accessories={[ { tag: { value: 'web-frontend', color: Color.Blue } }, { tag: { value: '6', color: Color.Orange } }, { tag: { value: 'In Progress', color: Color.Orange } }, { text: '22h ago' }, ]} /> <List.Item title="Refactor auth module" accessories={[ { tag: { value: 'api-server', color: Color.Blue } }, { tag: { value: '2', color: Color.Orange } }, { tag: '' }, // placeholder, preserves column alignment { text: '3d ago' }, ]} /> </List> ) } // Renders as: // Fix login timeout api-server 15 Open 7h ago // Add dark mode support web-frontend 6 In Progress 22h ago // Refactor auth module api-server 2 3d ago
{ tag: '' } or { text: '' } as a placeholder when an item is missing an accessory; it renders as empty space so the remaining columns stay aligned.accessoryTagsLayout is used. If one item has 2 tags and another has 3, columns shift and alignment breaks. Use { tag: '' } for conditionally absent tags, and use a ternary (condition ? { tag: ... } : { tag: '' }) instead of conditional .push().123456789101112// Good: optional "Blocked" tag first, common tags last, always same count accessories={[ item.blocked ? { tag: { value: 'Blocked', color: Color.Red } } : { tag: '' }, { tag: { value: item.status, color: statusColor } }, { tag: { value: item.priority } }, { date: item.updatedAt }, ]} // Bad: conditional push changes the number of accessories per item, breaking alignment const accessories = [{ tag: { value: item.status } }] if (item.blocked) accessories.push({ tag: { value: 'Blocked' } }) accessories.push({ tag: { value: item.priority } })
1234567891011121314151617181920212223242526272829303132333435363738import { List, Color } from 'termcast' const MAX_COL_WIDTH = 16 // Compute the widest value at each accessory position across all items const accessoryTagsLayout = issues.reduce<number[]>((widths, issue) => { const values = [ issue.assignee ?? '', issue.status, issue.priority, timeAgo(issue.updatedAt), ] values.forEach((text, i) => { widths[i] = Math.min(MAX_COL_WIDTH, Math.max(widths[i] ?? 0, text.length)) }) return widths }, []) function Issues() { return ( <List accessoryTagsLayout={accessoryTagsLayout}> {issues.map((issue) => ( <List.Item key={issue.id} title={issue.title} accessories={[ issue.assignee ? { tag: { value: issue.assignee } } : { tag: '' }, { tag: { value: issue.status, color: statusColor(issue.status) } }, { tag: { value: issue.priority } }, { text: timeAgo(issue.updatedAt) }, ]} /> ))} </List> ) }
reduce walks every item once and tracks the longest value per position. Math.min(MAX_COL_WIDTH, ...) prevents a single long value from dominating the layout. The optional assignee tag is placed first because it is often absent, keeping the status and priority columns aligned on the right.1234567891011121314151617181920212223import { Detail, Color } from 'termcast' function ServerStatus() { return ( <Detail 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="Uptime" text="14d 3h" /> <Detail.Metadata.Label title="CPU" text={{ value: '42%', color: Color.Orange }} /> <Detail.Metadata.Separator /> <Detail.Metadata.TagList title="Regions"> <Detail.Metadata.TagList.Item text="us-east-1" color={Color.Green} /> <Detail.Metadata.TagList.Item text="eu-west-1" color={Color.Blue} /> </Detail.Metadata.TagList> </Detail.Metadata> } /> ) }
12345678910111213141516171819202122232425262728import { Form, Action, ActionPanel, showToast, Toast } from 'termcast' function CreateIssue() { return ( <Form actions={ <ActionPanel> <Action.SubmitForm title="Create Issue" onSubmit={async (values) => { await showToast({ style: Toast.Style.Success, title: `Created: ${values.title}` }) }} /> </ActionPanel> } > <Form.TextField id="title" title="Title" placeholder="Bug report..." /> <Form.TextArea id="description" title="Description" /> <Form.Dropdown id="priority" title="Priority" defaultValue="medium"> <Form.Dropdown.Item value="low" title="Low" /> <Form.Dropdown.Item value="medium" title="Medium" /> <Form.Dropdown.Item value="high" title="High" /> </Form.Dropdown> <Form.Checkbox id="blocking" title="Blocking" label="Blocks release" /> <Form.DatePicker id="due" title="Due Date" /> </Form> ) }
TextField, TextArea, Dropdown, Checkbox, DatePicker, TagPicker, FilePicker, PasswordField, Separator, Description.123456789101112131415161718192021222324252627282930313233343536import { List, Detail, Action, ActionPanel, useNavigation } from 'termcast' function ItemList() { const { push } = useNavigation() return ( <List> <List.Item title="View Details" actions={ <ActionPanel> <Action.Push title="Open" target={<ItemDetail />} /> <Action title="Open Programmatically" onAction={() => { push(<ItemDetail />) }} /> </ActionPanel> } /> </List> ) } function ItemDetail() { const { pop } = useNavigation() return ( <Detail markdown="# Item Detail" actions={ <ActionPanel> <Action title="Go Back" onAction={() => { pop() }} /> </ActionPanel> } /> ) }
Ctrl+P. They support keyboard shortcuts, sections, and built-in types like copy, push, open URL, and submit form.123456789101112131415<ActionPanel> <ActionPanel.Section title="Primary"> <Action title="Open" onAction={() => {}} /> <Action title="Edit" shortcut={{ modifiers: ['ctrl'], key: 'e' }} onAction={() => {}} /> </ActionPanel.Section> <ActionPanel.Section title="Clipboard"> <Action.CopyToClipboard title="Copy ID" content="abc-123" /> <Action.Paste title="Paste" content="hello" /> </ActionPanel.Section> <ActionPanel.Section> <Action.Push title="View Details" target={<Detail markdown="..." />} /> <Action.OpenInBrowser title="GitHub" url="https://github.com" /> <Action.SubmitForm title="Save" onSubmit={handleSubmit} /> </ActionPanel.Section> </ActionPanel>
123456789101112import { showToast, Toast } from 'termcast' // Simple await showToast({ style: Toast.Style.Success, title: 'Saved' }) // With loading state const toast = await showToast({ style: Toast.Style.Animated, title: 'Downloading...', message: '0%' }) // ... update progress toast.message = '50%' // ... done toast.style = Toast.Style.Success toast.title = 'Downloaded'
@termcast/utils package provides hooks for async data loading, caching, and form management.12345678910111213141516171819202122232425import { List } from 'termcast' import { useCachedPromise } from '@termcast/utils' function EmailList() { const { data, isLoading, revalidate, pagination } = useCachedPromise( (query: string) => async ({ cursor }) => { const res = await fetchEmails({ query, pageToken: cursor }) return { data: res.emails, hasMore: !!res.nextPageToken, cursor: res.nextPageToken, } }, [''], { keepPreviousData: true }, ) return ( <List isLoading={isLoading} pagination={pagination}> {data?.map((email) => ( <List.Item key={email.id} title={email.subject} subtitle={email.from} /> ))} </List> ) }
List renders all children to the terminal. With hundreds of items, React reconciliation and layout computation become the bottleneck. Pagination keeps the rendered item count small by loading data in pages as the user scrolls.List component triggers onLoadMore() automatically when the cursor approaches the last few items. useCachedPromise accumulates pages internally so previously loaded items stay visible when scrolling back up.useCachedPromise. The outer function takes reactive dependencies; the inner async function receives { page, cursor } and returns { data, hasMore, cursor }.process.stdout.rows to size pages to the terminal height so the first page always fills the visible area. Subtract a few rows for List chrome (search bar, footer, borders).1234567891011121314151617181920212223242526272829import { List } from 'termcast' import { useCachedPromise } from '@termcast/utils' // Size pages to the terminal so the first fetch fills the visible area const PAGE_SIZE = Math.max(10, (process.stdout.rows || 30) - 5) function TracesList() { const { data, isLoading, pagination } = useCachedPromise( (projectId: string) => async ({ cursor }: { page: number; cursor?: { ts: string; id: string } }) => { const result = await fetchTraces({ projectId, cursor, limit: PAGE_SIZE }) return { data: result.traces, hasMore: result.hasMore, cursor: result.nextCursor, // passed back on next page request } }, [projectId], { keepPreviousData: true }, ) return ( <List isLoading={isLoading} pagination={pagination}> {data?.map((trace) => ( <List.Item key={trace.id} title={trace.name} subtitle={trace.duration} /> ))} </List> ) }
keepPreviousData: true prevents the list from flickering when dependencies change (e.g. switching a filter). The old data stays visible until the new first page arrives.pagination object from useCachedPromise has { pageSize, hasMore, onLoadMore } and can be passed directly to <List pagination={pagination}>.List.Item components going through React reconciliation and yoga layout on every keystroke.1234567891011121314151617181920212223242526272829303132333435import { List } from 'termcast' import { useCachedPromise } from '@termcast/utils' import { useState } from 'react' const PAGE_SIZE = Math.max(10, (process.stdout.rows || 30) - 5) function SpanTree({ traceId }: { traceId: string }) { const { data, isLoading } = useCachedPromise( async (id: string) => { const spans = await fetchAllSpans(id) // fetch everything return buildFlatTree(spans) // compute tree structure }, [traceId], ) const allItems = data ?? [] const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) const visible = allItems.slice(0, visibleCount) const hasMore = visibleCount < allItems.length return ( <List isLoading={isLoading} pagination={hasMore ? { pageSize: PAGE_SIZE, hasMore, onLoadMore: () => setVisibleCount((c) => Math.min(c + PAGE_SIZE, allItems.length)), } : undefined} > {visible.map((item) => ( <List.Item key={item.id} title={item.name} /> ))} </List> ) }
List triggers onLoadMore when the cursor gets close to the end, and useState grows the visible window by one page each time.12345import { useCachedState } from '@termcast/utils' const [selectedFolder, setSelectedFolder] = useCachedState('activeFolder', 'inbox', { cacheNamespace: 'mail', })
useCachedPromise but without caching. Good for one-shot fetches.1234567891011121314import { usePromise } from '@termcast/utils' const { data: video, isLoading } = usePromise( async (url: string) => { const result = await fetchVideoMetadata(url) return result }, [videoUrl], { onError(error) { showToast({ style: Toast.Style.Failure, title: 'Not found', message: error.message }) }, }, )
12345678910111213import { useForm } from '@termcast/utils' const { handleSubmit, itemProps } = useForm({ onSubmit: async (values) => { await saveItem(values) }, validation: { url: (value) => { if (!value) return 'URL is required' if (!isValidUrl(value)) return 'Invalid URL' }, }, })
1234567import { showFailureToast } from '@termcast/utils' try { await riskyOperation() } catch (error) { await showFailureToast(error, { title: 'Operation failed' }) }
12345678910import { Detail, Graph, Color } from 'termcast' <Detail markdown="# Stock Price" metadata={ <Graph height={15} xLabels={['Jan', 'Apr', 'Jul', 'Oct']} yTicks={6} yFormat={(v) => `$${v.toFixed(0)}`}> <Graph.Line data={[150, 162, 175, 190, 185, 201]} color={Color.Orange} title="AAPL" /> </Graph> } />
"line" (default), "area" (filled area below the line).1234567import { BarChart, Color } from 'termcast' <BarChart height={1}> <BarChart.Segment value={4850} label="Spent" /> <BarChart.Segment value={707} label="Remaining" /> <BarChart.Segment value={617} label="Savings" color={Color.Green} /> </BarChart>
█ fill, gaps between bars, X-axis labels, and a compact legend.123456import { BarGraph } from 'termcast' <BarGraph height={15} 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>
123456import { HorizontalBarGraph } from 'termcast' <HorizontalBarGraph height={10} labels={['Mon', 'Tue', 'Wed', 'Thu', 'Fri']}> <HorizontalBarGraph.Series data={[40, 30, 25, 15, 50]} title="Views" /> <HorizontalBarGraph.Series data={[20, 25, 10, 10, 25]} title="Clicks" /> </HorizontalBarGraph>
123456789import { CandleChart } from 'termcast' <CandleChart data={candles} // Array<{ open, close, high, low }> height={12} xLabels={['12d', '8d', '4d', 'Now']} yTicks={4} yFormat={(v) => `$${v.toFixed(2)}`} />
123456import { CalendarHeatmap, Color } from 'termcast' <CalendarHeatmap data={dailyData} // Array<{ date: Date, value: number }> color={Color.Green} />
12345678910import { Table } from 'termcast' <Table headers={['Region', 'Latency', 'Status']} rows={[ ['us-east-1', '**12ms**', '`ok`'], ['eu-west-1', '*45ms*', '`ok`'], ['ap-south-1', '`89ms`', '`warn`'], ]} />
123import { ProgressBar } from 'termcast' <ProgressBar title="Current session" value={37} percentageSuffix="used" label="Resets 9pm" />
12345678910import { Row, BarGraph } from 'termcast' <Row> <BarGraph height={10} labels={['Mon', 'Tue', 'Wed']}> <BarGraph.Series data={[40, 30, 25]} title="Week 1" /> </BarGraph> <BarGraph height={10} labels={['Mon', 'Tue', 'Wed']}> <BarGraph.Series data={[50, 40, 35]} title="Week 2" /> </BarGraph> </Row>
12345import { Markdown } from 'termcast' <Detail.Metadata> <Markdown content="**Status:** All systems operational. See [docs](https://example.com) for details." /> </Detail.Metadata>
renderWithProviders to mount any React component with all termcast infrastructure (navigation, storage, query cache, theme):123456789import { renderWithProviders, List } from 'termcast' function MyApp() { return <List><List.Item title="Hello" /></List> } await renderWithProviders(<MyApp />, { extensionName: 'my-app', })
| Option | Default | Description |
extensionName | 'termcast-app' | Derives storage paths and extension metadata |
extensionPath | ~/.termcast/compiled/{extensionName} | Where LocalStorage and Cache are stored |
packageJson | { name, title, description: '', commands: [] } | Extension metadata for preferences |
12345678910┌─────────────────────────────────────────────┐ │ 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 │ └─────────────────────────────────────────────┘
123456789101112131415161718192021function 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} /> }>
123456789101112131415161718192021222324252627282930313233function 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> )
1234567891011121314151617181920const [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}>
123456789101112131415161718192021222324<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>
List.Dropdown can control multiple independent states by using prefixed values and parsing the prefix in onChange. This avoids needing multiple dropdowns (which List doesn't support). Use displayValue to show a combined label for all states in the dropdown trigger.1234567891011121314151617181920212223242526272829const displayValue = `${viewLabel} · ${projectSlug} · ${timeLabel}` <List.Dropdown tooltip="Navigation" value={`view::${currentView}`} displayValue={displayValue} onChange={(value) => { if (value.startsWith('view::')) setView(value.slice(6)) else if (value.startsWith('project::')) { const [id, slug] = value.slice(9).split('::') setProject(id, slug) } else if (value.startsWith('time::')) setTimeRange(value.slice(6)) }} > <List.Dropdown.Section title="View"> <List.Dropdown.Item title="Issues" value="view::issues" /> <List.Dropdown.Item title="Logs" value="view::logs" /> </List.Dropdown.Section> <List.Dropdown.Section title="Project"> {projects.map(p => ( <List.Dropdown.Item key={p.id} title={p.slug} value={`project::${p.id}::${p.slug}`} /> ))} </List.Dropdown.Section> <List.Dropdown.Section title="Time Range"> <List.Dropdown.Item title="Last 24h" value="time::24h" /> <List.Dropdown.Item title="Last 7d" value="time::7d" /> </List.Dropdown.Section> </List.Dropdown>
displayValue, the dropdown trigger shows the title of whichever item matches value, which only reflects one state. With displayValue, the trigger always shows all three states regardless of which section was last picked.zustand/vanilla with termcast's Cache.Cache is synchronous (SQLite-backed) so you can read persisted state at module scope and use it as the initial zustand value. No async loading, no loading spinners, no useEffect. LocalStorage is async and requires the provider context; prefer Cache for zustand persistence.123456789101112131415161718192021222324252627282930import { createStore } from 'zustand/vanilla' import { Cache } from 'termcast' const cache = new Cache({ namespace: 'my-app' }) // Load persisted state synchronously at module scope function loadState(): Partial<AppState> { const raw = cache.get('state') if (!raw) return {} try { return JSON.parse(raw) } catch { return {} } } const saved = loadState() const store = createStore<AppState>(() => ({ view: saved.view ?? 'issues', projectId: saved.projectId ?? null, timeRange: saved.timeRange ?? '24h', service: saved.service ?? null, })) // Persist every change synchronously store.subscribe((state) => { cache.set('state', JSON.stringify(state)) }) // React hook function useStore<T>(selector: (s: AppState) => T): T { return useSyncExternalStore(store.subscribe, () => selector(store.getState())) }
useCachedState when state is shared across many components or views, because zustand gives you one store instead of scattered per-key hooks.@raycast/api -> termcast, @raycast/utils -> @termcast/utilscmd doesn't work in terminals. Replace with ctrl or altreturn in opentui key eventsList.Item, List.Section, List.Dropdown, List.Dropdown.ItemDetail.Metadata, Detail.Metadata.Label, Detail.Metadata.TagListForm.TextField, Form.Dropdown, Form.Dropdown.ItemActionPanel.Section.app on macOS).cpuprofile files you can analyze with profano. See the full profiling guide for CLI, scripted, and tuistory-based workflows.123456789# V8 CPU profiling (general performance) BUN_OPTIONS="--cpu-prof --cpu-prof-dir=./tmp/cpu-profiles" termcast dev ./my-extension # React component profiling (render timing) TERMCAST_REACT_PROFILE=1 termcast dev ./my-extension # Analyze (Ctrl+C to exit first) bunx profano ./tmp/cpu-profiles/CPU.*.cpuprofile --sort self bunx profano ./tmp/react-profile-*.cpuprofile --sort self
1npx -y skills add remorses/termcast