overengineeringstudio / overengineeringstudio/effect-utils
OpenTUI + Effect Integration Library
Nobody has claimed this yet.
- 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
-
Mouse parsing: OpenTUI doesn't expose parsed mouse events. We hook into
handleMouseDatato intercept raw SGR escape sequences (\x1b[<button;x;yM). -
Cleanup ordering: Critical to call
destroy()beforestdin.pause()due to OpenTUI not properly releasing stdin. -
Type assertions: OpenTUI's TypeScript types don't expose
handleMouseData,keyInput, etc. We useas anyinternally. -
Effect-atom integration: The example uses
Registrydirectly 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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