Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reorganize-ui-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@kidd-cli/core': minor
---

Reorganize UI components into prompts, display, and layout modules. Add new prompt components (Autocomplete, GroupMultiSelect, PathInput, SelectKey), display components (Alert, ProgressBar, Spinner, StatusMessage), and extract screen module with provider and context.
4 changes: 2 additions & 2 deletions codspeed.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
benchmarks:
- name: "icons --help"
- name: 'icons --help'
exec: node examples/icons/dist/index.mjs --help

- name: "icons status"
- name: 'icons status'
exec: node examples/icons/dist/index.mjs status
675 changes: 675 additions & 0 deletions docs/designs/component-library.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions examples/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "kidd dev",
"build": "kidd build",
"stories": "kidd stories --cwd ../../packages/core",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"lint:fix": "oxlint --fix --ignore-pattern node_modules",
"build": "turbo run build --filter='./packages/*'",
"dev": "turbo run dev",
"stories": "pnpm build && kidd stories --cwd packages/core",
"test": "turbo run test test:integration",
"test:integration": "vitest run --config vitest.exports.config.ts",
"test:coverage": "turbo run test:coverage --filter='./packages/*'",
Expand All @@ -33,6 +34,7 @@
"devDependencies": {
"@changesets/cli": "^2.30.0",
"@clack/prompts": "^1.1.0",
"@kidd-cli/cli": "workspace:*",
"@types/node": "catalog:",
"@typescript/native-preview": "7.0.0-dev.20260323.1",
"@vitest/coverage-v8": "catalog:",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export { autoload } from './autoload.js'
export { decorateContext } from './context/decorate.js'
export { middleware } from './middleware.js'
export { defineConfig } from '@kidd-cli/config'
export { screen, useScreenContext } from './screen/index.js'
export type { ScreenDef, ScreenExit } from './screen/index.js'
export type {
CliConfig,
Command,
Expand All @@ -29,6 +31,7 @@ export type {
ProgressBar,
ProgressOptions,
Prompts,
ScreenContext,
SelectOptions,
Spinner,
Status,
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/screen/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Screen runtime — mounts Ink, wires context, manages the output store.
*
* A peer to `command/` at the core level. Users never import from
* `screen/output/` directly.
*
* @module
*/

export { screen } from './screen.js'
export type { ScreenDef, ScreenExit } from './screen.js'

export { useScreenContext } from './provider.js'
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
* @module
*/

export { Output } from './output.js'

export { useOutputStore } from './use-output-store.js'

export { createOutputStore } from './store.js'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,13 @@ const OUTPUT_STORE_KEY: unique symbol = Symbol('kidd.outputStore')
* Options for {@link injectOutputStore}.
*/
interface InjectOutputStoreOptions {
/** The screen context record to extend. */
/**
* The screen context record to extend.
*/
readonly ctx: Record<string | symbol, unknown>
/** The output store to attach. */
/**
* The output store to attach.
*/
readonly store: OutputStore
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { match } from 'ts-pattern'

import type { CommandContext, ImperativeContextKeys, ScreenContext } from '../context/types.js'
import type { ArgsDef, Command, InferArgsMerged, Resolvable } from '../types/index.js'
import { FullScreen, LEAVE_ALT_SCREEN } from './fullscreen.js'
import { FullScreen, LEAVE_ALT_SCREEN } from '../ui/layout/fullscreen.js'
import { createScreenLog } from './output/screen-log.js'
import { createScreenReport } from './output/screen-report.js'
import { createScreenSpinner } from './output/screen-spinner.js'
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/stories/decorators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type { ComponentType, ReactElement } from 'react'
import React from 'react'

import type { ScreenContext } from '../context/types.js'
import { FullScreen } from '../ui/fullscreen.js'
import { KiddProvider } from '../ui/provider.js'
import { KiddProvider } from '../screen/provider.js'
import { FullScreen } from '../ui/layout/fullscreen.js'
import type { Decorator } from './types.js'

// ---------------------------------------------------------------------------
Expand Down
104 changes: 104 additions & 0 deletions packages/core/src/stories/importer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Module from 'node:module'

import { toError } from '@kidd-cli/utils/fp'
import { hasTag } from '@kidd-cli/utils/tag'
import { createJiti } from 'jiti'
Expand All @@ -14,9 +16,14 @@ export interface StoryImporter {
/**
* Create a story importer backed by jiti with cache disabled for hot reload.
*
* Installs a TypeScript extension resolution hook so that ESM-style `.js`
* imports (e.g. `from './alert.js'`) resolve to `.ts` / `.tsx` source files.
*
* @returns A frozen {@link StoryImporter} instance.
*/
export function createStoryImporter(): StoryImporter {
installTsExtensionResolution()

const jiti = createJiti(import.meta.url, {
fsCache: false,
moduleCache: false,
Expand Down Expand Up @@ -44,6 +51,103 @@ export function createStoryImporter(): StoryImporter {

// ---------------------------------------------------------------------------

/**
* TypeScript extensions to try when a `.js` import fails to resolve.
*
* @private
*/
const TS_EXTENSIONS: readonly string[] = ['.ts', '.tsx']

/**
* TypeScript extensions to try when a `.jsx` import fails to resolve.
*
* @private
*/
const TSX_EXTENSIONS: readonly string[] = ['.tsx', '.ts', '.jsx']

/**
* Name used to tag the patched `_resolveFilename` function so the
* patch can detect itself and remain idempotent.
*
* @private
*/
const PATCHED_FN_NAME = '__kidd_ts_resolve'

/**
* Patch `Module._resolveFilename` so that ESM-style `.js` / `.jsx` specifiers
* fall back to their TypeScript equivalents (`.ts`, `.tsx`).
*
* This is the same strategy used by `tsx` and `ts-node`. The patch is
* idempotent — calling it more than once is a no-op.
*
* @private
*/
function installTsExtensionResolution(): void {
const mod = Module as unknown as Record<string, unknown>
const current = mod._resolveFilename as (...args: unknown[]) => string

if (current.name === PATCHED_FN_NAME) {
return
}

const original = current

const patched = {
[PATCHED_FN_NAME]: (
request: string,
parent: unknown,
isMain: boolean,
options: unknown
): string => {
try {
return original.call(mod, request, parent, isMain, options)
} catch (error) {
const alternates = resolveAlternateExtensions(request)

const resolved = alternates.reduce<string | null>((found, alt) => {
if (found) {
return found
}
try {
return original.call(mod, alt, parent, isMain, options)
} catch {
return null
}
}, null)

if (resolved) {
return resolved
}
throw error
}
},
}

mod._resolveFilename = patched[PATCHED_FN_NAME]
}

/**
* Build a list of alternate file paths to try when a `.js` or `.jsx`
* import fails to resolve.
*
* @private
* @param request - The original module specifier.
* @returns Alternate specifiers to try, or an empty array if not applicable.
*/
function resolveAlternateExtensions(request: string): readonly string[] {
if (request.endsWith('.js')) {
const base = request.slice(0, -3)
return [...TS_EXTENSIONS.map((ext) => base + ext), base]
}

if (request.endsWith('.jsx')) {
const base = request.slice(0, -4)
return TSX_EXTENSIONS.map((ext) => base + ext)
}

return []
}

/**
* Check whether a value is a valid {@link StoryEntry} (tagged as `Story` or `StoryGroup`).
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import type { Option } from '@inkjs/ui'
import { ConfirmInput, MultiSelect, Select, TextInput } from '@inkjs/ui'
import { Box, Text } from 'ink'
import type { ReactElement } from 'react'
import { match } from 'ts-pattern'

import { ConfirmInput } from '../../../ui/confirm.js'
import { MultiSelect } from '../../../ui/multi-select.js'
import { Select } from '../../../ui/select.js'
import { TextInput } from '../../../ui/text-input.js'
import type { FieldControlKind } from '../../types.js'

// ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/stories/viewer/components/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { useMemo, useRef } from 'react'
import { match } from 'ts-pattern'

import { InputBarrier } from '../../../ui/input-barrier.js'
import { ScrollArea } from '../../../ui/scroll-area.js'
import { useSize } from '../../../ui/use-size.js'
import { ScrollArea } from '../../../ui/layout/scroll-area.js'
import { useSize } from '../../../ui/layout/use-size.js'
import type { FieldDescriptor, Story } from '../../types.js'
import type { FieldError } from '../../validate.js'
import { applyDecorators } from '../utils.js'
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/stories/viewer/components/props-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Box, Text } from 'ink'
import type { ReactElement } from 'react'
import { match } from 'ts-pattern'

import type { TabItem } from '../../../ui/tabs.js'
import { Tabs } from '../../../ui/tabs.js'
import type { TabItem } from '../../../ui/layout/tabs.js'
import { Tabs } from '../../../ui/layout/tabs.js'
import type { FieldControlKind, FieldDescriptor } from '../../types.js'
import type { FieldError } from '../../validate.js'
import { FieldControl } from './field-control.js'
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/stories/viewer/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { ReactElement } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { match } from 'ts-pattern'

import { ScrollArea } from '../../../ui/scroll-area.js'
import { useSize } from '../../../ui/use-size.js'
import { ScrollArea } from '../../../ui/layout/scroll-area.js'
import { useSize } from '../../../ui/layout/use-size.js'
import type { Story, StoryEntry, StoryGroup } from '../../types.js'

// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/stories/viewer/stories-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { ReactElement } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { match } from 'ts-pattern'

import { FullScreen } from '../../ui/fullscreen.js'
import { FullScreen } from '../../ui/layout/fullscreen.js'
import type { StoryRegistry } from '../registry.js'
import { schemaToFieldDescriptors } from '../schema.js'
import type { Story, StoryEntry, StoryGroup } from '../types.js'
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/stories/viewer/stories-check.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { ReactElement } from 'react'
import { useEffect, useRef } from 'react'
import { match } from 'ts-pattern'

import { Output } from '../../ui/output/index.js'
import { useScreenContext } from '../../ui/provider.js'
import { useScreenContext } from '../../screen/provider.js'
import { Output } from '../../ui/output.js'
import { checkStories } from '../check.js'
import { discoverStories } from '../discover.js'
import { createStoryImporter } from '../importer.js'
Expand Down
11 changes: 0 additions & 11 deletions packages/core/src/ui/confirm.tsx

This file was deleted.

56 changes: 56 additions & 0 deletions packages/core/src/ui/display/alert.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ComponentType } from 'react'
import { z } from 'zod'

import { stories } from '../../stories/story.js'
import type { StoryGroup } from '../../stories/types.js'
import { Alert } from './alert.js'

const schema = z.object({
children: z.string().describe('The content to display inside the alert box'),
variant: z.enum(['info', 'success', 'error', 'warning']).describe('Alert variant'),
title: z.string().optional().describe('Title rendered in the top border'),
rounded: z.boolean().optional().describe('Use rounded border corners'),
contentAlign: z
.enum(['left', 'center', 'right'])
.optional()
.describe('Horizontal alignment of content'),
titleAlign: z
.enum(['left', 'center', 'right'])
.optional()
.describe('Horizontal alignment of title'),
})

const storyGroup: StoryGroup = stories({
title: 'Alert',
component: Alert as unknown as ComponentType<Record<string, unknown>>,
schema,
defaults: {},
stories: {
Info: {
props: { children: 'This is an informational message.', variant: 'info' },
description: 'Info variant alert with blue border.',
},
Success: {
props: { children: 'Operation completed successfully.', variant: 'success' },
description: 'Success variant alert with green border.',
},
Error: {
props: { children: 'Something went wrong.', variant: 'error' },
description: 'Error variant alert with red border.',
},
Warning: {
props: { children: 'Config file not found.', variant: 'warning' },
description: 'Warning variant alert with yellow border.',
},
WithTitle: {
props: {
children: 'Please check your configuration.',
variant: 'warning',
title: 'Warning',
},
description: 'Alert with a title rendered in the top border.',
},
},
})

export default storyGroup
Loading
Loading