UNPKG

tuix

Version:

A performant TUI framework for Bun with JSX and reactive state management

278 lines (231 loc) 8.41 kB
/** * Terminal Service Implementation - Real terminal operations using Bun APIs */ import { Effect, Layer, Ref } from "effect" import { TerminalService } from "../terminal.ts" import { TerminalError } from "@/core/errors.ts" import type { WindowSize, TerminalCapabilities } from "@/core/types.ts" // ANSI Escape Sequences const ESC = '\x1b' const CSI = `${ESC}[` const ANSI = { // Cursor Movement cursorTo: (x: number, y: number) => `${CSI}${y};${x}H`, cursorUp: (n: number) => `${CSI}${n}A`, cursorDown: (n: number) => `${CSI}${n}B`, cursorForward: (n: number) => `${CSI}${n}C`, cursorBack: (n: number) => `${CSI}${n}D`, // Cursor Visibility cursorHide: `${CSI}?25l`, cursorShow: `${CSI}?25h`, cursorSave: `${ESC}7`, cursorRestore: `${ESC}8`, // Screen clear: `${CSI}2J${CSI}H`, clearLine: `${CSI}2K`, clearToEOL: `${CSI}0K`, clearToSOL: `${CSI}1K`, clearToEOS: `${CSI}0J`, clearToSOS: `${CSI}1J`, // Scrolling scrollUp: (n: number) => `${CSI}${n}S`, scrollDown: (n: number) => `${CSI}${n}T`, // Modes alternateScreenEnable: `${CSI}?1049h`, alternateScreenDisable: `${CSI}?1049l`, mouseTrackingEnable: `${CSI}?1000h`, mouseTrackingDisable: `${CSI}?1000l`, // Styling reset: `${CSI}0m`, bold: `${CSI}1m`, dim: `${CSI}2m`, italic: `${CSI}3m`, underline: `${CSI}4m`, // Other bell: '\x07', setTitle: (title: string) => `${ESC}]0;${title}\x07`, requestCursorPosition: `${CSI}6n`, // Cursor Shapes cursorBlock: `${CSI}1 q`, cursorUnderline: `${CSI}3 q`, cursorBar: `${CSI}5 q`, cursorBlinkingBlock: `${CSI}0 q`, cursorBlinkingUnderline: `${CSI}4 q`, cursorBlinkingBar: `${CSI}6 q`, } as const /** * Create the live Terminal service implementation */ export const TerminalServiceLive = Layer.effect( TerminalService, Effect.gen(function* (_) { const stdout = process.stdout const stdin = process.stdin const isRawMode = yield* _(Ref.make(false)) const isAlternateScreen = yield* _(Ref.make(false)) // Helper to write to stdout const write = (data: string) => Effect.try({ try: () => { stdout.write(data) }, catch: (error) => new TerminalError({ operation: "write", cause: error }) }) // Helper to detect terminal capabilities const detectCapabilities = (): TerminalCapabilities => { const env = process.env const colorSupport = (() => { if (env.COLORTERM === 'truecolor') return 'truecolor' if (env.TERM?.includes('256color')) return '256' if (env.TERM && !env.NO_COLOR) return 'basic' return 'none' })() return { colors: colorSupport, unicode: process.platform !== 'win32', // Simplified check mouse: true, // Most modern terminals support mouse clipboard: false, // Requires additional setup sixel: false, // Image protocol support kitty: env.TERM === 'xterm-kitty', iterm2: env.TERM_PROGRAM === 'iTerm.app', windowTitle: true, columns: stdout.columns || 80, rows: stdout.rows || 24, } } return { // Basic Terminal Operations clear: write(ANSI.clear), write: (text: string) => write(text), writeLine: (text: string) => write(text + '\n'), moveCursor: (x: number, y: number) => write(ANSI.cursorTo(x, y)), moveCursorRelative: (dx: number, dy: number) => Effect.gen(function* (_) { if (dx > 0) yield* _(write(ANSI.cursorForward(dx))) else if (dx < 0) yield* _(write(ANSI.cursorBack(-dx))) if (dy > 0) yield* _(write(ANSI.cursorDown(dy))) else if (dy < 0) yield* _(write(ANSI.cursorUp(-dy))) }), hideCursor: write(ANSI.cursorHide), showCursor: write(ANSI.cursorShow), // Terminal State Management getSize: Effect.sync(() => ({ width: stdout.columns || 80, height: stdout.rows || 24, })), setRawMode: (enabled: boolean) => Effect.gen(function* (_) { const currentRawMode = yield* _(Ref.get(isRawMode)) if (currentRawMode === enabled) return yield* _(Effect.try({ try: () => { if (stdin.isTTY) { stdin.setRawMode(enabled) } }, catch: (error) => new TerminalError({ operation: "setRawMode", cause: error }) })) yield* _(Ref.set(isRawMode, enabled)) }), setAlternateScreen: (enabled: boolean) => Effect.gen(function* (_) { const current = yield* _(Ref.get(isAlternateScreen)) if (current === enabled) return yield* _(write(enabled ? ANSI.alternateScreenEnable : ANSI.alternateScreenDisable)) yield* _(Ref.set(isAlternateScreen, enabled)) }), saveCursor: write(ANSI.cursorSave), restoreCursor: write(ANSI.cursorRestore), // Terminal Capabilities getCapabilities: Effect.sync(detectCapabilities), supportsTrueColor: Effect.sync(() => detectCapabilities().colors === 'truecolor' ), supports256Colors: Effect.sync(() => { const colors = detectCapabilities().colors return colors === '256' || colors === 'truecolor' }), supportsUnicode: Effect.sync(() => detectCapabilities().unicode ), // Screen Management clearToEndOfLine: write(ANSI.clearToEOL), clearToStartOfLine: write(ANSI.clearToSOL), clearLine: write(ANSI.clearLine), clearToEndOfScreen: write(ANSI.clearToEOS), clearToStartOfScreen: write(ANSI.clearToSOS), scrollUp: (lines: number) => write(ANSI.scrollUp(lines)), scrollDown: (lines: number) => write(ANSI.scrollDown(lines)), // Advanced Features setTitle: (title: string) => write(ANSI.setTitle(title)), bell: write(ANSI.bell), getCursorPosition: Effect.gen(function* (_) { // This is complex as it requires reading from stdin // For now, return a placeholder yield* _(Effect.logWarning("getCursorPosition not fully implemented")) return { x: 1, y: 1 } }), setCursorShape: (shape: 'block' | 'underline' | 'bar') => write( shape === 'block' ? ANSI.cursorBlock : shape === 'underline' ? ANSI.cursorUnderline : ANSI.cursorBar ), setCursorBlink: (enabled: boolean) => write(enabled ? ANSI.cursorBlinkingBlock : ANSI.cursorBlock), } }) ) /** * Create a test/mock Terminal service for testing */ export const TerminalServiceTest = Layer.succeed( TerminalService, { clear: Effect.void, write: (_text: string) => Effect.void, writeLine: (_text: string) => Effect.void, moveCursor: (_x: number, _y: number) => Effect.void, moveCursorRelative: (_dx: number, _dy: number) => Effect.void, hideCursor: Effect.void, showCursor: Effect.void, getSize: Effect.succeed({ width: 80, height: 24 }), setRawMode: (_enabled: boolean) => Effect.void, setAlternateScreen: (_enabled: boolean) => Effect.void, saveCursor: Effect.void, restoreCursor: Effect.void, getCapabilities: Effect.succeed({ colors: 'truecolor', unicode: true, mouse: true, clipboard: false, sixel: false, kitty: false, iterm2: false, windowTitle: true, columns: 80, rows: 24, }), supportsTrueColor: Effect.succeed(true), supports256Colors: Effect.succeed(true), supportsUnicode: Effect.succeed(true), clearToEndOfLine: Effect.void, clearToStartOfLine: Effect.void, clearLine: Effect.void, clearToEndOfScreen: Effect.void, clearToStartOfScreen: Effect.void, scrollUp: (_lines: number) => Effect.void, scrollDown: (_lines: number) => Effect.void, setTitle: (_title: string) => Effect.void, bell: Effect.void, getCursorPosition: Effect.succeed({ x: 1, y: 1 }), setCursorShape: (_shape: 'block' | 'underline' | 'bar') => Effect.void, setCursorBlink: (_enabled: boolean) => Effect.void, } )