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
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
use git ls-files | tree --fromfile to see files in the repo. this command will ignore files ignored by git
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
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
-
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
-
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
}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
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
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
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
to create strings with new lines use the dedent package so it is more readable
NEVER run examples yourself with bun src/examples/etc
These will hang. These are made for real people
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.
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:
- Track which children are rendered and in what order
- Implement keyboard navigation across children
- Manage selection state across dynamic children
- 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.
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: 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 is used for e2e tests. After any change to tuistory source files, you must rebuild it:
cd tuistory && bun run buildtuistory 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.
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
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
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
waitIdleeverywhere. only add one if the test seems flaky
make sure to pass an adeguate timeout in the test, passing a number as second arg of test
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
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
- 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
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:
- Fill with
█block characters usingfg={sameColor}so the text matches the background - Use
position="absolute"on the text wrapper so it doesn't affect flex layout - 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.
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.
- 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.
- 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, 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
setTimeout must never be used to schedule React updates after some time. This strategy is stupid and never makes sense.
the folders tuistory and ghostty-opentui are submodules. they should always stay in branch main and not be detached. do not commit unless asked.
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
Git submodules frequently end up in a "detached HEAD" state. This happens because:
- Submodules track commits, not branches - The parent repo stores a specific commit SHA, not a branch name like "main"
git submodule updatechecks out commits - Runninggit submodule updateor cloning with--recurse-submoduleschecks out that specific SHA, putting you in detached HEAD- No branch tracking by default -
.gitmodulesdoesn't specify a branch to follow
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 mainAfter any submodule update, cd into submodules and run git checkout main before making changes.
- 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
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.
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_token
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
- 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',
},
}-
Register OAuth app with the provider, set callback URL to:
https://termcast.app/oauth/newprovider/callback -
Set environment variables on website deployment:
NEWPROVIDER_OAUTH_CLIENT_ID=...
NEWPROVIDER_OAUTH_CLIENT_SECRET=...
- If needed, add the provider to
raycast-utils/src/oauth/OAuthService.ts
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
- 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
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
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.