diff --git a/.gitignore b/.gitignore index f8285e5..e0ec283 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ jspm_packages/ # Stores VSCode versions used for testing VSCode extensions .vscode-test +/.pnpm-store/ diff --git a/examples/basic/package.json b/examples/basic/package.json deleted file mode 100644 index a00d19a..0000000 --- a/examples/basic/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@examples/basic", - "private": true, - "type": "module", - "scripts": { - "start": "node src/index.ts" - }, - "dependencies": { - "@clack/ui": "workspace:*" - } -} diff --git a/examples/basic/src/index.ts b/examples/basic/src/index.ts deleted file mode 100644 index 1d5d075..0000000 --- a/examples/basic/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { box, text, createRoot, rgba } from '@clack/ui'; - -const cyan = rgba(0, 205, 205); -const green = rgba(0, 205, 0); -const white = rgba(229, 229, 229); -const gray = rgba(127, 127, 127); -const blue = rgba(0, 0, 238); - -// Framework-free: build a tree with `box` / `text`, render it once. -const root = await createRoot(); - -root.render( - box( - { - layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, - border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, - }, - text({ color: cyan }, '@clack/ui'), - box( - { layout: { direction: 'ttb' } }, - row('status ', text({ color: green }, '● ready')), - row('version', text({ color: white }, '0.0.0')), - row('render ', text({ color: white }, '@bomb.sh/tty')), - ), - ), -); - -function row(label: string, value: ReturnType) { - return box({ layout: { direction: 'ltr', gap: 1 } }, text({ color: gray }, label), value); -} diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json deleted file mode 100644 index 150435b..0000000 --- a/examples/basic/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "@bomb.sh/tools/tsconfig.json", - "compilerOptions": { - "noEmit": true - } -} diff --git a/package.json b/package.json index aabef2a..ed39166 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,10 @@ }, "devDependencies": { "@bomb.sh/tools": "^0.6.1", + "@termless/core": "^0.10.2", + "@termless/test": "^0.10.2", "@types/node": "^22.20.0", + "node-pty": "1.2.0-beta.15", "vitest": "^4.1.9" }, "devEngines": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 4df8256..6d3cb5f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,7 +19,7 @@ "type": "module", "exports": { ".": { - "types": "./dist/index.d.ts", + "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, "./package.json": "./package.json" diff --git a/packages/ui/src/children.test.ts b/packages/ui/src/children.test.ts deleted file mode 100644 index be30c3e..0000000 --- a/packages/ui/src/children.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { text } from './components/text.ts'; -import { normalizeChildren, stringifyPrimitive } from './children.ts'; - -// `@bomb.sh/tty` OP_TEXT directive. -const TEXT = 3; - -describe('stringifyPrimitive', () => { - test('keeps strings and numbers (incl. 0 and ""), drops nullish and booleans', () => { - expect(stringifyPrimitive('a')).toBe('a'); - expect(stringifyPrimitive(0)).toBe('0'); - expect(stringifyPrimitive('')).toBe(''); - expect(stringifyPrimitive(null)).toBeUndefined(); - expect(stringifyPrimitive(undefined)).toBeUndefined(); - expect(stringifyPrimitive(true)).toBeUndefined(); - expect(stringifyPrimitive(false)).toBeUndefined(); - }); -}); - -describe('normalizeChildren — React JSX coercion', () => { - test('strings and numbers become text ops', () => { - expect(normalizeChildren(['hi', 42])).toMatchObject([ - { directive: TEXT, content: 'hi' }, - { directive: TEXT, content: '42' }, - ]); - }); - - test('keeps 0 and empty string — the drop is nullish/boolean, not falsy', () => { - expect(normalizeChildren([0, '', false, null, undefined, true])).toMatchObject([ - { directive: TEXT, content: '0' }, - { directive: TEXT, content: '' }, - ]); - }); - - test('flattens arrays recursively', () => { - expect(normalizeChildren(['a', ['b', ['c']]])).toMatchObject([ - { content: 'a' }, - { content: 'b' }, - { content: 'c' }, - ]); - }); - - test('passes existing ops through by reference, untouched', () => { - const t = text({ color: 9 }, 'x'); - const ops = normalizeChildren(['a', t]); - expect(ops[0]).toMatchObject({ directive: TEXT, content: 'a' }); - expect(ops[1]).toBe(t); - }); -}); diff --git a/packages/ui/src/children.ts b/packages/ui/src/children.ts deleted file mode 100644 index 6af2c19..0000000 --- a/packages/ui/src/children.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { text as ttyText, type Op } from '@bomb.sh/tty'; - -/** A rendered node — what a component returns: one op (`text`) or a list of ops (`box`). */ -export type Node = Op | Op[]; - -/** A primitive child. Strings and numbers render as text (`0` and `''` included); `null`, `undefined`, and booleans render nothing — matching React's JSX rules. */ -export type Primitive = string | number | boolean | null | undefined; - -/** Anything you can pass as a child: a node, a primitive, or a (possibly nested) array of children. */ -export type Child = Node | Primitive | Child[]; - -/** - * Coerce a primitive child to its text form, or `undefined` if it renders nothing - * (`null`, `undefined`, or a boolean). Strings and numbers are kept — including `0` - * and `''` — so this is an explicit nullish/boolean check, not a truthiness test. - */ -export function stringifyPrimitive(value: Primitive): string | undefined { - if (value == null || typeof value === 'boolean') return undefined; - return String(value); -} - -/** - * Normalize heterogeneous children into the flat `Op[]` stream the renderer consumes, - * applying React's JSX coercion rules: strings and numbers become text ops, `null`/ - * `undefined`/booleans drop, arrays flatten recursively, and existing ops pass through. - */ -export function normalizeChildren(children: Child[]): Op[] { - const ops: Op[] = []; - // Recurse into one accumulator rather than spreading per level — avoids intermediate - // arrays and the argument-count ceiling of `push(...arr)` on large flattened lists. - const walk = (nodes: Child[]): void => { - for (const child of nodes) { - if (Array.isArray(child)) { - walk(child); - } else if (child !== null && typeof child === 'object') { - ops.push(child); - } else { - const str = stringifyPrimitive(child); - if (str !== undefined) ops.push(ttyText(str)); - } - } - }; - walk(children); - return ops; -} diff --git a/packages/ui/src/components/box.ts b/packages/ui/src/components/box.ts deleted file mode 100644 index 49e9d9e..0000000 --- a/packages/ui/src/components/box.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { open as ttyOpen, type OpenElement, type Op, close as ttyClose } from '@bomb.sh/tty'; -import { type Child, normalizeChildren } from '../children.ts'; - -/** Box props — a 1:1 passthrough to the tty `open` op, plus an optional `key` for identity across renders. */ -export type BoxProps = Omit & { key?: string }; - -export function box({ key, ...props }: BoxProps, ...children: Child[]): Op[] { - return [ttyOpen(key ?? '', boxPropsToOps(props)), ...normalizeChildren(children), ttyClose()]; -} - -function boxPropsToOps( - props: Omit, -): Omit { - return props; -} diff --git a/packages/ui/src/components/text.ts b/packages/ui/src/components/text.ts deleted file mode 100644 index 6215129..0000000 --- a/packages/ui/src/components/text.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { text as ttyText, type Op, type Text as TextOp } from '@bomb.sh/tty'; -import { type Primitive, stringifyPrimitive } from '../children.ts'; - -/** Text props — a 1:1 passthrough to the tty `text` op for now (friendlier shapes later). */ -export type TextProps = Omit; - -export function text(props: TextProps, ...content: Primitive[]): Op { - // `join` coerces the `undefined` drops (null/undefined/booleans) to '', leaving `0`/`''` intact. - const str = content.map(stringifyPrimitive).join(''); - return ttyText(str, textPropsToOps(props)); -} - -function textPropsToOps(props: TextProps): Omit { - return props; -} diff --git a/packages/ui/src/core.ts b/packages/ui/src/core.ts deleted file mode 100644 index 821b7f6..0000000 --- a/packages/ui/src/core.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './core/context.ts'; -export * from './core/node.ts'; diff --git a/packages/ui/src/core/context.test.ts b/packages/ui/src/core/context.test.ts deleted file mode 100644 index 97b24de..0000000 --- a/packages/ui/src/core/context.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { createContext, MissingContextError } from './context.ts'; -import { Node } from './node.ts'; - -const theme = createContext('theme'); -const count = createContext('count'); -const locale = createContext('locale', 'en'); - -describe('context', () => { - describe('on a single node', () => { - test('get returns undefined for a context that was never set', () => { - expect(theme.get(new Node())).toBeUndefined(); - }); - - test('does not inherit values from Object.prototype', () => { - const constructor = createContext('constructor'); - - expect(constructor.get(new Node())).toBeUndefined(); - }); - - test('get returns the value after it is set', () => { - const n = new Node(); - theme.set(n, 'dark'); - expect(theme.get(n)).toBe('dark'); - }); - - test('the last set wins', () => { - const n = new Node(); - theme.set(n, 'dark'); - theme.set(n, 'light'); - expect(theme.get(n)).toBe('light'); - }); - - test('keeps falsy values — 0 is stored, not treated as unset', () => { - const n = new Node(); - count.set(n, 0); - expect(count.get(n)).toBe(0); - }); - - test('distinct contexts do not collide', () => { - const n = new Node(); - theme.set(n, 'dark'); - expect(count.get(n)).toBeUndefined(); - }); - - test('contexts with the same name share a value', () => { - const first = createContext('shared'); - const second = createContext('shared'); - const n = new Node(); - first.set(n, 'value'); - expect(second.get(n)).toBe('value'); - }); - - test('expect returns the value when the context is set', () => { - const n = new Node(); - theme.set(n, 'dark'); - expect(theme.expect(n)).toBe('dark'); - }); - - test('expect throws MissingContextError when unset with no default', () => { - expect(() => theme.expect(new Node())).toThrow(MissingContextError); - }); - - test('expect falls back to the default when unset', () => { - expect(locale.expect(new Node())).toBe('en'); - }); - - test('a set value takes precedence over the default', () => { - const n = new Node(); - locale.set(n, 'fr'); - expect(locale.expect(n)).toBe('fr'); - }); - - test('get returns the default when the context is unset', () => { - expect(locale.get(new Node())).toBe('en'); - }); - - test('a set value overrides the default for get', () => { - const n = new Node(); - locale.set(n, 'fr'); - expect(locale.get(n)).toBe('fr'); - }); - - test('hasOwn is false before a value is set', () => { - expect(theme.hasOwn(new Node())).toBe(false); - }); - - test('hasOwn is true once a value is set on the node', () => { - const n = new Node(); - theme.set(n, 'dark'); - expect(theme.hasOwn(n)).toBe(true); - }); - }); - - describe('inheritance through the node tree', () => { - test('a child inherits a value set on its parent', () => { - const parent = new Node(); - theme.set(parent, 'dark'); - expect(theme.get(new Node(parent))).toBe('dark'); - }); - - test('a value set on a child shadows the parent for that child only', () => { - const parent = new Node(); - theme.set(parent, 'dark'); - const child = new Node(parent); - theme.set(child, 'light'); - expect(theme.get(child)).toBe('light'); - expect(theme.get(parent)).toBe('dark'); - }); - - test('setting a value on a child does not leak up to the parent', () => { - const parent = new Node(); - const child = new Node(parent); - theme.set(child, 'light'); - expect(theme.get(parent)).toBeUndefined(); - }); - - test('expect inherits an ancestor value', () => { - const parent = new Node(); - theme.set(parent, 'dark'); - expect(theme.expect(new Node(parent))).toBe('dark'); - }); - - test('an inherited value resolves through multiple levels', () => { - const grand = new Node(); - theme.set(grand, 'dark'); - const parent = new Node(grand); - expect(theme.get(new Node(parent))).toBe('dark'); - }); - - test('an explicitly set undefined shadows the ancestor value', () => { - const parent = new Node(); - locale.set(parent, 'fr'); - const child = new Node(parent); - locale.set(child, undefined as unknown as string); - expect(locale.get(child)).toBe('en'); - expect(locale.get(parent)).toBe('fr'); - }); - - test('an explicitly set undefined shadows with no default to fall back to', () => { - const parent = new Node(); - theme.set(parent, 'dark'); - const child = new Node(parent); - theme.set(child, undefined as unknown as string); - expect(theme.get(child)).toBeUndefined(); - expect(() => theme.expect(child)).toThrow(MissingContextError); - }); - - test('hasOwn is true for an explicitly set undefined', () => { - const parent = new Node(); - theme.set(parent, 'dark'); - const child = new Node(parent); - theme.set(child, undefined as unknown as string); - expect(theme.hasOwn(child)).toBe(true); - }); - - test('hasOwn is false for a value that is only inherited', () => { - const parent = new Node(); - theme.set(parent, 'dark'); - expect(theme.hasOwn(new Node(parent))).toBe(false); - }); - - test('hasOwn is true only for the node that owns the value', () => { - const parent = new Node(); - theme.set(parent, 'dark'); - const child = new Node(parent); - theme.set(child, 'light'); - expect(theme.hasOwn(child)).toBe(true); - expect(theme.hasOwn(parent)).toBe(true); - }); - - test('expect falls back to the default when neither node nor ancestors set it', () => { - const parent = new Node(); - expect(locale.expect(new Node(parent))).toBe('en'); - }); - - test('get falls back to the default when neither node nor ancestors set it', () => { - const parent = new Node(); - expect(locale.get(new Node(parent))).toBe('en'); - }); - }); -}); diff --git a/packages/ui/src/core/context.ts b/packages/ui/src/core/context.ts deleted file mode 100644 index 536764b..0000000 --- a/packages/ui/src/core/context.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Node } from './node.ts'; - -/** - * A `Context` defines a value which is in effect for a given {@link Node}. - * - * Unless a context value is set for a particular node, it will inherit its - * value from that node's parent. - */ -export class Context { - /** A unique identifier for this context. */ - readonly name: string; - - /** The value returned when this context is not present on a node. */ - readonly defaultValue?: T; - - constructor(name: string, defaultValue?: T) { - this.name = name; - this.defaultValue = defaultValue; - } - - get(node: Node): T | undefined { - for (let n: Node | undefined = node; n; n = n.parent) { - const contexts = n.contexts; - if (contexts?.has(this.name)) { - const value = contexts.get(this.name) as T | undefined; - return value === undefined ? this.defaultValue : value; - } - } - return this.defaultValue; - } - - set(node: Node, value: T): void { - const contexts = (node.contexts ??= new Map()); - contexts.set(this.name, value); - } - - expect(node: Node): T { - const value = this.get(node); - if (value === undefined) { - throw new MissingContextError(this.name); - } else { - return value; - } - } - - hasOwn(node: Node): boolean { - return node.contexts?.has(this.name) ?? false; - } -} - -/** - * Create a new {@link Context}. - * - * @param name - the unique name to give this context. - * @param defaultValue - the value used when the context is not present on a node. - * @returns the new context - */ -export function createContext(name: string, defaultValue?: T): Context { - return new Context(name, defaultValue); -} - -export class MissingContextError extends Error { - constructor(name: string) { - super(`no value set for context "${name}"`); - this.name = 'MissingContextError'; - } -} diff --git a/packages/ui/src/core/node.ts b/packages/ui/src/core/node.ts deleted file mode 100644 index 2049b94..0000000 --- a/packages/ui/src/core/node.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class Node { - readonly parent?: Node; - readonly children: Set; - - contexts?: Map; - - constructor(parent?: Node) { - this.parent = parent; - this.children = new Set(); - } -} diff --git a/packages/ui/src/dom.ts b/packages/ui/src/dom.ts new file mode 100644 index 0000000..e56606e --- /dev/null +++ b/packages/ui/src/dom.ts @@ -0,0 +1,75 @@ +/** + * Container type for valid names of createElement() + */ +export interface Elements { + document: Element; +} + +export class Document { + readonly documentElement: Element; + readonly notify: () => void; + + constructor(notify: () => void) { + this.notify = notify; + this.documentElement = new Element('document', notify); + } + + createElement(tagName: keyof Elements) { + return new Element(tagName, this.notify); + } + + createTextNode(data: string): Text { + return new Text(data, this.notify); + } +} + +export class Node { + readonly nodeName: string; + readonly childNodes: Node[]; + + protected readonly notify: () => void; + + constructor(nodeName: string, notify: Node['notify']) { + this.notify = notify; + this.nodeName = nodeName; + this.childNodes = []; + } + + appendChild(node: Node): Node { + this.childNodes.push(node); + this.notify(); + return node; + } +} + +export class Element extends Node { + readonly tagName: string; + + constructor(tagName: keyof Elements, notify: Node['notify']) { + super(tagName.toUpperCase(), notify); + this.tagName = tagName; + } + + get children(): Element[] { + return this.childNodes.filter((node) => node instanceof Element); + } +} + +export class Text extends Node { + #data: string; + constructor(data: string, notify: Node['notify']) { + super('#text', notify); + this.#data = data; + } + + get data() { + return this.#data; + } + + set data(data: string) { + if (data !== this.data) { + this.#data = data; + this.notify(); + } + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 5ffcc04..ca4a46a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1 @@ -export { rgba } from '@bomb.sh/tty'; -export { box, type BoxProps } from './components/box.ts'; -export { text, type TextProps } from './components/text.ts'; -export { createRoot, type RenderRoot, type RenderRootOptions } from './render/createRoot.ts'; +export { createUI } from './ui.ts'; diff --git a/packages/ui/src/layout.ts b/packages/ui/src/layout.ts new file mode 100644 index 0000000..05bce7c --- /dev/null +++ b/packages/ui/src/layout.ts @@ -0,0 +1,42 @@ +import { close, open, text, type Op } from '@bomb.sh/tty'; +import { Element, Text } from './dom.ts'; + +export class Layout { + #lastIdNumber = 0; + #stableIds = new WeakMap(); + #root: Element; + + constructor(root: Element) { + this.#root = root; + } + + /** Serialize an element to TTY Ops. */ + *layout(element: Element = this.#root): IterableIterator { + let stableId = this.#stableIds.get(element); + if (!stableId) { + stableId = `${element.nodeName}: ${this.#lastIdNumber++}`; + this.#stableIds.set(element, stableId); + } + yield open(stableId); + + try { + let content = ''; + for (const child of element.childNodes) { + if (child instanceof Text) { + content += child.data; + } else if (child instanceof Element) { + if (content !== '') { + yield text(content); + content = ''; + } + yield* this.layout(child); + } + } + if (content !== '') { + yield text(content); + } + } finally { + yield close(); + } + } +} diff --git a/packages/ui/src/render.test.ts b/packages/ui/src/render.test.ts new file mode 100644 index 0000000..d071a61 --- /dev/null +++ b/packages/ui/src/render.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from 'vitest'; +import { RenderApi } from './render.ts'; + +describe('RenderApi', () => { + test('waits while clean and yields void when requested', async () => { + using fixture = setup(); + const { renderer, iterator } = fixture; + const pending = iterator.next(); + + await expectPending(pending); + renderer.request(); + + await expect(pending).resolves.toEqual({ done: false, value: undefined }); + }); + + test('coalesces requests made before delivery without leaving an extra iteration', async () => { + using fixture = setup(); + const { controller, renderer, iterator } = fixture; + renderer.request(); + renderer.request(); + renderer.request(); + + await expect(iterator.next()).resolves.toEqual({ done: false, value: undefined }); + const pending = iterator.next(); + await expectPending(pending); + controller.abort(); + + await expect(pending).resolves.toEqual({ done: true, value: undefined }); + }); + + test('retains one follow-up request while the consumer awaits slow output', async () => { + using fixture = setup(); + const { controller, renderer, iterator } = fixture; + const output = Promise.withResolvers(); + let iterations = 0; + const consuming = (async () => { + for await (const _ of iterator) { + iterations++; + // Stop an unexpected extra iteration so a regression cannot spin forever. + if (iterations > 2) break; + if (iterations === 1) await output.promise; + } + })(); + + try { + renderer.request(); + await nextTurn(); + expect(iterations).toBe(1); + + renderer.request(); + renderer.request(); + renderer.request(); + await nextTurn(); + expect(iterations).toBe(1); + + output.resolve(); + await nextTurn(); + expect(iterations).toBe(2); + } finally { + controller.abort(); + output.resolve(); + await consuming; + } + }); + + test('abort ends a waiting iterator and later requests cannot restart it', async () => { + using fixture = setup(); + const { controller, renderer, iterator } = fixture; + const pending = iterator.next(); + controller.abort(); + + await expect(pending).resolves.toEqual({ done: true, value: undefined }); + renderer.request(); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); + }); + + test('an already-aborted signal ignores requests before iteration', async () => { + const controller = new AbortController(); + controller.abort(); + const renderer = new RenderApi(controller.signal); + const iterator = renderer[Symbol.asyncIterator](); + renderer.request(); + + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); + renderer.request(); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); + }); + + test.each(['before iteration', 'while waiting'] as const)( + 'abort discards a pending request %s', + async (phase) => { + using fixture = setup(); + const { controller, renderer, iterator } = fixture; + const pending = phase === 'while waiting' ? iterator.next() : undefined; + renderer.request(); + controller.abort(); + renderer.request(); + + await expect(pending ?? iterator.next()).resolves.toEqual({ done: true, value: undefined }); + }, + ); +}); + +function setup(): { + controller: AbortController; + renderer: RenderApi; + iterator: AsyncGenerator; + [Symbol.dispose](): void; +} { + const controller = new AbortController(); + const renderer = new RenderApi(controller.signal); + const iterator = renderer[Symbol.asyncIterator](); + return { + controller, + renderer, + iterator, + [Symbol.dispose]() { + controller.abort(); + }, + }; +} + +async function expectPending(result: PromiseLike): Promise { + let settled = false; + void result.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + await nextTurn(); + expect(settled).toBe(false); +} + +// Drain promise continuations without sleeping for a wall-clock duration. +function nextTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/packages/ui/src/render.ts b/packages/ui/src/render.ts new file mode 100644 index 0000000..479f173 --- /dev/null +++ b/packages/ui/src/render.ts @@ -0,0 +1,40 @@ +/** + * Drives a single `for await` rendering loop. + * + * `request()` asks the loop to run. Multiple requests before the iterator + * yields are combined into one pass through the loop. + * + * The caller renders and awaits its output write inside the loop. Any requests + * made during that work cause one more pass after it finishes. + * + * Aborting the signal ends iteration. + */ +export class RenderApi { + #signal: AbortSignal; + #requested = Promise.withResolvers(); + + constructor(signal: AbortSignal) { + this.#signal = signal; + if (!signal.aborted) { + signal.addEventListener('abort', () => this.#requested.resolve(), { once: true }); + } + } + + /** Ask the rendering loop to run; repeated pending requests count as one. */ + request(): void { + this.#requested.resolve(); + } + + async *[Symbol.asyncIterator](): AsyncGenerator { + while (!this.#signal.aborted) { + await this.#requested.promise; + if (this.#signal.aborted) { + break; + } + + // Collect requests for the next pass before handing control to the loop. + this.#requested = Promise.withResolvers(); + yield; + } + } +} diff --git a/packages/ui/src/render/createRoot.ts b/packages/ui/src/render/createRoot.ts deleted file mode 100644 index e166b97..0000000 --- a/packages/ui/src/render/createRoot.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { WriteStream } from 'node:tty'; -import { createTerm, type Op, type Term } from '@bomb.sh/tty'; -import { type Child, normalizeChildren } from '../children.ts'; -import { resolveIds } from './ids.ts'; - -/** Thrown when {@link RenderRoot.render} is called before {@link RenderRoot.ready} completes. */ -export class RenderRootNotReadyError extends Error { - readonly code = 'CLACK_RENDER_ROOT_NOT_READY'; - constructor() { - super('RenderRoot is not ready: await `ready()` (or use `createRoot`) before `render`.'); - this.name = 'RenderRootNotReadyError'; - } -} - -export interface RenderRootOptions { - /** Stream to write rendered frames to. Defaults to `process.stdout`. */ - output?: WriteStream; - /** Render width in columns. Defaults to the output's column count. */ - width?: number; - /** Render height in rows. Defaults to the output's row count. */ - height?: number; -} - -/** Owns a tty terminal and its children; each {@link RenderRoot.render} re-renders the full op stream (diffing happens in `Term.render`). */ -export class RenderRoot { - #output: WriteStream; - #width: number; - #height: number; - #term?: Term; - #children: Op[] = []; - - constructor(options: RenderRootOptions = {}) { - this.#output = options.output ?? process.stdout; - this.#width = options.width ?? this.#output.columns ?? 80; - this.#height = options.height ?? this.#output.rows ?? 24; - } - - /** Initialize the terminal. Required before {@link RenderRoot.render}; {@link createRoot} awaits it for you. */ - async ready(): Promise { - this.#term ??= await createTerm({ width: this.#width, height: this.#height }); - } - - /** - * Render `children` to the output, replacing the previous frame. Call with no - * arguments to re-render the current children (e.g. on resize). - * - * Synchronous on purpose: `Term.render` and the stream write are both sync, so each - * call runs to completion before the next starts — no await means no microtask gap in - * which a later `render` could clobber this frame. Requires {@link RenderRoot.ready}. - */ - render(...children: Child[]): void { - if (!this.#term) throw new RenderRootNotReadyError(); - if (children.length > 0) this.#children = resolveIds(normalizeChildren(children)); - const { output } = this.#term.render(this.#children, { mode: 'line' }); - this.#output.write(output); - } -} -export async function createRoot(options?: Pick): Promise { - const root = new RenderRoot(options); - await root.ready(); - return root; -} diff --git a/packages/ui/src/render/ids.test.ts b/packages/ui/src/render/ids.test.ts deleted file mode 100644 index 7537c9a..0000000 --- a/packages/ui/src/render/ids.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { box } from '../components/box.ts'; -import { text } from '../components/text.ts'; -import { resolveIds } from './ids.ts'; - -/** Pull the resolved ids off the open ops, in document order. */ -function ids(ops: ReturnType): string[] { - return ops.filter((op) => op.directive === 2).map((op) => (op as { id: string }).id); -} - -describe('resolveIds — hierarchical key paths', () => { - test('keyed boxes resolve to their parent path + key', () => { - const ops = box({ key: 'app' }, box({ key: 'sidebar' }), box({ key: 'body' })); - expect(ids(resolveIds(ops))).toEqual(['app', 'app/sidebar', 'app/body']); - }); - - test('a kept sibling keeps its id when a conditional sibling is dropped', () => { - const withSidebar = box({ key: 'app' }, box({ key: 'sidebar' }), box({ key: 'body' })); - const conditionalSidebar = false; - const withoutSidebar = box( - { key: 'app' }, - conditionalSidebar ? box({ key: 'sidebar' }) : null, - box({ key: 'body' }), - ); - const bodyId = (resolved: string[]) => resolved.find((id) => id.endsWith('body')); - expect(bodyId(ids(resolveIds(withSidebar)))).toBe('app/body'); - expect(bodyId(ids(resolveIds(withoutSidebar)))).toBe('app/body'); - }); - - test('unkeyed boxes fall back to their positional index among siblings', () => { - const ops = box({}, box({}), box({})); - expect(ids(resolveIds(ops))).toEqual(['0', '0/0', '0/1']); - }); - - test('a key overrides the index, but the index slot still advances', () => { - const ops = box({ key: 'app' }, box({}), box({ key: 'x' }), box({})); - expect(ids(resolveIds(ops))).toEqual(['app', 'app/0', 'app/x', 'app/2']); - }); - - test('text children do not consume a sibling index slot', () => { - const ops = box({ key: 'app' }, text({}, 'hi'), box({})); - expect(ids(resolveIds(ops))).toEqual(['app', 'app/0']); - }); - - test('does not mutate its input — the source keeps its raw key segments', () => { - const ops = box({ key: 'app' }, box({ key: 'body' })); - resolveIds(ops); - // raw segments survive, so re-resolving is idempotent - expect(ids(ops)).toEqual(['app', 'body']); - expect(ids(resolveIds(ops))).toEqual(['app', 'app/body']); - }); -}); diff --git a/packages/ui/src/render/ids.ts b/packages/ui/src/render/ids.ts deleted file mode 100644 index d60a234..0000000 --- a/packages/ui/src/render/ids.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Op } from '@bomb.sh/tty'; - -// `@bomb.sh/tty` op directives (OP_OPEN_ELEMENT / OP_CLOSE_ELEMENT); not exported by the package. -const OPEN = 2; -const CLOSE = 4; - -/** Separator between key segments in a resolved id path. */ -const SEP = '/'; - -/** - * Resolve each box's raw segment (its `key`, or `''` when unkeyed) into a full - * hierarchical id, so `@bomb.sh/tty` keeps element identity stable across renders. - * - * The id is the path of segments from the root: a keyed box contributes its key, - * an unkeyed box falls back to its positional index among siblings. Returns a new - * op array — the input keeps its raw segments, so re-resolving the same source is - * idempotent (important when a `box(...)` result is rendered more than once). - */ -export function resolveIds(ops: Op[]): Op[] { - const stack = [{ path: '', index: 0 }]; - const out: Op[] = []; - for (const op of ops) { - if (op.directive === OPEN) { - const parent = stack[stack.length - 1]!; - const segment = op.id || String(parent.index); - parent.index++; - const path = parent.path ? `${parent.path}${SEP}${segment}` : segment; - stack.push({ path, index: 0 }); - out.push({ ...op, id: path }); - } else { - // Keep the root frame so a peek is always safe, even on an unbalanced stream. - if (op.directive === CLOSE && stack.length > 1) stack.pop(); - out.push(op); - } - } - return out; -} diff --git a/packages/ui/src/ui.lifecycle.test.ts b/packages/ui/src/ui.lifecycle.test.ts new file mode 100644 index 0000000..4457a69 --- /dev/null +++ b/packages/ui/src/ui.lifecycle.test.ts @@ -0,0 +1,162 @@ +import { PassThrough, Writable } from 'node:stream'; +import { alternateBuffer, cursor, settings } from '@bomb.sh/tty'; +import { describe, expect, test } from 'vitest'; +import { createUI } from './ui.ts'; + +const terminalSettings = settings(cursor(false), alternateBuffer()); +const apply = Buffer.from(terminalSettings.apply).toString('utf8'); +const restore = Buffer.from(terminalSettings.revert).toString('utf8'); + +describe('UI lifecycle', () => { + test('restores terminal settings when main was never called', async () => { + using io = setup(); + const { writes } = io; + { + await using _ui = await createUI(io); + expect(writes).toEqual([apply]); + } + + expect(writes).toEqual([apply, restore]); + }); + + test('repeated main calls return the same running task', async () => { + using io = setup(); + const { writes } = io; + { + await using resource = await createUI(io); + expect(resource.main()).toBe(resource.main()); + } + + expect(writes).toEqual([apply, restore]); + }); + + test('disposal waits for a second output write before restoring the terminal', async () => { + const output = Promise.withResolvers(); + let frames = 0; + using io = setup((chunk, complete) => { + if (chunk === apply || chunk === restore) { + complete(); + } else if (++frames === 2) { + void output.promise.then(() => complete()); + } else { + complete(); + } + }); + const { writes } = io; + const ui = await createUI(io); + let disposing: Promise | undefined; + + try { + void ui.main(); + const { document } = ui; + document.documentElement.appendChild(document.createTextNode('first')); + await nextTurn(); + expect(frames).toBe(1); + + document.documentElement.appendChild(document.createTextNode(' second')); + await nextTurn(); + expect(frames).toBe(2); + + disposing = Promise.resolve(ui[Symbol.asyncDispose]()); + let settled = false; + void disposing.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await nextTurn(); + expect(settled).toBe(false); + expect(writes).not.toContain(restore); + + output.resolve(); + await disposing; + expect(writes.at(-1)).toBe(restore); + expect(frames).toBe(2); + } finally { + output.resolve(); + await (disposing ?? ui[Symbol.asyncDispose]()); + } + }); + + test('disposal propagates a render failure after restoring terminal settings', async () => { + using io = setup(); + const { writes } = io; + const ui = await createUI(io); + const failure = new TestRenderError(); + const text = ui.document.createTextNode('unreadable'); + Object.defineProperty(text, 'data', { + get() { + throw failure; + }, + }); + let disposing: Promise | undefined; + + try { + const failed = expect(ui.main()).rejects.toBe(failure); + ui.document.documentElement.appendChild(text); + await failed; + + disposing = Promise.resolve(ui[Symbol.asyncDispose]()); + await expect(disposing).rejects.toBe(failure); + expect(writes).toEqual([apply, restore]); + } finally { + // Manually own the expected failure; never dispose the resource twice. + if (!disposing) { + await Promise.resolve(ui[Symbol.asyncDispose]()).catch(() => {}); + } + } + }); +}); + +type CompleteWrite = (error?: Error | null) => void; + +interface TestIO extends Disposable { + input: PassThrough; + output: Writable; + width: number; + height: number; + writes: string[]; +} + +function setup(onWrite?: (chunk: string, complete: CompleteWrite) => void): TestIO { + const writes: string[] = []; + const input = new PassThrough(); + const output = new Writable({ + write(bytes: Buffer, _encoding, complete) { + const chunk = bytes.toString('utf8'); + writes.push(chunk); + if (onWrite) { + onWrite(chunk, complete); + } else { + complete(); + } + }, + }); + return { + input, + output, + width: 80, + height: 24, + writes, + [Symbol.dispose]() { + input.destroy(); + output.destroy(); + }, + }; +} + +function nextTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +class TestRenderError extends Error { + readonly code = 'CLACK_UI_TEST_RENDER_FAILURE'; + + constructor() { + super('render failed'); + this.name = 'TestRenderError'; + } +} diff --git a/packages/ui/src/ui.test.ts b/packages/ui/src/ui.test.ts new file mode 100644 index 0000000..937289a --- /dev/null +++ b/packages/ui/src/ui.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { createTestUI } from '../test/ui.ts'; + +describe('UI', () => { + it('renders hello world in the terminal', async () => { + await using ui = await createTestUI({ cols: 80, rows: 24 }); + const { terminal, document } = ui; + + document.documentElement.appendChild(document.createTextNode('hello world')); + + await expect.poll(() => terminal.screen, { timeout: 2000 }).toHaveText('hello world'); + }); + + it('repaints when an existing text node changes', async () => { + await using ui = await createTestUI({ cols: 12, rows: 2 }); + const { terminal, document } = ui; + const text = document.createTextNode('before'); + document.documentElement.appendChild(text); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['before', '']); + text.data = 'after'; + await expect.poll(() => terminal.screen.getLines()).toEqual(['after', '']); + }); +}); diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts new file mode 100644 index 0000000..eb27a8d --- /dev/null +++ b/packages/ui/src/ui.ts @@ -0,0 +1,97 @@ +import { alternateBuffer, createTerm, cursor, settings, type Term } from '@bomb.sh/tty'; +import type { Readable, Writable } from 'node:stream'; +import { Document } from './dom.ts'; +import { RenderApi } from './render.ts'; +import { Layout } from './layout.ts'; + +export interface UI extends AsyncDisposable { + document: Document; + /** Start rendering once. Disposal waits for this task and propagates its failure. */ + main(): Promise; +} + +export interface UIOptions { + height?: number; + width?: number; + input: Readable; + output: Writable; +} + +export async function createUI(options: UIOptions): Promise { + const { output, width = 80, height = 24 } = options; + const controller = new AbortController(); + + const setup = settings(cursor(false), alternateBuffer()); + await write(output, setup.apply); + + const term = await createTerm({ width, height }); + const render = new RenderApi(controller.signal); + + const document = new Document(() => render.request()); + + const layout = new Layout(document.documentElement); + + const loop = new RenderLoop({ layout, term, output, frames: render }); + + return { + document, + main: () => loop.run(), + async [Symbol.asyncDispose]() { + controller.abort(); + try { + await loop.run(); + } finally { + await write(output, setup.revert); + } + }, + }; +} + +interface RenderLoopOptions { + layout: Layout; + frames: AsyncIterable; + term: Term; + output: Writable; +} + +class RenderLoop { + #layout: Layout; + #frames: AsyncIterable; + #term: Term; + #output: Writable; + #running: Promise | undefined; + + constructor(options: RenderLoopOptions) { + this.#layout = options.layout; + this.#frames = options.frames; + this.#term = options.term; + this.#output = options.output; + } + + async #loop() { + for await (const _ of this.#frames) { + const ops = [...this.#layout.layout()]; + const result = this.#term.render(ops); + await write(this.#output, result.output); + } + } + + run() { + if (!this.#running) { + this.#running = this.#loop(); + } + return this.#running; + } +} + +function write(stream: Writable, bytes: Uint8Array): Promise { + const result = Promise.withResolvers(); + stream.write(bytes, (err) => { + if (err) { + result.reject(err); + } else { + result.resolve(); + } + }); + return result.promise; +} diff --git a/packages/ui/test/terminal.ts b/packages/ui/test/terminal.ts new file mode 100644 index 0000000..a6cf845 --- /dev/null +++ b/packages/ui/test/terminal.ts @@ -0,0 +1,53 @@ +import { PassThrough, Writable } from 'node:stream'; +import { keyToAnsi, parseKey, type Region } from '@termless/core'; +import { + createTestTerminal as createTermlessTerminal, + type SyncTestTerminalOptions, +} from '@termless/test'; + +export interface TestTerminal extends AsyncDisposable { + input: PassThrough; + output: Writable; + readonly screen: Region; + type(text: string): void; + press(key: string): void; +} + +/* oxlint-disable bombshell-dev/exported-function-async -- Mirrors Termless's synchronous fixture. */ +/** A Termless terminal with application-facing streams for in-process UI tests. */ +export function createTestTerminal(options: SyncTestTerminalOptions = {}): TestTerminal { + const terminal = createTermlessTerminal(options); + const input = new PassThrough(); + const output = new Writable({ + write(chunk: Buffer, _encoding, callback) { + terminal.feed(chunk); + callback(); + }, + }); + + // Terminal replies travel back to the application through the same input stream. + terminal.backend.onResponse = (bytes) => { + input.write(bytes); + }; + + return { + input, + output, + get screen() { + return terminal.screen; + }, + type(text) { + input.write(text); + }, + press(key) { + const descriptor = parseKey(key); + const encoded = terminal.backend.encodeKey(descriptor); + input.write(encoded.length > 0 ? encoded : keyToAnsi(descriptor)); + }, + async [Symbol.asyncDispose]() { + input.destroy(); + output.destroy(); + await terminal.close(); + }, + }; +} diff --git a/packages/ui/test/ui.ts b/packages/ui/test/ui.ts new file mode 100644 index 0000000..69d8255 --- /dev/null +++ b/packages/ui/test/ui.ts @@ -0,0 +1,39 @@ +import type { SyncTestTerminalOptions } from '@termless/test'; +import { createUI, type UI } from '../src/ui.ts'; +import { createTestTerminal, type TestTerminal } from './terminal.ts'; + +export interface TestUI extends AsyncDisposable { + terminal: TestTerminal; + document: UI['document']; +} + +/** A running UI and its terminal, owned and disposed together. */ +export async function createTestUI(options: SyncTestTerminalOptions = {}): Promise { + const { cols = 80, rows = 24 } = options; + const terminal = createTestTerminal({ ...options, cols, rows }); + + try { + const ui = await createUI({ + input: terminal.input, + output: terminal.output, + width: cols, + height: rows, + }); + void ui.main(); + + return { + terminal, + document: ui.document, + async [Symbol.asyncDispose]() { + try { + await ui[Symbol.asyncDispose](); + } finally { + await terminal[Symbol.asyncDispose](); + } + }, + }; + } catch (error) { + await terminal[Symbol.asyncDispose](); + throw error; + } +} diff --git a/patches/@bomb.sh__tools@0.6.1.patch b/patches/@bomb.sh__tools@0.6.1.patch new file mode 100644 index 0000000..1d37648 --- /dev/null +++ b/patches/@bomb.sh__tools@0.6.1.patch @@ -0,0 +1,14 @@ +diff --git a/dist/commands/test.mjs b/dist/commands/test.mjs +index e07ebb287e5a450dfde4df7e973b459a4b5651b3..8fb3b952fef033bb32f749bc1bcfdccf6763a7a0 100644 +--- a/dist/commands/test.mjs ++++ b/dist/commands/test.mjs +@@ -18,6 +18,9 @@ async function test(ctx) { + ...ctx.args + ]); + for await (const line of stdio) console.info(line); ++ // Without forwarding Vitest's exit status, failing tests still make `bsh test` ++ // exit successfully, allowing CI to pass despite test failures. ++ process.exitCode = stdio.exitCode ?? 1; + } + //#endregion + export { test }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72f0c96..056274d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,16 +10,30 @@ catalogs: specifier: ^0.7.0 version: 0.7.0 +patchedDependencies: + '@bomb.sh/tools@0.6.1': + hash: 233e8ef5a4ce472e66d681a69475318f4daad79a70ba5fdc88f9ce9904ae5603 + path: patches/@bomb.sh__tools@0.6.1.patch + importers: .: devDependencies: '@bomb.sh/tools': specifier: ^0.6.1 - version: 0.6.1(@types/node@22.20.0)(oxc-resolver@11.20.0)(unrun@0.2.39)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) + version: 0.6.1(patch_hash=233e8ef5a4ce472e66d681a69475318f4daad79a70ba5fdc88f9ce9904ae5603)(@types/node@22.20.0)(oxc-resolver@11.20.0)(unrun@0.2.39)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) + '@termless/core': + specifier: ^0.10.2 + version: 0.10.2(node-pty@1.2.0-beta.15) + '@termless/test': + specifier: ^0.10.2 + version: 0.10.2(@termless/core@0.10.2(node-pty@1.2.0-beta.15))(playwright@1.64.1)(vitest@4.1.9(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0))) '@types/node': specifier: ^22.20.0 version: 22.20.0 + node-pty: + specifier: 1.2.0-beta.15 + version: 1.2.0-beta.15 vitest: specifier: ^4.1.9 version: 4.1.9(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) @@ -117,6 +131,76 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/canvas-android-arm64@1.0.11': + resolution: {integrity: sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.11': + resolution: {integrity: sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.11': + resolution: {integrity: sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.11': + resolution: {integrity: sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.11': + resolution: {integrity: sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@1.0.11': + resolution: {integrity: sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.11': + resolution: {integrity: sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@1.0.11': + resolution: {integrity: sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@1.0.11': + resolution: {integrity: sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.11': + resolution: {integrity: sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.11': + resolution: {integrity: sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.11': + resolution: {integrity: sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -581,6 +665,82 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -857,6 +1017,32 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@termless/core@0.10.2': + resolution: {integrity: sha512-xr/TvnhzB0R7g24W3EcwP8UQ+ztYd5ybk4oxR/PkcEh92O5NRjl9mCkqz/wvTcGX3u77AcmA8w5Ox16T22DOCA==} + engines: {bun: '>=1.0.0', node: '>=23.6.0'} + peerDependencies: + '@termless/ghostty': ^0.10.2 + node-pty: '>=1.0.0' + peerDependenciesMeta: + '@termless/ghostty': + optional: true + node-pty: + optional: true + + '@termless/test@0.10.2': + resolution: {integrity: sha512-1u7Un1k3S0hEw+j8dcNnQQGQxBKkQYznZKNMvKmx0VppxcdkdaOeaX/1DcftYDhPeg2QqmRtsjHV161/plUNhQ==} + engines: {bun: '>=1.0.0', node: '>=23.6.0'} + peerDependencies: + '@termless/core': ^0.10.2 + playwright: ^1.59.0 + vitest: '>=2.0.0' + peerDependenciesMeta: + playwright: + optional: true + + '@twemoji/svg@15.0.0': + resolution: {integrity: sha512-ZSPef2B6nBaYnfgdTbAy4jgW95o7pi2xPGwGCU+WMTxo7J6B1lMPTWwSq/wTuiMq+N0khQ90CcvYp1wFoQpo/w==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -1036,6 +1222,12 @@ packages: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} + ghostty-web@0.4.0: + resolution: {integrity: sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg==} + + gifenc@1.0.3: + resolution: {integrity: sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -1139,6 +1331,12 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-pty@1.2.0-beta.15: + resolution: {integrity: sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -1171,6 +1369,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1181,6 +1382,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -1343,6 +1554,9 @@ packages: synckit: optional: true + upng-js@2.1.0: + resolution: {integrity: sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1475,7 +1689,7 @@ snapshots: '@bomb.sh/args@0.3.1': {} - '@bomb.sh/tools@0.6.1(@types/node@22.20.0)(oxc-resolver@11.20.0)(unrun@0.2.39)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0))': + '@bomb.sh/tools@0.6.1(patch_hash=233e8ef5a4ce472e66d681a69475318f4daad79a70ba5fdc88f9ce9904ae5603)(@types/node@22.20.0)(oxc-resolver@11.20.0)(unrun@0.2.39)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@bomb.sh/args': 0.3.1 '@humanfs/node': 0.16.8 @@ -1580,6 +1794,54 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/canvas-android-arm64@1.0.11': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.11': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.11': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.11': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.11': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.11': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.11': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.11': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.11': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.11': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.11': + optional: true + + '@napi-rs/canvas@1.0.11': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.11 + '@napi-rs/canvas-darwin-arm64': 1.0.11 + '@napi-rs/canvas-darwin-x64': 1.0.11 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.11 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.11 + '@napi-rs/canvas-linux-arm64-musl': 1.0.11 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.11 + '@napi-rs/canvas-linux-x64-gnu': 1.0.11 + '@napi-rs/canvas-linux-x64-musl': 1.0.11 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.11 + '@napi-rs/canvas-win32-x64-msvc': 1.0.11 + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1846,6 +2108,58 @@ snapshots: dependencies: quansync: 1.0.0 + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true @@ -2000,6 +2314,29 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@termless/core@0.10.2(node-pty@1.2.0-beta.15)': + optionalDependencies: + '@napi-rs/canvas': 1.0.11 + '@resvg/resvg-js': 2.6.2 + '@twemoji/svg': 15.0.0 + ghostty-web: 0.4.0 + gifenc: 1.0.3 + node-pty: 1.2.0-beta.15 + playwright: 1.64.1 + upng-js: 2.1.0 + + '@termless/test@0.10.2(@termless/core@0.10.2(node-pty@1.2.0-beta.15))(playwright@1.64.1)(vitest@4.1.9(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)))': + dependencies: + '@termless/core': 0.10.2(node-pty@1.2.0-beta.15) + vitest: 4.1.9(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) + optionalDependencies: + '@napi-rs/canvas': 1.0.11 + '@resvg/resvg-js': 2.6.2 + playwright: 1.64.1 + + '@twemoji/svg@15.0.0': + optional: true + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -2151,6 +2488,12 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + ghostty-web@0.4.0: + optional: true + + gifenc@1.0.3: + optional: true + hookable@6.1.1: {} import-without-cache@0.4.0: {} @@ -2232,6 +2575,12 @@ snapshots: nanoid@3.3.12: {} + node-addon-api@7.1.1: {} + + node-pty@1.2.0-beta.15: + dependencies: + node-addon-api: 7.1.1 + obug@2.1.3: {} oxc-parser@0.133.0: @@ -2329,12 +2678,23 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: + optional: true + pathe@2.0.3: {} picocolors@1.1.1: {} picomatch@4.0.4: {} + playwright-core@1.64.1: + optional: true + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + optional: true + postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -2510,6 +2870,11 @@ snapshots: rolldown: 1.0.0-rc.17 optional: true + upng-js@2.1.0: + dependencies: + pako: 1.0.11 + optional: true + vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 860a4f2..c8fba15 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,12 @@ packages: catalog: '@bomb.sh/tty': '^0.7.0' +onlyBuiltDependencies: + - node-pty + publicHoistPattern: - oxlint - oxfmt + +patchedDependencies: + '@bomb.sh/tools@0.6.1': patches/@bomb.sh__tools@0.6.1.patch