Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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:"
}
}
}
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
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