diff --git a/opentui b/opentui new file mode 160000 index 00000000..44cb164f --- /dev/null +++ b/opentui @@ -0,0 +1 @@ +Subproject commit 44cb164ffd97548cf6964890a30a41b16531fee3 diff --git a/packages/demo/demos/prompt-input.tsx b/packages/demo/demos/prompt-input.tsx index 2601a91d..15db55c5 100644 --- a/packages/demo/demos/prompt-input.tsx +++ b/packages/demo/demos/prompt-input.tsx @@ -66,6 +66,7 @@ export function PromptInputApp() { files={files} placeholder="Message Claude..." showDividers + autoFocus useKeyboard={useKeyboard} onSubmit={handleSubmit} /> diff --git a/packages/demo/demos/spinner.tsx b/packages/demo/demos/spinner.tsx index 063c74a0..5888a01b 100644 --- a/packages/demo/demos/spinner.tsx +++ b/packages/demo/demos/spinner.tsx @@ -1,16 +1,11 @@ // @ts-nocheck import { useKeyboard } from "@gridland/utils" -import { SpinnerPicker, StatusBar } from "@gridland/ui" +import { SpinnerPicker } from "@gridland/ui" export function SpinnerApp() { return ( - - - - - - + ) } diff --git a/packages/docs/components/demos/ai-chat-interface-demo.tsx b/packages/docs/components/demos/ai-chat-interface-demo.tsx index 459659c0..16f6f0c2 100644 --- a/packages/docs/components/demos/ai-chat-interface-demo.tsx +++ b/packages/docs/components/demos/ai-chat-interface-demo.tsx @@ -78,7 +78,7 @@ function AIChatInterfaceApp() { export default function AIChatInterfaceDemo() { return ( - + ) diff --git a/packages/docs/components/demos/chain-of-thought-demo.tsx b/packages/docs/components/demos/chain-of-thought-demo.tsx index 3a88dcc3..092f9c46 100644 --- a/packages/docs/components/demos/chain-of-thought-demo.tsx +++ b/packages/docs/components/demos/chain-of-thought-demo.tsx @@ -6,7 +6,7 @@ import { ChainOfThought, ChainOfThoughtHeader, ChainOfThoughtContent, ChainOfTho export default function ChainOfThoughtDemo() { return ( - + ) diff --git a/packages/docs/components/demos/spinner-demo.tsx b/packages/docs/components/demos/spinner-demo.tsx index a4152a43..71b46668 100644 --- a/packages/docs/components/demos/spinner-demo.tsx +++ b/packages/docs/components/demos/spinner-demo.tsx @@ -6,7 +6,7 @@ import { Spinner, SpinnerShowcase } from "@gridland/ui" export default function SpinnerPickerDemo() { return ( - + ) diff --git a/packages/docs/components/demos/tabs-demo.tsx b/packages/docs/components/demos/tabs-demo.tsx index 78fc7284..aaa8f92a 100644 --- a/packages/docs/components/demos/tabs-demo.tsx +++ b/packages/docs/components/demos/tabs-demo.tsx @@ -109,7 +109,7 @@ function ContentTabBarApp() { export function TabsSimpleDemo() { return ( - + ) diff --git a/packages/docs/components/demos/text-input-demo.tsx b/packages/docs/components/demos/text-input-demo.tsx index 0d9fdfe6..54065efb 100644 --- a/packages/docs/components/demos/text-input-demo.tsx +++ b/packages/docs/components/demos/text-input-demo.tsx @@ -48,7 +48,7 @@ function TextInputPickerApp() { export function TextInputPickerDemo() { return ( - + ) diff --git a/packages/docs/components/ui/demo-window.tsx b/packages/docs/components/ui/demo-window.tsx index ce6c036b..0481bf05 100644 --- a/packages/docs/components/ui/demo-window.tsx +++ b/packages/docs/components/ui/demo-window.tsx @@ -22,6 +22,7 @@ export interface DemoWindowProps { cols?: number rows?: number cursorHighlight?: boolean + autoFocus?: boolean children: ReactNode } @@ -32,6 +33,7 @@ export function DemoWindow({ cols = 80, rows = 24, cursorHighlight = false, + autoFocus = false, children, }: DemoWindowProps) { const { resolvedTheme } = usePageTheme() @@ -93,7 +95,7 @@ export function DemoWindow({
- + {children}
diff --git a/packages/docs/content/docs/components/ascii.mdx b/packages/docs/content/docs/components/ascii.mdx index efce48fd..0c753b6a 100644 --- a/packages/docs/content/docs/components/ascii.mdx +++ b/packages/docs/content/docs/components/ascii.mdx @@ -11,7 +11,7 @@ Renders text as large ASCII art using the `` OpenTUI intrinsic. Supports multiple font styles and custom colors.
- +
diff --git a/packages/docs/content/docs/components/chat.mdx b/packages/docs/content/docs/components/chat.mdx index cce9c280..8a938dee 100644 --- a/packages/docs/content/docs/components/chat.mdx +++ b/packages/docs/content/docs/components/chat.mdx @@ -12,7 +12,7 @@ call status cards, streaming text, a loading indicator, and a `PromptInput`. SDK-agnostic — the consumer passes messages and handles submission.
- +
diff --git a/packages/docs/content/docs/components/gradient.mdx b/packages/docs/content/docs/components/gradient.mdx index a533680c..2b461fe0 100644 --- a/packages/docs/content/docs/components/gradient.mdx +++ b/packages/docs/content/docs/components/gradient.mdx @@ -11,7 +11,7 @@ Applies a color gradient across text characters. Choose from 13 named presets or provide custom hex colors.
- +
diff --git a/packages/docs/content/docs/components/link.mdx b/packages/docs/content/docs/components/link.mdx index a9fcda29..44d9fc27 100644 --- a/packages/docs/content/docs/components/link.mdx +++ b/packages/docs/content/docs/components/link.mdx @@ -11,7 +11,7 @@ A clickable hyperlink for terminal UIs with configurable underline style and color.
- +
diff --git a/packages/docs/content/docs/components/modal.mdx b/packages/docs/content/docs/components/modal.mdx index ed4efa62..56992cbf 100644 --- a/packages/docs/content/docs/components/modal.mdx +++ b/packages/docs/content/docs/components/modal.mdx @@ -11,7 +11,7 @@ A bordered container that overlays content, optionally displays a title, and listens for the Escape key to trigger a close callback.
- +
diff --git a/packages/docs/content/docs/components/multi-select.mdx b/packages/docs/content/docs/components/multi-select.mdx index fef5a1a8..ea8d2d43 100644 --- a/packages/docs/content/docs/components/multi-select.mdx +++ b/packages/docs/content/docs/components/multi-select.mdx @@ -11,7 +11,7 @@ A multi-selection list with checkbox indicators, keyboard navigation, group headers, a submit row, and a submitted state.
- +
diff --git a/packages/docs/content/docs/components/primitives.mdx b/packages/docs/content/docs/components/primitives.mdx index 00c3b03b..d3fa7a81 100644 --- a/packages/docs/content/docs/components/primitives.mdx +++ b/packages/docs/content/docs/components/primitives.mdx @@ -12,7 +12,7 @@ Gridland uses JSX intrinsic elements that map to renderable components in the op These are used inside ``.
- +
diff --git a/packages/docs/content/docs/components/prompt-input.mdx b/packages/docs/content/docs/components/prompt-input.mdx index e6e929e8..d3be88db 100644 --- a/packages/docs/content/docs/components/prompt-input.mdx +++ b/packages/docs/content/docs/components/prompt-input.mdx @@ -12,7 +12,7 @@ command history, and direct Vercel AI SDK integration. Supports both a self-contained default layout and a fully composable compound mode.
- +
diff --git a/packages/docs/content/docs/components/select-input.mdx b/packages/docs/content/docs/components/select-input.mdx index 941eb27c..c943c2ba 100644 --- a/packages/docs/content/docs/components/select-input.mdx +++ b/packages/docs/content/docs/components/select-input.mdx @@ -11,7 +11,7 @@ A single-selection list with radio indicators, keyboard navigation, group headers, and a submitted state.
- +
diff --git a/packages/docs/content/docs/components/status-bar.mdx b/packages/docs/content/docs/components/status-bar.mdx index f23a7e4e..20b21d2a 100644 --- a/packages/docs/content/docs/components/status-bar.mdx +++ b/packages/docs/content/docs/components/status-bar.mdx @@ -11,7 +11,7 @@ A horizontal bar that displays keybinding hints with key labels and descriptions. Commonly placed at the bottom of a view.
- +
diff --git a/packages/docs/content/docs/components/terminal-window.mdx b/packages/docs/content/docs/components/terminal-window.mdx index ad54cd79..86f0d334 100644 --- a/packages/docs/content/docs/components/terminal-window.mdx +++ b/packages/docs/content/docs/components/terminal-window.mdx @@ -11,7 +11,7 @@ A decorative container that mimics a terminal window with traffic light buttons and an optional centered title. Defaults to a dark gray background.
- +
diff --git a/packages/docs/content/docs/components/tui.mdx b/packages/docs/content/docs/components/tui.mdx index 01b18772..ebe852c1 100644 --- a/packages/docs/content/docs/components/tui.mdx +++ b/packages/docs/content/docs/components/tui.mdx @@ -13,7 +13,7 @@ canvas. It handles client detection, canvas creation, React reconciler setup, and automatic resizing. Gridland is built on the [opentui](https://opentui.com) engine.
- +
diff --git a/packages/docs/content/docs/guides/focus-and-navigation.mdx b/packages/docs/content/docs/guides/focus-and-navigation.mdx index 6ba318c0..3e63b8a3 100644 --- a/packages/docs/content/docs/guides/focus-and-navigation.mdx +++ b/packages/docs/content/docs/guides/focus-and-navigation.mdx @@ -24,7 +24,7 @@ Gridland provides a unified focus and input system that works identically in bot #### Focus with Multi-Select
- +
diff --git a/packages/ui/components/chat/__snapshots__/chat.snapshot.test.tsx.snap b/packages/ui/components/chat/__snapshots__/chat.snapshot.test.tsx.snap deleted file mode 100644 index cf0501ab..00000000 --- a/packages/ui/components/chat/__snapshots__/chat.snapshot.test.tsx.snap +++ /dev/null @@ -1,38 +0,0 @@ -// Bun Snapshot v1, https://bun.sh/docs/test/snapshots - -exports[`ChatPanel snapshots renders messages with input 1`] = ` -" > Hello! - < Hi there! - -────────────────────────────────────────────────── - > ▍ Type a message... -──────────────────────────────────────────────────" -`; - -exports[`ChatPanel snapshots renders with streaming text 1`] = ` -" > Tell me a story - < Once upon a time_ - -────────────────────────────────────────────────── - > Generating... -──────────────────────────────────────────────────" -`; - -exports[`ChatPanel snapshots renders with loading state 1`] = ` -" > Hello - Thinking... - -────────────────────────────────────────────────── - > Thinking... -──────────────────────────────────────────────────" -`; - -exports[`ChatPanel snapshots renders with tool calls 1`] = ` -" > Read my file - ⠋ Read file ... - ✓ Edit file - -────────────────────────────────────────────────── - > ▍ Type a message... -──────────────────────────────────────────────────" -`; diff --git a/packages/ui/components/chat/chat.behavioral.test.tsx b/packages/ui/components/chat/chat.behavioral.test.tsx index 288a4ae2..deaf97a7 100644 --- a/packages/ui/components/chat/chat.behavioral.test.tsx +++ b/packages/ui/components/chat/chat.behavioral.test.tsx @@ -9,6 +9,7 @@ describe("ChatPanel behavior", () => { it("renders user messages with > prefix", () => { const { screen } = renderTui( {}} />, @@ -22,6 +23,7 @@ describe("ChatPanel behavior", () => { it("renders assistant messages with < prefix", () => { const { screen } = renderTui( {}} />, @@ -35,6 +37,7 @@ describe("ChatPanel behavior", () => { it("renders multiple messages in order", () => { const { screen } = renderTui( { it("renders streaming text with cursor", () => { const { screen } = renderTui( {}} @@ -68,6 +72,7 @@ describe("ChatPanel behavior", () => { it("renders loading indicator when isLoading and no streaming", () => { const { screen } = renderTui( {}} @@ -80,6 +85,7 @@ describe("ChatPanel behavior", () => { it("streaming text takes priority over loading", () => { const { screen } = renderTui( { it("renders tool call cards", () => { const { screen } = renderTui( { it("shows ellipsis for pending/in_progress tool calls", () => { const { screen } = renderTui( { it("does not show ellipsis for completed tool calls", () => { const { screen } = renderTui( { it("renders placeholder in input", () => { const { screen } = renderTui( {}} />, @@ -156,6 +166,7 @@ describe("ChatPanel behavior", () => { it("renders custom placeholder", () => { const { screen } = renderTui( {}} @@ -168,6 +179,7 @@ describe("ChatPanel behavior", () => { it("renders custom loading text", () => { const { screen } = renderTui( { it("handles empty messages array", () => { const { screen } = renderTui( {}} />, @@ -196,6 +209,7 @@ describe("ChatPanel behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } const tui = renderTui( { sent = text }} useKeyboard={mockUseKeyboard} @@ -215,6 +229,7 @@ describe("ChatPanel behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } const tui = renderTui( { sent = text }} useKeyboard={mockUseKeyboard} @@ -232,6 +247,7 @@ describe("ChatPanel behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } const tui = renderTui( { sent = text }} useKeyboard={mockUseKeyboard} @@ -252,6 +268,7 @@ describe("ChatPanel behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } const tui = renderTui( { sent = text }} @@ -271,6 +288,7 @@ describe("ChatPanel behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } const tui = renderTui( { cancelled = true }} @@ -290,6 +308,7 @@ describe("ChatPanel behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } const tui = renderTui( { cancelled = true }} @@ -308,6 +327,7 @@ describe("ChatPanel behavior", () => { it("shows loading indicator when status is submitted", () => { const { screen } = renderTui( {}} @@ -320,6 +340,7 @@ describe("ChatPanel behavior", () => { it("shows streaming text when status is streaming", () => { const { screen } = renderTui( { const mockUseKeyboard = (handler) => { savedHandler = handler } const tui = renderTui( { sent = text }} @@ -355,6 +377,7 @@ describe("ChatPanel behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } const tui = renderTui( { const mockUseKeyboard = (handler) => { savedHandler = handler } const tui = renderTui( { sent = text }} diff --git a/packages/ui/components/chat/chat.snapshot.test.tsx b/packages/ui/components/chat/chat.snapshot.test.tsx index c2a7166f..c924288d 100644 --- a/packages/ui/components/chat/chat.snapshot.test.tsx +++ b/packages/ui/components/chat/chat.snapshot.test.tsx @@ -9,6 +9,7 @@ describe("ChatPanel snapshots", () => { it("renders messages with input", () => { const { screen } = renderTui( { it("renders with streaming text", () => { const { screen } = renderTui( {}} @@ -35,6 +37,7 @@ describe("ChatPanel snapshots", () => { it("renders with loading state", () => { const { screen } = renderTui( {}} @@ -47,6 +50,7 @@ describe("ChatPanel snapshots", () => { it("renders with tool calls", () => { const { screen } = renderTui( void) => void } @@ -158,6 +160,7 @@ export function ChatPanel({ userColor, assistantColor, loadingText = "Thinking...", + focus, useKeyboard: useKeyboardProp, }: ChatPanelProps) { const theme = useTheme() @@ -217,6 +220,7 @@ export function ChatPanel({ promptColor={resolvedPromptColor} foregroundColor={theme.foreground} submittedText={loadingText} + focus={focus} useKeyboard={useKeyboardProp} /> diff --git a/packages/ui/components/prompt-input/__snapshots__/prompt-input.snapshot.test.tsx.snap b/packages/ui/components/prompt-input/__snapshots__/prompt-input.snapshot.test.tsx.snap deleted file mode 100644 index 7baea230..00000000 --- a/packages/ui/components/prompt-input/__snapshots__/prompt-input.snapshot.test.tsx.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Bun Snapshot v1, https://bun.sh/docs/test/snapshots - -exports[`PromptInput snapshots renders default state 1`] = ` -"──────────────────────────────────────── - ❯ ▍ Type a message... -────────────────────────────────────────" -`; - -exports[`PromptInput snapshots renders disabled state 1`] = ` -"──────────────────────────────────────── - ❯ Generating... -────────────────────────────────────────" -`; diff --git a/packages/ui/components/prompt-input/prompt-input.behavioral.test.tsx b/packages/ui/components/prompt-input/prompt-input.behavioral.test.tsx index 091809e5..8b3d8b59 100644 --- a/packages/ui/components/prompt-input/prompt-input.behavioral.test.tsx +++ b/packages/ui/components/prompt-input/prompt-input.behavioral.test.tsx @@ -10,7 +10,7 @@ describe("PromptInput behavior", () => { it("renders prompt and placeholder", () => { const { screen } = renderTui( - , + , { cols: 40, rows: 4 }, ) const text = screen.text() @@ -20,26 +20,18 @@ describe("PromptInput behavior", () => { it("renders custom prompt", () => { const { screen } = renderTui( - , + , { cols: 40, rows: 4 }, ) expect(screen.text()).toContain(">") }) - it("shows cursor when not disabled", () => { + it("shows placeholder when not focused", () => { const { screen } = renderTui( - , + , { cols: 40, rows: 4 }, ) - expect(screen.text()).toContain("\u258D") - }) - - it("hides cursor when disabled", () => { - const { screen } = renderTui( - , - { cols: 40, rows: 4 }, - ) - expect(screen.text()).not.toContain("\u258D") + expect(screen.text()).toContain("Type here...") }) it("shows disabled text when disabled", () => { @@ -58,6 +50,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { submitted = msg.text }} />, @@ -75,6 +68,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { submitted = msg.text }} />, @@ -90,6 +84,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { changed = text }} onSubmit={() => {}} @@ -108,6 +103,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { changed = text }} />, @@ -125,6 +121,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { changed = text }} />, @@ -159,6 +156,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { changed = text }} />, @@ -176,6 +174,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { submitted = msg.text }} @@ -237,6 +238,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { changed = text }} @@ -259,6 +261,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { changed = text }} @@ -278,6 +281,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { changed = text }} onSubmit={() => {}} @@ -308,6 +312,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { changed = text }} @@ -397,6 +402,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { stopped = true }} useKeyboard={mockUseKeyboard} @@ -413,6 +419,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { submitted = msg.text }} @@ -431,6 +438,7 @@ describe("PromptInput behavior", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { submitted = msg.text }} @@ -445,7 +453,7 @@ describe("PromptInput behavior", () => { it("shows error text when status is error", () => { const { screen } = renderTui( - , + , { cols: 40, rows: 4 }, ) expect(screen.text()).toContain("Something went wrong") @@ -457,7 +465,7 @@ describe("PromptInput behavior", () => { describe("PromptInput compound mode", () => { it("renders compound subcomponents when children provided", () => { const { screen } = renderTui( - + @@ -476,7 +484,7 @@ describe("PromptInput compound mode", () => { it("renders submit icon for ready status", () => { const { screen } = renderTui( - + , { cols: 40, rows: 4 }, @@ -506,7 +514,7 @@ describe("PromptInput compound mode", () => { it("renders submit icon for error status", () => { const { screen } = renderTui( - + , { cols: 40, rows: 4 }, @@ -516,7 +524,7 @@ describe("PromptInput compound mode", () => { it("shows error text via StatusText subcomponent", () => { const { screen } = renderTui( - + , { cols: 40, rows: 4 }, @@ -526,7 +534,7 @@ describe("PromptInput compound mode", () => { it("hides StatusText when not in error", () => { const { screen } = renderTui( - + , { cols: 40, rows: 4 }, @@ -539,7 +547,7 @@ describe("PromptInput compound mode", () => { let savedHandler = null const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( - { submitted = msg.text }}> + { submitted = msg.text }}> , { cols: 40, rows: 4 }, @@ -556,6 +564,7 @@ describe("PromptInput compound mode", () => { const mockUseKeyboard = (handler) => { savedHandler = handler } renderTui( { submitted = msg.text }} diff --git a/packages/ui/components/prompt-input/prompt-input.snapshot.test.tsx b/packages/ui/components/prompt-input/prompt-input.snapshot.test.tsx index 35c52c30..c46572ee 100644 --- a/packages/ui/components/prompt-input/prompt-input.snapshot.test.tsx +++ b/packages/ui/components/prompt-input/prompt-input.snapshot.test.tsx @@ -8,7 +8,7 @@ afterEach(() => cleanup()) describe("PromptInput snapshots", () => { it("renders default state", () => { const { screen } = renderTui( - , + , { cols: 40, rows: 4 }, ) expect(screen.text()).toMatchSnapshot() diff --git a/packages/ui/components/prompt-input/prompt-input.tsx b/packages/ui/components/prompt-input/prompt-input.tsx index 65a02070..0ff81ef5 100644 --- a/packages/ui/components/prompt-input/prompt-input.tsx +++ b/packages/ui/components/prompt-input/prompt-input.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck — OpenTUI intrinsic elements conflict with React's HTML/SVG types import { useState, useRef, @@ -108,6 +109,7 @@ export function PromptInputProvider({ initialInput = "", children }: PromptInput export interface PromptInputContextValue { value: string + isFocused: boolean disabled: boolean status?: ChatStatus onStop?: () => void @@ -123,6 +125,9 @@ export interface PromptInputContextValue { dividerColor?: string dividerDashed?: boolean theme: ReturnType + handleInput: (value: string) => void + handleInputSubmit: (value: string) => void + handleInputKeyDown: (key: any) => void } const PromptInputContext = createContext(null) @@ -189,6 +194,8 @@ export interface PromptInputProps { enableHistory?: boolean /** Model name displayed below the input */ model?: string + /** Whether the input is focused and accepting keystrokes */ + focus?: boolean /** Show horizontal dividers above and below the input */ showDividers?: boolean /** Auto-focus the input on mount (ensures canvas has keyboard focus in the browser) */ @@ -282,26 +289,31 @@ function PromptInputSuggestions() { ) } -const CURSOR_CHAR = "\u258D" - -/** Prompt char + text with syntax highlighting + cursor. */ +/** Prompt char + input element when focused, static text otherwise. */ function PromptInputTextarea() { - const { value, disabled, statusHintText, placeholder, prompt, promptColor, theme } = usePromptInput() + const { value, isFocused, disabled, statusHintText, placeholder, prompt, promptColor, theme, handleInput, handleInputSubmit, handleInputKeyDown } = usePromptInput() return ( - - {prompt} - {value.length === 0 ? ( - <> - {!disabled && {CURSOR_CHAR}} - {disabled ? statusHintText : " " + placeholder} - + + {prompt} + {isFocused ? ( + + ) : disabled && value.length === 0 ? ( + {statusHintText} ) : ( - <> - {value} - {!disabled && {CURSOR_CHAR}} - + {value || placeholder} )} - + ) } @@ -382,6 +394,7 @@ export function PromptInput({ maxSuggestions = 5, enableHistory = true, model, + focus = true, showDividers = true, autoFocus = false, dividerColor, @@ -405,6 +418,7 @@ export function PromptInput({ // Status-driven state const disabled = status ? status === "submitted" || status === "streaming" : disabledProp + const isFocused = focus && !disabled const statusHintText = resolveStatusHintText(status, submittedText, streamingLabel, errorText, disabledText) // ── Dual-mode state: provider-managed or self-managed ────────────────── @@ -526,15 +540,103 @@ export function PromptInput({ } }, [onSubmit, clearInput]) + // ── Input handlers (passed to intrinsic via context) ─────────── + + const handleInputSubmit = (text: string) => { + const trimmed = text.trim() + if (!trimmed) return + if (enableHistory) { + setHist([trimmed, ...historyRef.current]) + } + updateValue("") + setHistI(-1) + handleSubmit(trimmed) + } + + const handleInputKeyDown = (key: any) => { + // Return with active suggestions: custom submit logic + if (key.name === "return" && suggestionsRef.current.length > 0) { + const sel = suggestionsRef.current[sugIdxRef.current] + if (sel) { + if (valueRef.current.startsWith("/")) { + // Slash commands: submit immediately on selection + updateValue("") + if (enableHistory) { + setHist([sel.text, ...historyRef.current]) + } + setHistI(-1) + handleSubmit(sel.text) + } else { + const base = valueRef.current.slice(0, valueRef.current.lastIndexOf("@")) + updateValue(base + sel.text + " ") + setSug([]) + } + } + key.preventDefault() + return + } + + // Tab: cycle suggestions + if (key.name === "tab" && suggestionsRef.current.length > 0) { + setSugI((sugIdxRef.current + 1) % suggestionsRef.current.length) + key.preventDefault() + return + } + + // Up: navigate suggestions or history + if (key.name === "up") { + if (suggestionsRef.current.length > 0) { + setSugI(Math.max(0, sugIdxRef.current - 1)) + } else if (enableHistory && historyRef.current.length > 0) { + const idx = Math.min(historyRef.current.length - 1, histIdxRef.current + 1) + setHistI(idx) + updateValue(historyRef.current[idx]!) + } + key.preventDefault() + return + } + + // Down: navigate suggestions or history + if (key.name === "down") { + if (suggestionsRef.current.length > 0) { + setSugI(Math.min(suggestionsRef.current.length - 1, sugIdxRef.current + 1)) + } else if (enableHistory && histIdxRef.current > 0) { + const nextIdx = histIdxRef.current - 1 + setHistI(nextIdx) + updateValue(historyRef.current[nextIdx]!) + } else if (enableHistory && histIdxRef.current === 0) { + setHistI(-1) + updateValue("") + } + key.preventDefault() + return + } + + // Escape: dismiss suggestions + if (key.name === "escape") { + if (suggestionsRef.current.length > 0) { + setSug([]) + key.preventDefault() + } + return + } + } + // ── Keyboard handler ─────────────────────────────────────────────────── + // When focused, handles text input; useKeyboard only handles Escape→onStop. + // When not focused (no rendered), useKeyboard provides full keyboard fallback + // for character input, suggestions, and history (used in tests and unfocused state). useKeyboard?.((event: any) => { - // Escape during submitted/streaming calls onStop + // Escape during submitted/streaming calls onStop (always active) if (event.name === "escape" && (status === "streaming" || status === "submitted") && onStop) { onStop() return } + // When is rendered, it handles everything else + if (isFocused) return + if (disabled) return if (event.name === "return") { @@ -542,8 +644,6 @@ export function PromptInput({ const sel = suggestionsRef.current[sugIdxRef.current] if (sel) { if (valueRef.current.startsWith("/")) { - // Slash commands: submit immediately on selection - setSug([]) updateValue("") if (enableHistory) { setHist([sel.text, ...historyRef.current]) @@ -564,7 +664,6 @@ export function PromptInput({ } updateValue("") setHistI(-1) - setSug([]) handleSubmit(trimmed) } return @@ -607,7 +706,6 @@ export function PromptInput({ return } - // Character-level input fallback (used when intrinsic is not available, e.g. in tests) if (event.name === "backspace" || event.name === "delete") { updateValue(valueRef.current.slice(0, -1)) return @@ -631,6 +729,7 @@ export function PromptInput({ const ctxValue: PromptInputContextValue = { value, + isFocused, disabled, status, onStop, @@ -646,6 +745,9 @@ export function PromptInput({ dividerColor, dividerDashed, theme, + handleInput: updateValue, + handleInputSubmit, + handleInputKeyDown, } // ── Render ───────────────────────────────────────────────────────────── diff --git a/packages/ui/registry/ui/prompt-input.tsx b/packages/ui/registry/ui/prompt-input.tsx index c2950e0b..04de007c 100644 --- a/packages/ui/registry/ui/prompt-input.tsx +++ b/packages/ui/registry/ui/prompt-input.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck — OpenTUI intrinsic elements conflict with React's HTML/SVG types import { useState, useRef, @@ -108,6 +109,7 @@ export function PromptInputProvider({ initialInput = "", children }: PromptInput export interface PromptInputContextValue { value: string + isFocused: boolean disabled: boolean status?: ChatStatus onStop?: () => void @@ -121,6 +123,9 @@ export interface PromptInputContextValue { errorText: string model?: string theme: ReturnType + handleInput: (value: string) => void + handleInputSubmit: (value: string) => void + handleInputKeyDown: (key: any) => void } const PromptInputContext = createContext(null) @@ -187,6 +192,8 @@ export interface PromptInputProps { enableHistory?: boolean /** Model name displayed below the input */ model?: string + /** Whether the input is focused and accepting keystrokes */ + focus?: boolean /** Show horizontal dividers above and below the input */ showDividers?: boolean /** Auto-focus the input on mount (ensures canvas has keyboard focus in the browser) */ @@ -276,26 +283,31 @@ function PromptInputSuggestions() { ) } -const CURSOR_CHAR = "\u258D" - -/** Prompt char + text with syntax highlighting + cursor. */ +/** Prompt char + input element when focused, static text otherwise. */ function PromptInputTextarea() { - const { value, disabled, statusHintText, placeholder, prompt, promptColor, theme } = usePromptInput() + const { value, isFocused, disabled, statusHintText, placeholder, prompt, promptColor, theme, handleInput, handleInputSubmit, handleInputKeyDown } = usePromptInput() return ( - - {prompt} - {value.length === 0 ? ( - <> - {!disabled && {CURSOR_CHAR}} - {disabled ? statusHintText : " " + placeholder} - + + {prompt} + {isFocused ? ( + + ) : disabled && value.length === 0 ? ( + {statusHintText} ) : ( - <> - {value} - {!disabled && {CURSOR_CHAR}} - + {value || placeholder} )} - + ) } @@ -376,6 +388,7 @@ export function PromptInput({ maxSuggestions = 5, enableHistory = true, model, + focus = true, showDividers = true, autoFocus = false, useKeyboard: useKeyboardProp, @@ -397,6 +410,7 @@ export function PromptInput({ // Status-driven state const disabled = status ? status === "submitted" || status === "streaming" : disabledProp + const isFocused = focus && !disabled const statusHintText = resolveStatusHintText(status, submittedText, streamingLabel, errorText, disabledText) // ── Dual-mode state: provider-managed or self-managed ────────────────── @@ -518,15 +532,103 @@ export function PromptInput({ } }, [onSubmit, clearInput]) + // ── Input handlers (passed to intrinsic via context) ─────────── + + const handleInputSubmit = (text: string) => { + const trimmed = text.trim() + if (!trimmed) return + if (enableHistory) { + setHist([trimmed, ...historyRef.current]) + } + updateValue("") + setHistI(-1) + handleSubmit(trimmed) + } + + const handleInputKeyDown = (key: any) => { + // Return with active suggestions: custom submit logic + if (key.name === "return" && suggestionsRef.current.length > 0) { + const sel = suggestionsRef.current[sugIdxRef.current] + if (sel) { + if (valueRef.current.startsWith("/")) { + // Slash commands: submit immediately on selection + updateValue("") + if (enableHistory) { + setHist([sel.text, ...historyRef.current]) + } + setHistI(-1) + handleSubmit(sel.text) + } else { + const base = valueRef.current.slice(0, valueRef.current.lastIndexOf("@")) + updateValue(base + sel.text + " ") + setSug([]) + } + } + key.preventDefault() + return + } + + // Tab: cycle suggestions + if (key.name === "tab" && suggestionsRef.current.length > 0) { + setSugI((sugIdxRef.current + 1) % suggestionsRef.current.length) + key.preventDefault() + return + } + + // Up: navigate suggestions or history + if (key.name === "up") { + if (suggestionsRef.current.length > 0) { + setSugI(Math.max(0, sugIdxRef.current - 1)) + } else if (enableHistory && historyRef.current.length > 0) { + const idx = Math.min(historyRef.current.length - 1, histIdxRef.current + 1) + setHistI(idx) + updateValue(historyRef.current[idx]!) + } + key.preventDefault() + return + } + + // Down: navigate suggestions or history + if (key.name === "down") { + if (suggestionsRef.current.length > 0) { + setSugI(Math.min(suggestionsRef.current.length - 1, sugIdxRef.current + 1)) + } else if (enableHistory && histIdxRef.current > 0) { + const nextIdx = histIdxRef.current - 1 + setHistI(nextIdx) + updateValue(historyRef.current[nextIdx]!) + } else if (enableHistory && histIdxRef.current === 0) { + setHistI(-1) + updateValue("") + } + key.preventDefault() + return + } + + // Escape: dismiss suggestions + if (key.name === "escape") { + if (suggestionsRef.current.length > 0) { + setSug([]) + key.preventDefault() + } + return + } + } + // ── Keyboard handler ─────────────────────────────────────────────────── + // When focused, handles text input; useKeyboard only handles Escape→onStop. + // When not focused (no rendered), useKeyboard provides full keyboard fallback + // for character input, suggestions, and history (used in tests and unfocused state). useKeyboard?.((event: any) => { - // Escape during submitted/streaming calls onStop + // Escape during submitted/streaming calls onStop (always active) if (event.name === "escape" && (status === "streaming" || status === "submitted") && onStop) { onStop() return } + // When is rendered, it handles everything else + if (isFocused) return + if (disabled) return if (event.name === "return") { @@ -534,8 +636,6 @@ export function PromptInput({ const sel = suggestionsRef.current[sugIdxRef.current] if (sel) { if (valueRef.current.startsWith("/")) { - // Slash commands: submit immediately on selection - setSug([]) updateValue("") if (enableHistory) { setHist([sel.text, ...historyRef.current]) @@ -556,7 +656,6 @@ export function PromptInput({ } updateValue("") setHistI(-1) - setSug([]) handleSubmit(trimmed) } return @@ -599,7 +698,6 @@ export function PromptInput({ return } - // Character-level input fallback (used when intrinsic is not available, e.g. in tests) if (event.name === "backspace" || event.name === "delete") { updateValue(valueRef.current.slice(0, -1)) return @@ -623,6 +721,7 @@ export function PromptInput({ const ctxValue: PromptInputContextValue = { value, + isFocused, disabled, status, onStop, @@ -636,6 +735,9 @@ export function PromptInput({ errorText, model, theme, + handleInput: updateValue, + handleInputSubmit, + handleInputKeyDown, } // ── Render ─────────────────────────────────────────────────────────────