Termcast Framework

repository·main·Indexed 43 days ago

https://github.com/remorses/termcast

A React-based framework for building Terminal User Interfaces (TUIs) that implements the Raycast extension API. It allows developers to create interactive terminal apps or port existing Raycast extensions, requiring Bun as a runtime. Termcast provides a CLI for scaffolding, development with Hot Module Reloading (HMR), and tools to compile TUIs into standalone executables or desktop applications.

Tokens
51.7K
Snippets
146
Records
202
Agent score
55%

What's inside Termcast

  1. Use Raycast imports in termcast

    main

    If you are porting an existing Raycast extension, Termcast provides built-in compatibility. Imports from @raycast/api and @raycast/utils are aliased at build time to work within the terminal.

    For new development, it is recommended to use termcast and @termcast/utils instead.

  2. Handle state synchronization with navigation push()

    main

    When rendering an element using push(), the props passed to the pushed element are not dynamic and will not automatically sync with parent state changes.

    To ensure a pushed child element reacts to parent state changes, you must use Zustand state. If the state needs to be local to a component, initialize the store using useMemo() or useState to ensure the store instance is stable:

    const [store] = useState(() => create<StateType>({}));
    // Pass 'store' down via props to the pushed element
  3. How to build TUIs with termcast

    main

    Termcast offers two distinct development models depending on your use case:

    1. Standalone Extension

    Best for pure TUIs. You create a folder with a package.json and React components. Termcast manages the dev server, compilation, and distribution. The entry point must be a file (e.g., src/index.tsx) that default-exports a React component.

    2. Library inside a CLI

    Best for hybrid CLIs that have both standard text commands and a TUI mode. You can use any CLI framework (like goke) and call renderWithProviders from the termcast package to launch the TUI screen.

    Note on Runtimes: Since Termcast requires Bun, if your CLI is running under Node.js, you must re-spawn the script using bun to enable the TUI mode.

    // Example of the Standalone Extension pattern
    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>
      )
    }
  4. How the OAuth system works in Termcast

    main

    Termcast uses an OAuth proxy hosted on termcast.app to handle authentication for extensions (e.g., GitHub, Linear, Slack). This allows extensions to authenticate without managing their own OAuth apps.

    The flow is as follows:

    1. The extension calls a method on OAuthService (e.g., OAuthService.github()).
    2. The browser opens the termcast.app authorization URL.
    3. termcast.app redirects to the provider (e.g., GitHub).
    4. After user authentication, the provider redirects back to termcast.app.
    5. termcast.app redirects to http://localhost:8989/oauth/callback?code=XXX.
    6. The Termcast CLI receives the code and exchanges it for a token via the proxy.
    7. The CLI receives and stores the access_token.
  5. Manage state with useStore

    main

    Use global state ONLY for:

    • Toast notifications
    • Current extension/command context
    • Dev mode settings
    • Cross-component communication that cannot use props

    Use local state for everything else (form data, search text, selection state, etc.).

    Accessing and Updating State:

    • In Components: Use useStore((state) => state.key).
    • In Handlers/Async: Use useStore.getState() to read and useStore.setState({ ... }) to update (updates are merged automatically).
    // In React Components
    const toast = useStore((state) => state.toast)
    const dialogStack = useStore((state) => state.dialogStack)
    
    // In Event Handlers/Async Functions
    const state = useStore.getState()
    
    // Update state (merges automatically)
    useStore.setState({
      toast: <Toast message='Success!' />,
      dialogStack: [],
    })
  6. Align List item accessories correctly

    main

    To prevent columns from shifting and breaking alignment in a List, every item must define the exact same number of accessories in the same order.

    Best Practices:

    • Use ternaries to provide empty placeholders (e.g., { tag: '' } or { text: '' }) for conditionally absent accessories instead of using .push(), which changes the array length.
    • When using accessoryTagsLayout, remember it maps positionally to all accessories (tags, text, date).
    • Include widths for text and date accessories to prevent variable-width content (like timestamps) from shifting the entire accessories block.
    // Correct way to handle conditional accessories
    { condition ? { tag: value } : { tag: '' } }
  7. Use the Descendants pattern for compound components

    main

    The descendants pattern is used to coordinate parent and child components (like List and List.Item) for features like keyboard navigation and selection.

    The useDescendant hook

    Returns an object with:

    • index: The current position in the rendered list (subject to change via filtering/reordering).
    • descendantId: A stable, unique ID for the item.

    CRITICAL: Always use descendantId for tracking item-specific state (selection, expansion, etc.). Using index for state tracking is incorrect because indices change when items are filtered or reordered.

    // CORRECT: Using descendantId for selection tracking
    const isSelected = selectedIds.has(descendant.descendantId)
    
    // WRONG: Using index for selection tracking
    // const isSelected = selectedIndexes.has(descendant.index)

    Accessing map.current

    When using useDescendants(), the map.current property is NOT reactive.

    • DO NOT access map.current during render or inside useMemo.
    • DO access map.current inside useEffect, useLayoutEffect, or event handlers (e.g., useKeyboard).

    To handle filtering, do not use map.current to filter the list; instead, have the child components render null and pass the search query via context.

    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
    })
  8. Manage state with Zustand in termcast

    main

    When using Zustand for state management, follow these rules to avoid infinite render loops and improper state updates:

    • Updating State: Use useStore.setState instead of adding custom setter methods.
    • Selectors: Only return scalar values from selectors. Returning objects like useStore((state) => ({item: state.item})) will trigger infinite render loops.
    • Accessing State: Use useStore.getState() to access state outside of React components.
    • Merging State: Zustand automatically merges partial state. Do not manually spread the initial state unless you are performing a full reset: useStore.setState({ ...useStore.getInitialState(), ... }).
  9. Handle keyboard input with the Focus Rule

    main

    To prevent components underneath dialogs from responding to input, always check if your component is in focus before handling keyboard events. Use the useIsInFocus hook to determine the current focus state.

    import { useIsInFocus } from 'termcast/src/internal/focus-context'
    import { useKeyboard } from '@opentui/react'
    
    function MyComponent() {
      const inFocus = useIsInFocus()
    
      useKeyboard((evt) => {
        if (!inFocus) return // Ignore input if not in focus
    
        // Handle keyboard events
        if (evt.name === 'return') {
          // Do something
        }
      })
    }
  10. Module Cache Busting via query parameters

    main

    To ensure that fresh code is loaded after a rebuild, termcast uses cache-busting query parameters on dynamic imports. Since Bun caches dynamic imports by the full URL, incrementing a version parameter forces a fresh import of the updated bundle.

    Example of the mechanism:

    await import('./cmd.js?v=1')  // cached as v=1
    await import('./cmd.js?v=2')  // fresh import, cached as v=2
  11. Implement compound components using the interface pattern

    main

    To implement compound components (e.g., List.Item), define a type for the parent component using an interface that includes the sub-components. This ensures proper typing for the parent and its children.

    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
    }
    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
    }
  12. How termcast implements React Refresh

    main

    termcast implements HMR by combining SWC transformations with React Refresh runtime.

    1. Build Time: During termcast dev, source files are transformed via SWC with refresh: true. This injects $RefreshReg$ calls into the output, associating components with stable IDs based on their source file paths.
    2. Runtime Initialization: Before React renders, the refresh runtime is injected into the global hook (globalThis), and $RefreshReg$ and $RefreshSig$ globals are defined.
    3. On File Change:
      • A file watcher triggers a rebuild using Bun.build().
      • Modules are re-imported using a cache-busting query parameter (e.g., ?v=N) to bypass Bun's module cache.
      • RefreshRuntime.performReactRefresh() is called to swap component implementations in-place while maintaining the existing component tree and state.