Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
13 changes: 13 additions & 0 deletions .changeset/interactive-story-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@kidd-cli/core': minor
---

feat(core): add interactive mode and declarative key binding hooks

Adds interactive mode to the stories viewer, giving story components full terminal control with the header and sidebar hidden. Press `i` to enter interactive mode and double-press `Esc` to exit.

Introduces reusable key handling primitives:

- **keys.ts**: shared key vocabulary, pattern parser, and normalizer for Ink's `useInput`
- **useKeyBinding**: declarative keymap hook supporting single keys, modifier combos, and multi-key sequences
- **useKeyInput**: enhanced raw input hook with normalized key events
2 changes: 1 addition & 1 deletion examples/diagnostic-output/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
"tsx": "catalog:",
"typescript": "catalog:"
}
}
}
1 change: 0 additions & 1 deletion packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ export async function cli<TSchema extends z.ZodType = z.ZodType>(
middleware: options.middleware,
name: options.name,
prompts: options.prompts,
spinner: options.spinner,
status: options.status,
version,
})
Expand Down
15 changes: 3 additions & 12 deletions packages/core/src/context/create-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import type {
Log,
Meta,
Prompts,
Spinner,
Status,
Store,
StoreMap,
Expand All @@ -28,9 +27,8 @@ import type {
*
* Carries the parsed args, validated config, and CLI metadata needed to
* assemble a fully-wired context. Optional overrides allow callers to inject
* custom {@link Log}, {@link Prompts}, {@link Status}, and {@link Spinner}
* implementations; when omitted, default `@clack/prompts`-backed instances
* are used.
* custom {@link Log}, {@link Prompts}, and {@link Status} implementations;
* when omitted, default `@clack/prompts`-backed instances are used.
*/
export interface CreateContextOptions<TArgs extends AnyRecord, TConfig extends AnyRecord> {
readonly args: TArgs
Expand All @@ -45,11 +43,6 @@ export interface CreateContextOptions<TArgs extends AnyRecord, TConfig extends A
readonly log?: Log
readonly prompts?: Prompts
readonly status?: Status
/**
* @deprecated Use `status` instead. When provided, creates a Status
* wrapper around this spinner for backwards compatibility.
*/
readonly spinner?: Spinner
}

/**
Expand Down Expand Up @@ -141,8 +134,7 @@ function resolveCommonDefaults(dc: DisplayConfig): {
}

/**
* Resolve the Status instance from options, supporting the deprecated
* `spinner` override for backwards compatibility.
* Resolve the Status instance from options or create a default one.
*
* @private
* @param options - The create context options.
Expand All @@ -162,7 +154,6 @@ function resolveStatus<TArgs extends AnyRecord, TConfig extends AnyRecord>(
return createContextStatus({
defaults: commonDefaults,
progressConfig: dc.progress,
spinner: options.spinner,
spinnerConfig: dc.spinner,
})
}
7 changes: 1 addition & 6 deletions packages/core/src/context/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@
* Options for {@link createContextStatus}.
*/
export interface CreateContextStatusOptions {
/**
* Override the spinner implementation (useful for testing or screen contexts).
*/
readonly spinner?: Spinner
/** Spinner config defaults from {@link DisplayConfig}. */
readonly spinnerConfig?: DisplayConfig['spinner']
/** Progress config defaults from {@link DisplayConfig}. */
Expand All @@ -61,8 +57,7 @@
*/
export function createContextStatus(options?: CreateContextStatusOptions): Status {
const base = resolveClackBase(options?.defaults)
const spinner: Spinner =
options?.spinner ?? createDefaultSpinner(base, options?.spinnerConfig ?? {})
const spinner: Spinner = createDefaultSpinner(base, options?.spinnerConfig ?? {})
const progressConfig = options?.progressConfig ?? {}

return Object.freeze({
Expand Down Expand Up @@ -100,7 +95,7 @@

async tasks(tasks: readonly TaskDef[]): Promise<void> {
// Accepted exception: kidd's TaskDef.task returns `Promise<string | void>`,
// clack expects separate `Promise<string> | Promise<void>`. The cast bridges this.

Check warning on line 98 in packages/core/src/context/status.ts

View workflow job for this annotation

GitHub Actions / CI

eslint(capitalized-comments)

Comments should not begin with a lowercase letter
await clack.tasks(
tasks.map((t) => ({
title: t.title,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/lib/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function createLog(options?: CreateLogOptions): Log {
},

box(message: string, title?: string, opts?: BoxOptions): void {
clack.box(message, title, mergeClackOpts(boxBase as ClackBase, opts))
clack.box(message, title, mergeClackOpts(boxBase, opts))
},

outro(message?: string): void {
Expand Down Expand Up @@ -144,7 +144,7 @@ function resolveOutput(options: CreateLogOptions | undefined): NodeJS.WritableSt
function resolveBoxBase(
base: ClackBase,
boxDefaults: DisplayConfig['box'] | undefined
): ClackBase | Record<string, unknown> {
): ClackBase & Partial<NonNullable<DisplayConfig['box']>> {
if (boxDefaults === undefined) {
return base
}
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ export async function createRuntime<TSchema extends z.ZodType>(
version: options.version,
},
prompts: options.prompts,
spinner: options.spinner,
status: options.status,
})

Expand Down
10 changes: 1 addition & 9 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import type { AsyncResult, Result } from '@kidd-cli/utils/fp'
import type { z } from 'zod'

import type {
CommandContext,
DisplayConfig,
Log,
Prompts,
Spinner,
Status,
} from '@/context/types.js'
import type { CommandContext, DisplayConfig, Log, Prompts, Status } from '@/context/types.js'
import type {
ArgsDef,
CliConfigOptions,
Expand All @@ -30,7 +23,6 @@ export interface RuntimeOptions<TSchema extends z.ZodType = z.ZodType> {
readonly log?: Log
readonly prompts?: Prompts
readonly status?: Status
readonly spinner?: Spinner
}

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/stories/viewer/components/help-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ReactElement } from 'react'
const BROWSE_SHORTCUTS = [
{ key: 'Up/Down', description: 'Navigate story tree' },
{ key: 'Enter', description: 'Select story / expand-collapse group' },
{ key: 'i', description: 'Enter interactive mode' },
{ key: 'b', description: 'Toggle sidebar' },
{ key: 'r', description: 'Reset props to defaults' },
{ key: '?', description: 'Toggle help' },
Expand All @@ -24,6 +25,8 @@ const EDIT_SHORTCUTS = [
{ key: 'q', description: 'Quit' },
] as const

const INTERACTIVE_SHORTCUTS = [{ key: 'Esc Esc', description: 'Exit interactive mode' }] as const

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -80,6 +83,18 @@ export function HelpOverlay({ onClose }: HelpOverlayProps): ReactElement {
<Text>{shortcut.description}</Text>
</Box>
))}
<Text> </Text>
<Text bold color="green">
Interactive Mode
</Text>
{INTERACTIVE_SHORTCUTS.map((shortcut) => (
<Box key={shortcut.key} gap={2}>
<Box width={12}>
<Text color="cyan">{shortcut.key}</Text>
</Box>
<Text>{shortcut.description}</Text>
</Box>
))}
</Box>
)
}
12 changes: 12 additions & 0 deletions packages/core/src/stories/viewer/components/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface PreviewProps {
readonly onPropsChange: (name: string, value: unknown) => void
readonly isFocused: boolean
readonly borderless?: boolean
readonly interactive?: boolean
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -75,6 +76,7 @@ export function Preview({
onPropsChange,
isFocused,
borderless = false,
interactive = false,
}: PreviewProps): ReactElement {
const contentRef = useRef<DOMElement>(null)
const { height: contentHeight } = useSize(contentRef)
Expand Down Expand Up @@ -115,6 +117,16 @@ export function Preview({
)
}

if (interactive) {
return (
<Box flexDirection="column" flexGrow={1} overflow="hidden">
<ErrorBoundary key={context.displayName}>
<DecoratedComponent {...currentProps} />
</ErrorBoundary>
</Box>
)
}

return (
<Box
flexDirection="column"
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/stories/viewer/components/status-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function StatusBar({ mode, hasSelection, isReloading }: StatusBarProps):
<BrowseHints hasSelection={hasSelection} />
))
.with({ isReloading: false, mode: 'edit' }, () => <EditHints />)
.with({ isReloading: false, mode: 'interactive' }, () => <InteractiveHints />)
.exhaustive()}
<Spacer />
<Text dimColor>q</Text>
Expand All @@ -71,11 +72,13 @@ function ModeIndicator({ mode }: { readonly mode: ViewerMode }): ReactElement {
color={match(mode)
.with('browse', () => 'cyan' as const)
.with('edit', () => 'yellow' as const)
.with('interactive', () => 'green' as const)
.exhaustive()}
>
{match(mode)
.with('browse', () => '● Browse')
.with('edit', () => '● Edit')
.with('interactive', () => '● Interactive')
.exhaustive()}
</Text>
)
Expand Down Expand Up @@ -139,3 +142,18 @@ function EditHints(): ReactElement {
</Box>
)
}

/**
* Render keyboard hints for interactive mode.
*
* @private
* @returns A rendered hints element.
*/
function InteractiveHints(): ReactElement {
return (
<Box>
<Text dimColor>esc esc</Text>
<Text>: exit interactive</Text>
</Box>
)
}
44 changes: 44 additions & 0 deletions packages/core/src/stories/viewer/hooks/use-double-escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Thin wrapper hook that listens for a double-press Escape sequence
* and invokes an exit callback. Used by the stories viewer to exit
* interactive mode without intercepting single Escape presses.
*
* @module
*/

import { useMemo } from 'react'

import type { KeyBinding } from '../../../ui/use-key-binding.js'
import { useKeyBinding } from '../../../ui/use-key-binding.js'

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/**
* Options for the {@link useDoubleEscape} hook.
*/
interface DoubleEscapeOptions {
readonly onExit: () => void
readonly isActive: boolean
}

// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------

/**
* Listen for a double-press Escape sequence and call `onExit` when detected.
* The binding is only active when `isActive` is `true`, allowing the caller
* to enable it only in interactive mode.
*
* @param options - The hook options with exit callback and active state.
* @returns Nothing. Registers the key binding side effect.
*/
export function useDoubleEscape({ onExit, isActive }: DoubleEscapeOptions): void {
const bindings = useMemo<readonly KeyBinding[]>(
() => [{ keys: 'escape escape', action: onExit }],
[onExit]
)
useKeyBinding(bindings, { isActive })
}
15 changes: 13 additions & 2 deletions packages/core/src/stories/viewer/hooks/use-panel-focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { useCallback, useState } from 'react'
*
* - `browse` — Sidebar is active, user navigates the story tree.
* - `edit` — Props editor is active, user edits field values.
* - `interactive` — Story component has full terminal control.
*/
export type ViewerMode = 'browse' | 'edit'
export type ViewerMode = 'browse' | 'edit' | 'interactive'

/**
* State and controls for managing the viewer interaction mode.
Expand All @@ -19,6 +20,8 @@ export interface ViewerModeState {
readonly mode: ViewerMode
readonly enterEditMode: () => void
readonly exitEditMode: () => void
readonly enterInteractiveMode: () => void
readonly exitInteractiveMode: () => void
}

// ---------------------------------------------------------------------------
Expand All @@ -43,5 +46,13 @@ export function useViewerMode(): ViewerModeState {
setMode('browse')
}, [])

return { mode, enterEditMode, exitEditMode }
const enterInteractiveMode = useCallback(() => {
setMode('interactive')
}, [])

const exitInteractiveMode = useCallback(() => {
setMode('browse')
}, [])

return { mode, enterEditMode, exitEditMode, enterInteractiveMode, exitInteractiveMode }
}
Loading
Loading