-
+
{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 ─────────────────────────────────────────────────────────────