| description | Effect-TS CLI project conventions and patterns |
|---|---|
| globs | *.ts, *.tsx, package.json |
| alwaysApply | true |
- Sacrifice grammar in favour of concision. Write like a good software engineer would write to another.
- Comments and documentation must be standalone - readable without knowledge of prior versions. Never write "now simplified to X", "previously was Y", "changed from Z". Describe what IS, not what changed.
- Never give time estimates for how long tasks would take humans. Focus on implementation steps and actions, not timelines.
See README.md for context
- Use bun as runtime and package manager
- Run CLI using
bun run mini-agent(includes doppler for env vars) - kebab-case filenames
- tests using vitest; colocate test files with .test.ts
- import using .ts extension; no .js
- Use comments sparingly to explain any additional context and "why" that isn't evident from the code. Don't redundantly describe the code below.
- No banner comments (e.g.
// ===== Section Name =====). Use whitespace and JSDoc to organize code. - DO NOT use nodejs imports like node:fs etc - you must use @effect/platform/FileSystem and @effect/platform/Path instead (read source if you need to grok it). Exception: test fixtures in
test/fixtures.tsmay use node:* imports for test infrastructure. - Acronyms in identifiers use PascalCase, not ALL_CAPS:
LlmConfignotLLMConfig,HttpClientnotHTTPClient
bun run typecheck— tsc onlybun run lint/bun run lint:fix— eslint onlybun run check— typecheck + lintbun run check:fix— typecheck + lint:fixdoppler run -- bun run test— vitest (requires Doppler for API keys)doppler run -- bun run test:watch— vitest watch mode
Before committing and pushing code, you must run:
bun run check:fixThis runs typecheck + linter with auto-fix. Commit any resulting changes before pushing.
Also make sure to amend the pull request description using the gh utility each time you push.
effect-solutions list- List all available topicseffect-solutions show <slug...>- Read one or more topicseffect-solutions search <term>- Search topics by keyword
Local Effect Source: ~/src/github.com/Effect-TS/effect
Effect Patterns Knowledge Base: Cross-reference with ~/src/github.com/PaulJPhilp/EffectPatterns for community patterns in content/ and packages/.
Use Effect.Service for service definitions. It combines tag, implementation, and layer generation:
class MyService extends Effect.Service<MyService>()("@mini-agent/MyService", {
effect: Effect.gen(function*() {
const dep = yield* SomeDependency
return {
doSomething: (input: string) => Effect.succeed(`result: ${input}`)
}
}),
dependencies: [SomeDependency.Default]
}) {}
// Auto-generated: MyService.Default (includes dependencies)
// Usage:
Effect.provide(program, MyService.Default)For simple services without dependencies:
class Config extends Effect.Service<Config>()("@mini-agent/Config", {
sync: () => ({
logLevel: "info",
apiUrl: "https://api.example.com"
})
}) {}Test layers use Layer.succeed with the service tag:
const MyServiceTest = Layer.succeed(MyService, {
doSomething: (input) => Effect.succeed(`mock: ${input}`)
})Tag identifiers use package-scoped names: @mini-agent/ServiceName
Use Schema instead of plain TypeScript types for domain values. Schemas provide runtime validation, encoding/decoding, and type guards - plain types only exist at compile time.
// ❌ Plain type - no runtime validation
type Status = "pending" | "active" | "done"
// ✅ Schema - runtime validation + type derivation
const Status = Schema.Literal("pending", "active", "done")
type Status = typeof Status.Type
// Use the schema for validation
const validateStatus = Schema.decodeUnknown(Status)
const isStatus = Schema.is(Status)This applies to:
- Enums/Literals:
Schema.Literal("a", "b", "c")overtype T = "a" | "b" | "c" - Domain objects:
Schema.Struct({...})orSchema.TaggedClassoverinterface - Unions:
Schema.Union(A, B, C)overtype T = A | B | C - Branded types:
Schema.String.pipe(Schema.brand("UserId"))overstring & { _brand: "UserId" }
The pattern: define Schema first, derive type with typeof Schema.Type.
Use branded types for domain identifiers to prevent mixing strings:
export const ContextName = Schema.String.pipe(Schema.brand("ContextName"))
export type ContextName = typeof ContextName.Type
export const UserId = Schema.String.pipe(Schema.brand("UserId"))
export type UserId = typeof UserId.Typeexport class UserMessage extends Schema.TaggedClass<UserMessage>()("UserMessage", {
content: Schema.String
}) {}
// Type guard
export const isUserMessage = Schema.is(UserMessage)
// Union types - use Schema.Union for runtime encoding/decoding
export const Event = Schema.Union(UserMessage, SystemPrompt, AssistantMessage)
export type Event = typeof Event.TypeDefine domain errors with Schema.TaggedError for type-safe error handling:
export class ContextNotFound extends Schema.TaggedError<ContextNotFound>()(
"ContextNotFound",
{ name: ContextName }
) {}
export class ConfigurationError extends Schema.TaggedError<ConfigurationError>()(
"ConfigurationError",
{ key: Schema.String, message: Schema.String }
) {}
// Union for error types
export const ContextError = Schema.Union(ContextNotFound, ContextLoadError)
export type ContextError = typeof ContextError.Type
// Typed error recovery
effect.pipe(
Effect.catchTag("ContextNotFound", (e) => Effect.succeed(fallback)),
Effect.catchTags({
ContextNotFound: (e) => handleNotFound(e),
ConfigurationError: (e) => handleConfig(e)
})
)class AppConfig extends Context.Tag("@app/AppConfig")<
AppConfig,
{
readonly apiKey: Redacted.Redacted
readonly model: string
}
>() {
// Layer that loads from ConfigProvider
static readonly layer = Layer.effect(
AppConfig,
Effect.gen(function* () {
const apiKey = yield* Config.redacted("API_KEY")
const model = yield* Config.string("MODEL").pipe(
Config.withDefault("gpt-4o-mini")
)
return { apiKey, model }
})
)
// Test layer with mock values
static readonly testLayer = Layer.succeed(AppConfig, {
apiKey: Redacted.make("test-key"),
model: "test-model"
})
}Use Terminal service instead of process.stdout.write:
import { Terminal } from "@effect/platform"
// ❌ Bad - direct process access
Effect.sync(() => process.stdout.write(text))
// ✅ Good - Terminal service
Effect.gen(function*() {
const terminal = yield* Terminal.Terminal
yield* terminal.display(text)
})Use @effect/platform Command for subprocess execution. Pipe stdin with Command.stdin(Stream), capture output with Command.string / Command.lines / Command.stream:
import { Command } from "@effect/platform"
import { Stream } from "effect"
// Run command with stdin input
const output = yield* Command.make("cat").pipe(
Command.stdin(Stream.make(Buffer.from("hello\n", "utf-8"))),
Command.string
)
// Stream output line by line
const lines = Command.streamLines(Command.make("ls", "-la"))Two different output mechanisms:
Effect.log* = Observability logging (timestamps, levels, goes to file)
yield* Effect.log("Processing request") // info (stdout + file)
yield* Effect.logDebug("Detailed state") // debug (file only by default)
yield* Effect.logWarning("Retrying...") // warn
yield* Effect.logError("Failed", { error }) // error with structured dataConsole.log/error = Direct user output (chat messages, JSON, prompts)
yield* Console.log(assistantMessage) // User-facing output
yield* Console.error("Error: ...") // User-visible errorConfig defaults: stdout=warn, file=debug (in .mini-agent/logs/).
For errors, do BOTH - log for observability AND show user:
Effect.logError("Request failed", { error }).pipe(
Effect.flatMap(() => Console.error(`Error: ${error}`))
)Annotations add structured metadata to all logs within an effect scope. Use Effect.annotateLogs to attach key-value pairs (e.g., requestId, userId) that appear in every log emitted by nested effects.
Spans track execution duration. Wrap an effect with Effect.withLogSpan("label") to automatically include timing in logs—useful for performance debugging.
const program = Effect.gen(function*() {
yield* Effect.log("Starting")
yield* doWork()
yield* Effect.log("Done")
}).pipe(
Effect.annotateLogs({ requestId: "abc123", userId: "user42" }),
Effect.withLogSpan("processRequest")
)
// Logs include: requestId=abc123 userId=user42 processRequest=152msSee Effect logging docs for details.
Use test from ./fixtures.js for e2e tests needing isolated temp directories:
import { test, expect } from "./fixtures.js"
test("my test", async ({ testDir }) => {
// testDir is a unique temp directory for this test
// Files written here are preserved for debugging
})Suite directory logged once per file; test directory only logged on failure.
Use Layer.sync for test layers (cleaner than Layer.effect(Effect.sync(...))):
import { describe, expect, it } from "@effect/vitest"
describe("MyService", () => {
// Each test gets fresh layer - no state leakage
it.effect("does something", () =>
Effect.gen(function*() {
const service = yield* MyService
const result = yield* service.doSomething("input")
expect(result).toBe("expected")
}).pipe(Effect.provide(MyService.testLayer))
)
})Test layer pattern:
static readonly testLayer = Layer.sync(MyService, () => {
// Mutable state is fine in tests - JS is single-threaded
const store = new Map<string, Data>()
return MyService.of({
get: (key) => Effect.succeed(store.get(key)),
set: (key, value) => Effect.sync(() => void store.set(key, value))
})
})Layers are memoized by reference. Functions returning layers defeat memoization—each call creates a new object, causing duplicate construction, resource leaks, and inconsistent state.
// ❌ Factory function - new reference each call
const makeDatabase = () => Layer.effect(Database, ...)
makeDatabase() === makeDatabase() // false
// ✅ Module-level constant - single reference
export const DatabaseLive = Layer.effect(Database, ...)For parameterized layers, call factory once and export the result:
const createDbLayer = (url: string) => Layer.scoped(Database, ...)
export const ProductionDb = createDbLayer(process.env.DB_URL!)Generator vs Pipe: Use Effect.gen for business logic with control flow; use pipe() for linear transformations.
Service interfaces don't leak dependencies - dependencies are resolved in the layer, not exposed in the service interface.
Effect.fn for tracing: Wrap service methods with Effect.fn("ServiceName.methodName") for automatic span creation.
TypeScript TUI library by SST | Zig native backend | Yoga flexbox | React reconciler
Repo: github.com/sst/opentui | v0.1.57 (Dec 2025) | MIT License
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"moduleResolution": "bundler"
}
}import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
const renderer = await createCliRenderer()
createRoot(renderer).render(<text>Hello world</text>)<box
// Layout (Yoga flexbox)
width={50} // number (cells) | "50%" | "auto"
height={20}
minWidth={10}
maxWidth={100}
flexDirection="row" // "row" | "column" | "row-reverse" | "column-reverse"
flexGrow={1}
flexShrink={0}
flexBasis="auto"
alignItems="center" // "flex-start" | "center" | "flex-end" | "stretch"
justifyContent="center" // "flex-start" | "center" | "flex-end" | "space-between" | "space-around"
alignSelf="auto"
gap={1}
padding={1} // number | {top, bottom, left, right}
paddingTop={1}
margin={1}
position="relative" // "relative" | "absolute"
top={0} left={0} // for absolute positioning
zIndex={1} // ⚠️ always set explicitly for overlays
// Appearance
backgroundColor="blue" // color name | hex
borderStyle="single" // "single" | "double" | "rounded" | "heavy" | "none"
borderColor="white"
// Events
onLayout={(layout) => {}} // {x, y, width, height}
/><text
content="Hello" // or use children: <text>Hello</text>
width={20}
padding={1}
fg="white" // foreground color
bg="black" // background color
bold={true}
italic={true}
underline={true}
strikethrough={true}
wrap="word" // "word" | "char" | "none"
/>
// Inline modifiers
<text>
<span fg="red">Red</span>
<strong>Bold</strong>
<em>Italic</em>
<u>Underline</u>
<b fg="blue">Bold blue</b>
<i>Italic</i>
<br/>
</text><scrollbox
width={40}
height={10}
scrollX={true} // enable horizontal scroll
scrollY={true} // enable vertical scroll (default)
scrollPosition={0} // controlled scroll position
onScroll={(pos) => {}} // scroll callback
flexGrow={1}
padding={1}
>
{/* content taller than height scrolls */}
</scrollbox><input
value={text}
defaultValue="initial"
placeholder="Type here..."
focused={true} // whether input has focus
password={true} // mask characters
disabled={false}
onChange={(value) => setValue(value)}
onSubmit={(value) => handleSubmit(value)} // Enter key
onFocus={() => {}}
onBlur={() => {}}
width={30}
fg="white"
bg="black"
cursorColor="white"
/><textarea
value={text}
defaultValue="initial\nmultiline"
placeholder="Enter text..."
focused={true}
disabled={false}
onChange={(value) => setValue(value)}
onFocus={() => {}}
onBlur={() => {}}
width={40}
height={10}
fg="white"
bg="black"
/><select
options={[
{ label: "Option 1", value: "opt1" },
{ label: "Option 2", value: "opt2" },
]}
value="opt1" // controlled
defaultValue="opt1" // uncontrolled
focused={true}
disabled={false}
open={false} // dropdown open state
onChange={(value) => setSelected(value)}
onOpen={() => {}}
onClose={() => {}}
width={20}
fg="white"
bg="black"
/>import { RGBA, SyntaxStyle } from "@opentui/core"
<code
content={codeString}
filetype="typescript" // language for highlighting
syntaxStyle={SyntaxStyle.fromStyles({
keyword: { fg: RGBA.fromHex("#ff6b6b"), bold: true },
string: { fg: RGBA.fromHex("#51cf66") },
comment: { fg: RGBA.fromHex("#868e96"), italic: true },
number: { fg: RGBA.fromHex("#fab005") },
function: { fg: RGBA.fromHex("#339af0") },
type: { fg: RGBA.fromHex("#be4bdb") },
default: { fg: RGBA.fromHex("#ffffff") },
})}
width={60}
height={20}
showLineNumbers={true}
lineNumberFg="gray"
/>Requires peer dep: web-tree-sitter
// Colors: name or hex
fg="white"
bg="#1a1a2e"
backgroundColor="blue"
borderColor="gray"
// Available color names:
// black, red, green, yellow, blue, magenta, cyan, white
// brightBlack, brightRed, brightGreen, brightYellow,
// brightBlue, brightMagenta, brightCyan, brightWhitewidth={number | string}
height={number | string}
minWidth / maxWidth / minHeight / maxHeight
padding / paddingTop / paddingBottom / paddingLeft / paddingRight
margin / marginTop / marginBottom / marginLeft / marginRight
flexDirection / flexGrow / flexShrink / flexBasis
alignItems / justifyContent / alignSelf
gap
position / top / left / right / bottom
zIndeximport { useKeyboard, useRenderer, useTerminalDimensions, useOnResize } from "@opentui/react"
// Keyboard
useKeyboard((key) => {
if (key.name === "escape") process.exit(0)
})
// Terminal size
const { width, height } = useTerminalDimensions()
// Resize callback
useOnResize((w, h) => console.log(`${w}x${h}`))
// Renderer access
const renderer = useRenderer()
renderer.console.show() // enable console loggingimport { useKeyboard } from "@opentui/react"
function App() {
useKeyboard((key) => {
if (key.name === "escape") process.exit(0)
})
return <text>Press ESC to exit</text>
}const [pressed, setPressed] = useState<Set<string>>(new Set())
useKeyboard((e) => {
setPressed(keys => {
const n = new Set(keys)
e.eventType === "release" ? n.delete(e.name) : n.add(e.name)
return n
})
}, { release: true })function LoginForm() {
const [user, setUser] = useState("")
const [pass, setPass] = useState("")
const [focus, setFocus] = useState<"user"|"pass">("user")
useKeyboard((k) => {
if (k.name === "tab") setFocus(f => f === "user" ? "pass" : "user")
})
return (
<box flexDirection="column" gap={1}>
<box border borderColor={focus === "user" ? "blue" : "gray"}>
<input focused={focus === "user"} onInput={setUser} placeholder="Username" />
</box>
<box border borderColor={focus === "pass" ? "blue" : "gray"}>
<input focused={focus === "pass"} onInput={setPass} password placeholder="Password" />
</box>
</box>
)
}<select
focused
onChange={(_, opt) => setChoice(opt?.value)}
showScrollIndicator
options={[
{ name: "Small", description: "Tiny font", value: "sm" },
{ name: "Medium", description: "Normal", value: "md" },
{ name: "Large", description: "Big font", value: "lg" },
]}
style={{ flexGrow: 1 }}
/><scrollbox width={40} height={10} style={{ border: true }}>
{items.map((item, i) => <text key={i}>{item}</text>)}
</scrollbox>const { width, height } = useTerminalDimensions()
<box flexDirection={width > 80 ? "row" : "column"}>
<box flexGrow={1}><text>Main</text></box>
<box width={width > 80 ? 20 : "100%"}><text>Sidebar</text></box>
</box>const renderer = useRenderer()
useEffect(() => {
renderer.console.show() // enable console panel
console.log("Debug message")
}, [])import { RGBA, SyntaxStyle } from "@opentui/core"
const syntax = SyntaxStyle.fromStyles({
keyword: { fg: RGBA.fromHex("#ff6b6b"), bold: true },
string: { fg: RGBA.fromHex("#51cf66") },
comment: { fg: RGBA.fromHex("#868e96"), italic: true },
number: { fg: RGBA.fromHex("#ffd43b") },
default: { fg: RGBA.fromHex("#fff") },
})
<code content={codeStr} filetype="typescript" syntaxStyle={syntax} />import type { LineNumberRenderable } from "@opentui/core"
const ref = useRef<LineNumberRenderable>(null)
useEffect(() => {
ref.current?.setLineColor(1, "#1a4d1a") // green bg
ref.current?.setLineSign(1, { after: " +", afterColor: "#22c55e" }) // + sign
ref.current?.setLineSign(4, { before: "⚠️", beforeColor: "#f59e0b" }) // warning
}, [])
<line-number ref={ref} content={code} filetype="ts" syntaxStyle={syntax} /><diff
oldContent={oldCode}
newContent={newCode}
oldFilename="old.ts"
newFilename="new.ts"
viewMode="split" // "unified"|"split"
syntaxStyle={syntax}
wrap={true}
/><box
border
borderStyle="rounded" // "single"|"double"|"rounded"|"heavy"
borderColor="cyan"
padding={1}
backgroundColor="#1a1a2e"
>
<text>Content here</text>
</box>// Horizontal split
<box flexDirection="row" width="100%" height="100%">
<box width={20} border><text>Sidebar</text></box>
<box flexGrow={1} border><text>Main</text></box>
</box>
// Vertical with flex
<box flexDirection="column" height="100%">
<box height={3}><text>Header</text></box>
<box flexGrow={1}><text>Content</text></box>
<box height={3}><text>Footer</text></box>
</box>
// Centered
<box width="100%" height="100%" justifyContent="center" alignItems="center">
<text>Centered content</text>
</box>
// Three column
<box flexDirection="row" gap={1}>
<box flexGrow={1}><text>Left</text></box>
<box flexGrow={2}><text>Center (2x)</text></box>
<box flexGrow={1}><text>Right</text></box>
</box><box width="100%" height="100%">
<text>Background content</text>
<box position="absolute" top={5} left={10} zIndex={10} border backgroundColor="black">
<text>Modal overlay</text>
</box>
</box>zIndex explicitly for overlays
useKeyboard((k) => {
if (k.name === "escape") process.exit(0)
if (k.ctrl && k.name === "c") process.exit(0)
if (k.name === "d" && k.ctrl) renderer.console.toggle() // toggle debug console
})- CJK chars corrupt (#255)
- Emoji artifacts (#336)
- Nested scrollbox clips wrong (#388)
- zIndex ignored → always set explicit
zIndexfor layers (#332)
- Kitty graphics leaks into tmux pane title (#334) → detection disabled v0.1.50
- tmux → use v0.1.55+ for 3.6 native OSC4 support
- Zellij → theme console errors (#4017)
- shift+space broken on WezTerm (#380)
- Ctrl+A/E fixed v0.1.51 (was jumping to buffer start/end)
- Console not restored on exit (#293)
- Suspend (Ctrl+Z) screen switch broken (#283) - partially fixed v0.1.49
- External editor return → UI doesn't re-render (#3311)
- Effect-TS teardown hooks blocked by OpenTUI import
- Top-level await blocks bytecode compilation (#355)
| Ver | Key Fixes |
|---|---|
| 0.1.57 | configurable exit signals |
| 0.1.55 | tmux 3.6 OSC4, input modifier fix |
| 0.1.52 | key repeat fix |
| 0.1.51 | Ctrl+A/E nav |
| 0.1.50 | integer overflow, Kitty detection disabled |
Always use latest version
- Framework (React) → declarative
- Component (TS) → Renderable tree, Yoga layout
- FFI Bridge (Bun dlopen) → JS↔Zig
- Native (Zig) → double-buffer, ANSI, Unicode
| Dep | Purpose |
|---|---|
yoga-layout |
flexbox |
jimp |
image processing |
web-tree-sitter |
syntax parse (peer dep) |
- Repo: github.com/sst/opentui
- npm core: npmjs.com/package/@opentui/core
- npm react: npmjs.com/package/@opentui/react
- DeepWiki docs: deepwiki.com/sst/opentui (best docs)
- Awesome list: github.com/msmps/awesome-opentui
- OpenCode (opencode.ai) - AI coding agent (main reference impl)
- terminal.shop - terminal shopping