overengineeringstudio / overengineeringstudio/effect-utils

OpenTUI + Effect Integration Library

Open
#35 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area:effect area:tui type:feature
Dominant language
TypeScript
Stars
82
Forks
2
Avg merge
1d 8h
Merged PRs (30d)
121

Description

Summary

Add a reusable library for integrating OpenTUI with Effect, providing proper resource management, typed event streams, and React/effect-atom integration.

Motivation

Building TUI apps with OpenTUI + Effect currently requires significant boilerplate:

  • Manual cleanup orchestration (unmount, destroy, stdin.pause)
  • Hooking into undocumented APIs for mouse events (handleMouseData)
  • Coordinating exit signals with Effect's fiber system
  • Wiring keyboard events to Effect streams

This pattern has been battle-tested in pimuseum-cli's deploy TUI and is ready for extraction.

Proposed API

Core Service
import { Effect, Stream, Deferred } from 'effect'
import { createCliRenderer } from '@opentui/core'
import { createRoot } from '@opentui/react'

export interface MouseEvent {
  readonly button: number  // 0=left, 1=middle, 2=right, 64=scrollUp, 65=scrollDown
  readonly x: number
  readonly y: number
  readonly type: 'press' | 'release' | 'move'
}

export interface KeyEvent {
  readonly name: string
  readonly ctrl: boolean
  readonly shift: boolean
  readonly meta: boolean
  readonly sequence?: string
}

export class TuiRenderer extends Effect.Service<TuiRenderer>()('TuiRenderer', {
  scoped: Effect.gen(function* () {
    const renderer = yield* Effect.acquireRelease(
      Effect.tryPromise(() => createCliRenderer({ exitOnCtrlC: false })),
      (r) => Effect.sync(() => {
        r.destroy()
        process.stdin.pause()  // OpenTUI bug workaround
      })
    )

    const root = createRoot(renderer)
    const exitDeferred = yield* Deferred.make<void>()

    // Parse SGR mouse escape sequences into typed events
    const mouseEvents = Stream.async<MouseEvent>((emit) => {
      const original = (renderer as any).handleMouseData.bind(renderer)
      ;(renderer as any).handleMouseData = (data: string) => {
        for (const match of data.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
          const button = Number.parseInt(match[1]!, 10)
          emit.single({
            button: button & 0x43,  // Mask to get actual button
            x: Number.parseInt(match[2]!, 10),
            y: Number.parseInt(match[3]!, 10),
            type: match[4] === 'm' ? 'release' : button >= 32 ? 'move' : 'press',
          })
        }
        return original(data)
      }
      return Effect.sync(() => { (renderer as any).handleMouseData = original })
    })

    // Keyboard events as a Stream
    const keyEvents = Stream.async<KeyEvent>((emit) => {
      const handler = (key: any) => emit.single({
        name: key.name,
        ctrl: key.ctrl ?? false,
        shift: key.shift ?? false,
        meta: key.meta ?? false,
        sequence: key.sequence,
      })
      ;(renderer as any).keyInput.on('keypress', handler)
      return Effect.sync(() => (renderer as any).keyInput.off('keypress', handler))
    })

    const requestExit = () => Deferred.succeed(exitDeferred, undefined)
    const awaitExit = Deferred.await(exitDeferred)

    return {
      renderer,
      root,
      mouseEvents,
      keyEvents,
      requestExit,
      awaitExit,
    }
  })
}) {}
Usage Example with React + effect-atom
import { Registry } from '@effect-atom/atom'
import { RegistryContext, useAtomValue } from '@effect-atom/atom-react'
import { Effect, Stream, pipe } from 'effect'
import { TuiRenderer } from '@overeng/effect-opentui'

// Define atoms
const counterAtom = Atom.make(0)
const logsAtom = Atom.make<string[]>([])

// React component
const App = () => {
  const counter = useAtomValue(counterAtom)
  const logs = useAtomValue(logsAtom)

  return (
    <box flexDirection="column" padding={1}>
      <text fg="cyan" bold>Counter: {counter}</text>
      <box borderStyle="single" height={10}>
        {logs.slice(-8).map((log, i) => (
          <text key={i} fg="gray">{log}</text>
        ))}
      </box>
      <text fg="gray">[up/down: adjust] [scroll: logs] [q: quit]</text>
    </box>
  )
}

// Main program
const program = Effect.gen(function* () {
  const tui = yield* TuiRenderer
  const registry = Registry.make()

  // Handle keyboard
  yield* tui.keyEvents.pipe(
    Stream.runForEach((key) =>
      Effect.sync(() => {
        if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
          tui.requestExit()
        } else if (key.name === 'up') {
          registry.set(counterAtom, registry.get(counterAtom) + 1)
        } else if (key.name === 'down') {
          registry.set(counterAtom, registry.get(counterAtom) - 1)
        }
      })
    ),
    Effect.fork,
  )

  // Handle mouse scroll
  yield* tui.mouseEvents.pipe(
    Stream.runForEach((ev) =>
      Effect.sync(() => {
        if (ev.button === 64) {  // Scroll up
          registry.set(logsAtom, [...registry.get(logsAtom), `Scrolled up at ${ev.x},${ev.y}`])
        } else if (ev.button === 65) {  // Scroll down
          registry.set(logsAtom, [...registry.get(logsAtom), `Scrolled down at ${ev.x},${ev.y}`])
        }
      })
    ),
    Effect.fork,
  )

  // Render
  tui.root.render(
    <RegistryContext.Provider value={registry}>
      <App />
    </RegistryContext.Provider>
  )

  // Wait for exit
  yield* tui.awaitExit
})

// Run
Effect.runPromise(
  program.pipe(
    Effect.provide(TuiRenderer.Default),
    Effect.scoped,
  )
)

Implementation Notes

  1. Mouse parsing: OpenTUI doesn't expose parsed mouse events. We hook into handleMouseData to intercept raw SGR escape sequences (\x1b[<button;x;yM).

  2. Cleanup ordering: Critical to call destroy() before stdin.pause() due to OpenTUI not properly releasing stdin.

  3. Type assertions: OpenTUI's TypeScript types don't expose handleMouseData, keyInput, etc. We use as any internally.

  4. Effect-atom integration: The example uses Registry directly rather than React hooks for event handlers to avoid fiber isolation issues.

Package Structure

packages/@overeng/effect-opentui/
├── src/
│   ├── mod.ts           # Re-exports
│   ├── TuiRenderer.ts   # Main service
│   ├── mouse.ts         # Mouse event parsing utilities
│   └── types.ts         # MouseEvent, KeyEvent types
├── package.json
└── tsconfig.json

Dependencies

{
  "peerDependencies": {
    "@opentui/core": ">=0.1.68",
    "@opentui/react": ">=0.1.68",
    "effect": ">=3.0.0"
  }
}

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the proposed API and implementation notes, then map the package structure into src/TuiRenderer.ts, src/mouse.ts, src/types.ts, and src/mod.ts. Verify the OpenTUI peer dependency requirements and the documented cleanup, typed mouse and keyboard streams, and React/effect-atom usage. Done means the package exposes the proposed TuiRenderer service and integrations described in the issue.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
cli
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.