diff --git a/bun.lock b/bun.lock index efffd2b2..ffdbfb0d 100644 --- a/bun.lock +++ b/bun.lock @@ -61,7 +61,7 @@ }, "packages/bun": { "name": "@gridland/bun", - "version": "0.2.50", + "version": "0.2.52", "dependencies": { "@gridland/utils": "workspace:*", "react": "^19.0.0", @@ -121,7 +121,7 @@ }, "packages/create-gridland": { "name": "create-gridland", - "version": "0.2.50", + "version": "0.2.52", "bin": { "create-gridland": "./dist/index.js", }, @@ -136,7 +136,7 @@ }, "packages/demo": { "name": "@gridland/demo", - "version": "0.2.50", + "version": "0.2.52", "bin": { "gridland-demo": "./bin/cli.mjs", }, @@ -147,7 +147,6 @@ "react": "^19.0.0", }, "devDependencies": { - "@gridland/ui": "workspace:*", "tsup": "^8.4.0", "typescript": "^5.7.0", }, @@ -180,7 +179,7 @@ }, "packages/testing": { "name": "@gridland/testing", - "version": "0.2.50", + "version": "0.2.52", "dependencies": { "@gridland/utils": "workspace:*", "events": "^3.3.0", @@ -216,7 +215,7 @@ }, "packages/utils": { "name": "@gridland/utils", - "version": "0.2.50", + "version": "0.2.52", "dependencies": { "react": "^19.0.0", }, @@ -232,7 +231,7 @@ }, "packages/web": { "name": "@gridland/web", - "version": "0.2.50", + "version": "0.2.52", "dependencies": { "@gridland/utils": "workspace:*", "diff": "^8.0.3", diff --git a/packages/demo/demos/canvas.tsx b/packages/demo/demos/canvas.tsx deleted file mode 100644 index d0446606..00000000 --- a/packages/demo/demos/canvas.tsx +++ /dev/null @@ -1,206 +0,0 @@ -// @ts-nocheck -"use client" -import { useState, useRef } from "react" -import { useKeyboard } from "@gridland/utils" -import { StatusBar, useTheme, textStyle } from "@gridland/ui" - -const DEFAULT_COLS = 24 -const DEFAULT_ROWS = 8 - -const PALETTE = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899"] - -function makeEmptyGrid(cols: number, rows: number): number[][] { - return Array.from({ length: rows }, () => Array(cols).fill(0)) -} - -function seedGrid(cols: number, rows: number): number[][] { - const g = makeEmptyGrid(cols, rows) - // Small heart near center - const heart = [ - [0, 1, 1, 0, 0, 1, 1, 0], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1, 0], - [0, 0, 1, 1, 1, 1, 0, 0], - [0, 0, 0, 1, 1, 0, 0, 0], - ] - const ox = Math.floor(cols / 2) - 4 - const oy = 1 - for (let r = 0; r < heart.length; r++) { - for (let c = 0; c < heart[r].length; c++) { - if (heart[r][c] && oy + r < rows && ox + c < cols) { - g[oy + r][ox + c] = 1 // red - } - } - } - return g -} - -interface CanvasAppProps { - mouseOffset?: { x: number; y: number } - containerWidth?: number - containerHeight?: number -} - -export function CanvasApp({ mouseOffset = { x: 0, y: 0 }, containerWidth, containerHeight }: CanvasAppProps = {}) { - const theme = useTheme() - const COLS = containerWidth ? Math.floor((containerWidth - 2) / 2) : DEFAULT_COLS - // instruction(1) + grid(ROWS) + spacer(1) + palette(1) + spacer(1) + statusbar(1) = containerHeight - 2 (borders) - const ROWS = containerHeight ? Math.max(3, containerHeight - 2 - 5) : DEFAULT_ROWS - const [grid, setGrid] = useState(() => seedGrid(COLS, ROWS)) - const [cursor, setCursor] = useState({ x: Math.floor(COLS / 2), y: Math.floor(ROWS / 2) }) - const [selectedColor, setSelectedColor] = useState(0) - const [drawing, setDrawing] = useState(false) - - const gridRef = useRef(grid) - const cursorRef = useRef(cursor) - const selectedColorRef = useRef(selectedColor) - gridRef.current = grid - cursorRef.current = cursor - selectedColorRef.current = selectedColor - - useKeyboard((event) => { - const cur = cursorRef.current - const col = selectedColorRef.current - - if (event.name === "up") { - const ny = Math.max(0, cur.y - 1) - cursorRef.current = { ...cur, y: ny } - setCursor({ ...cur, y: ny }) - } else if (event.name === "down") { - const ny = Math.min(ROWS - 1, cur.y + 1) - cursorRef.current = { ...cur, y: ny } - setCursor({ ...cur, y: ny }) - } else if (event.name === "left") { - const nx = Math.max(0, cur.x - 1) - cursorRef.current = { x: nx, y: cur.y } - setCursor({ x: nx, y: cur.y }) - } else if (event.name === "right") { - const nx = Math.min(COLS - 1, cur.x + 1) - cursorRef.current = { x: nx, y: cur.y } - setCursor({ x: nx, y: cur.y }) - } else if (event.name === "return") { - const g = gridRef.current.map((r) => [...r]) - const current = g[cur.y][cur.x] - g[cur.y][cur.x] = current === col + 1 ? 0 : col + 1 - gridRef.current = g - setGrid(g) - } else if (event.name >= "1" && event.name <= "8") { - const idx = parseInt(event.name) - 1 - selectedColorRef.current = idx - setSelectedColor(idx) - } else if (event.name === "c") { - const g = makeEmptyGrid(COLS, ROWS) - gridRef.current = g - setGrid(g) - } - event.preventDefault() - }) - - function paintCell(x: number, y: number) { - if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return - const g = grid.map((r) => [...r]) - g[y][x] = selectedColor + 1 - setGrid(g) - } - - function gridMousePos(e: any): { gx: number; gy: number } | null { - // Each cell is 2 chars wide; grid has 1 padding left - const gx = Math.floor((e.x - mouseOffset.x - 1) / 2) - const gy = e.y - mouseOffset.y - 2 // account for border (1) + instruction row (1) - if (gx >= 0 && gx < COLS && gy >= 0 && gy < ROWS) { - return { gx, gy } - } - return null - } - - return ( - { - // Check if clicking on palette row (padding + title + subtitle + spacer + grid + spacer = row 13) - const paletteY = mouseOffset.y + 1 + 1 + ROWS + 1 - if (e.y === paletteY) { - const px = Math.floor((e.x - mouseOffset.x - 1) / 4) - if (px >= 0 && px < PALETTE.length) { - selectedColorRef.current = px - setSelectedColor(px) - return - } - } - setDrawing(true) - const p = gridMousePos(e) - if (p) { - paintCell(p.gx, p.gy) - setCursor({ x: p.gx, y: p.gy }) - } - }} - onMouseUp={() => { - setDrawing(false) - }} - onMouseMove={(e: any) => { - const p = gridMousePos(e) - if (p) { - setCursor({ x: p.gx, y: p.gy }) - if (drawing) { - paintCell(p.gx, p.gy) - } - } - }} - > - - Draw with mouse or keyboard - - {grid.map((row, r) => ( - - {row.map((cell, c) => { - const isCursor = cursor.x === c && cursor.y === r - if (isCursor) { - const color = cell > 0 ? PALETTE[cell - 1] : PALETTE[selectedColor] - return ( - - {"▒▒"} - - ) - } - if (cell > 0) { - return ( - - {"▓▓"} - - ) - } - return ( - - {"··"} - - ) - })} - - ))} - - - - {PALETTE.map((color, i) => { - const isSelected = i === selectedColor - return ( - - {isSelected ? "[██]" : " ██ "} - - ) - })} - - - - - - - - ) -} diff --git a/packages/demo/demos/index.tsx b/packages/demo/demos/index.tsx index 930b6995..e159ca7a 100644 --- a/packages/demo/demos/index.tsx +++ b/packages/demo/demos/index.tsx @@ -28,8 +28,6 @@ import { ThemingApp } from "./theming" import { LandingApp } from "../src/landing" import { RippleApp } from "./ripple" import { PuzzleApp } from "./puzzle" -import { CanvasApp } from "./canvas" -import { SnakeApp } from "./snake" export { GradientApp, @@ -60,8 +58,6 @@ export { LandingApp, RippleApp, PuzzleApp, - CanvasApp, - SnakeApp, } export interface Demo { @@ -98,6 +94,4 @@ export const demos: Demo[] = [ { name: "landing", app: () => }, { name: "ripple", app: () => }, { name: "puzzle", app: () => }, - { name: "canvas", app: () => }, - { name: "snake", app: () => }, ] diff --git a/packages/demo/demos/snake.tsx b/packages/demo/demos/snake.tsx deleted file mode 100644 index 70656db6..00000000 --- a/packages/demo/demos/snake.tsx +++ /dev/null @@ -1,263 +0,0 @@ -// @ts-nocheck -"use client" -import { useState, useEffect, useRef, useCallback } from "react" -import { useKeyboard, useTerminalDimensions } from "@gridland/utils" -import { StatusBar, textStyle, useTheme } from "@gridland/ui" - -// Layout constants (in terminal rows/cols) -const SCORE_ROW = 0 // row 0: score line -const GRID_TOP = 1 // row 1: border top -const GRID_LEFT = 1 // col 1: border left (1 col margin) -const BORDER = 1 // border is 1 char thick -const CELL_WIDTH = 2 // each game cell = 2 terminal columns -const STATUS_HEIGHT = 1 // status bar at bottom - -const HEAD_COLOR = "#22d3ee" -const BODY_COLOR = "#0e7490" -const FOOD_COLOR = "#ef4444" - -function randomFood(cols: number, rows: number, snake: { x: number; y: number }[]): { x: number; y: number } { - const occupied = new Set(snake.map((s) => `${s.x},${s.y}`)) - const empty: { x: number; y: number }[] = [] - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - if (!occupied.has(`${x},${y}`)) { - empty.push({ x, y }) - } - } - } - if (empty.length === 0) return { x: 0, y: 0 } - return empty[Math.floor(Math.random() * empty.length)] -} - -function makeInitialSnake(cols: number, rows: number): { x: number; y: number }[] { - const cx = Math.floor(cols / 2) - const cy = Math.floor(rows / 2) - return [ - { x: cx, y: cy }, - { x: cx - 1, y: cy }, - { x: cx - 2, y: cy }, - ] -} - -interface SnakeAppProps { - containerWidth?: number - containerHeight?: number - mouseOffset?: { x: number; y: number } -} - -export function SnakeApp({ containerWidth, containerHeight, mouseOffset = { x: 0, y: 0 } }: SnakeAppProps = {}) { - const theme = useTheme() - const termDims = useTerminalDimensions() - const termW = containerWidth ?? termDims.width - const termH = containerHeight ? containerHeight - 2 : termDims.height // -2 for outer game box borders when embedded - const [, setTick] = useState(0) - - // Derive game grid size from terminal dimensions - // Available width: termW - 2 (margin) - 2 (border left+right), divided by cell width - // Available height: termH - 1 (score) - 2 (border top+bottom) - 1 (status bar) - const cols = Math.max(4, Math.floor((termW - 2 * GRID_LEFT - 2 * BORDER) / CELL_WIDTH)) - const rows = Math.max(4, termH - GRID_TOP - 2 * BORDER - STATUS_HEIGHT) - - // Content area starts after margin + border - const contentLeft = GRID_LEFT + BORDER - const contentTop = GRID_TOP + BORDER - - const colsRef = useRef(cols) - const rowsRef = useRef(rows) - colsRef.current = cols - rowsRef.current = rows - - const snakeRef = useRef(makeInitialSnake(cols, rows)) - const dirRef = useRef({ dx: 1, dy: 0 }) - const foodRef = useRef(randomFood(cols, rows, snakeRef.current)) - const scoreRef = useRef(0) - const gameOverRef = useRef(false) - const gameStartedRef = useRef(false) - - const restart = useCallback(() => { - const c = colsRef.current - const r = rowsRef.current - snakeRef.current = makeInitialSnake(c, r) - dirRef.current = { dx: 1, dy: 0 } - foodRef.current = randomFood(c, r, snakeRef.current) - scoreRef.current = 0 - gameOverRef.current = false - gameStartedRef.current = false - setTick((t) => t + 1) - }, []) - - useKeyboard((event) => { - if (gameOverRef.current) { - if (event.name === "return") restart() - event.preventDefault() - return - } - - const dir = dirRef.current - if (event.name === "up" && dir.dy !== 1) { - dirRef.current = { dx: 0, dy: -1 } - if (!gameStartedRef.current) gameStartedRef.current = true - } else if (event.name === "down" && dir.dy !== -1) { - dirRef.current = { dx: 0, dy: 1 } - if (!gameStartedRef.current) gameStartedRef.current = true - } else if (event.name === "left" && dir.dx !== 1) { - dirRef.current = { dx: -1, dy: 0 } - if (!gameStartedRef.current) gameStartedRef.current = true - } else if (event.name === "right" && dir.dx !== -1) { - dirRef.current = { dx: 1, dy: 0 } - if (!gameStartedRef.current) gameStartedRef.current = true - } - event.preventDefault() - }) - - useEffect(() => { - const id = setInterval(() => { - if (!gameStartedRef.current || gameOverRef.current) return - - const c = colsRef.current - const r = rowsRef.current - const snake = snakeRef.current - const dir = dirRef.current - const head = snake[0] - const newHead = { x: head.x + dir.dx, y: head.y + dir.dy } - - // Wall collision - if (newHead.x < 0 || newHead.x >= c || newHead.y < 0 || newHead.y >= r) { - gameOverRef.current = true - setTick((t) => t + 1) - return - } - - const ate = newHead.x === foodRef.current.x && newHead.y === foodRef.current.y - - // Self collision — exclude the tail if we're not growing - const checkSegments = ate ? snake : snake.slice(0, -1) - for (const seg of checkSegments) { - if (seg.x === newHead.x && seg.y === newHead.y) { - gameOverRef.current = true - setTick((t) => t + 1) - return - } - } - - const newSnake = [newHead, ...snake] - if (!ate) { - newSnake.pop() - } else { - scoreRef.current++ - foodRef.current = randomFood(c, r, newSnake) - } - snakeRef.current = newSnake - - setTick((t) => t + 1) - }, 150) - return () => clearInterval(id) - }, []) - - const handleClick = useCallback((e: any) => { - if (gameOverRef.current) return - - // Map absolute terminal coordinates to game cell - const cellX = Math.floor((e.x - mouseOffset.x - contentLeft) / CELL_WIDTH) - const cellY = e.y - mouseOffset.y - contentTop - if (cellX < 0 || cellX >= colsRef.current || cellY < 0 || cellY >= rowsRef.current) return - - const head = snakeRef.current[0] - const dx = cellX - head.x - const dy = cellY - head.y - const dir = dirRef.current - - if (Math.abs(dx) > Math.abs(dy)) { - if (dx > 0 && dir.dx !== -1) dirRef.current = { dx: 1, dy: 0 } - else if (dx < 0 && dir.dx !== 1) dirRef.current = { dx: -1, dy: 0 } - } else { - if (dy > 0 && dir.dy !== -1) dirRef.current = { dx: 0, dy: 1 } - else if (dy < 0 && dir.dy !== 1) dirRef.current = { dx: 0, dy: -1 } - } - - if (!gameStartedRef.current) gameStartedRef.current = true - }, [contentLeft, contentTop]) - - // Reset game when terminal resizes - useEffect(() => { - restart() - }, [cols, rows]) - - const snake = snakeRef.current - const food = foodRef.current - const score = scoreRef.current - const gameOver = gameOverRef.current - const gameStarted = gameStartedRef.current - - const snakeSet = new Map() - snake.forEach((s, i) => snakeSet.set(`${s.x},${s.y}`, i)) - - return ( - - {/* Score row */} - - - {"Score: "} - {String(score)} - {!gameStarted && ( - {" — press an arrow key to start"} - )} - - - {/* Game area with border */} - - - {Array.from({ length: rows }, (_, row) => ( - - {Array.from({ length: cols }, (_, col) => { - const key = `${col},${row}` - const snakeIdx = snakeSet.get(key) - if (snakeIdx !== undefined) { - return ( - - {"██"} - - ) - } - if (food.x === col && food.y === row) { - return ( - - {"██"} - - ) - } - return ( - - {" "} - - ) - })} - - ))} - {gameOver && ( - - {" GAME OVER "} - {" press enter to restart "} - - )} - - - {/* Status bar */} - - - - - - ) -} diff --git a/packages/demo/src/landing/demo-switcher-bar.tsx b/packages/demo/src/landing/demo-switcher-bar.tsx index ea19602d..dd02c829 100644 --- a/packages/demo/src/landing/demo-switcher-bar.tsx +++ b/packages/demo/src/landing/demo-switcher-bar.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import { textStyle, useTheme } from "@gridland/ui" -const DEMOS = ["ripple", "puzzle", "canvas", "snake"] +const DEMOS = ["ripple", "puzzle"] interface DemoSwitcherBarProps { activeIndex: number diff --git a/packages/demo/src/landing/landing-app.tsx b/packages/demo/src/landing/landing-app.tsx index 9b15a9c3..345ec5eb 100644 --- a/packages/demo/src/landing/landing-app.tsx +++ b/packages/demo/src/landing/landing-app.tsx @@ -8,10 +8,7 @@ import { MatrixBackground } from './matrix-background' import type { MatrixRipple } from './use-matrix' import { RippleApp } from '../../demos/ripple' import { PuzzleApp } from '../../demos/puzzle' -import { CanvasApp } from '../../demos/canvas' -import { SnakeApp } from '../../demos/snake' - -const DEMOS = ["ripple", "puzzle", "canvas", "snake"] +const DEMOS = ["ripple", "puzzle"] // Tab chrome is 2 rows (top border + labels); connecting line overlaps game box top border const TAB_HEIGHT = 2 @@ -199,8 +196,6 @@ export function LandingApp({ useKeyboard }: LandingAppProps) { {activeIndex === 0 && } {activeIndex === 1 && } - {activeIndex === 2 && } - {activeIndex === 3 && } diff --git a/packages/demo/src/landing/logo.tsx b/packages/demo/src/landing/logo.tsx index 5cbba236..0e6dac39 100644 --- a/packages/demo/src/landing/logo.tsx +++ b/packages/demo/src/landing/logo.tsx @@ -3,11 +3,11 @@ import { useState, useEffect, useRef, useMemo } from "react" import { Gradient, GRADIENTS, generateGradient, textStyle } from "@gridland/ui" import figlet from "figlet" // @ts-ignore -import standardFont from "figlet/importable-fonts/Standard.js" +import blockFont from "figlet/importable-fonts/Block.js" -figlet.parseFont("Standard", standardFont) +figlet.parseFont("Block", blockFont) -function makeArt(text: string, font = "ANSI Shadow") { +function makeArt(text: string, font = "Block") { return figlet .textSync(text, { font: font as any }) .split("\n") @@ -15,10 +15,10 @@ function makeArt(text: string, font = "ANSI Shadow") { .join("\n") } -const fullArt = makeArt("gridland", "Standard") -const gridArt = makeArt("grid", "Standard") -const landArt = makeArt("land", "Standard") -const ART_HEIGHT = 6 // Standard font produces 6 lines +const fullArt = makeArt("gridland", "Block") +const gridArt = makeArt("grid", "Block") +const landArt = makeArt("land", "Block") +const ART_HEIGHT = fullArt.split("\n").length function useAnimation(duration = 1000) { const isBrowser = typeof document !== "undefined" @@ -46,6 +46,13 @@ function useAnimation(duration = 1000) { /** Renders text with gradient colors, revealing characters left-to-right. * Non-space characters are rendered as positioned runs so that space gaps * are truly empty (allowing a background layer to show through). */ +function darkenHex(hex: string, factor = 0.4): string { + const r = Math.round(parseInt(hex.slice(1, 3), 16) * factor) + const g = Math.round(parseInt(hex.slice(3, 5), 16) * factor) + const b = Math.round(parseInt(hex.slice(5, 7), 16) * factor) + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}` +} + function RevealGradient({ children, revealCol }: { children: string; revealCol: number }) { const gradientColors = GRADIENTS.instagram const lines = children.split("\n") @@ -54,6 +61,7 @@ function RevealGradient({ children, revealCol }: { children: string; revealCol: if (maxLength === 0) return {children} const hexColors = useMemo(() => generateGradient(gradientColors, maxLength), [maxLength]) + const bgColors = useMemo(() => hexColors.map((c) => darkenHex(c)), [hexColors]) return ( @@ -92,7 +100,7 @@ function RevealGradient({ children, revealCol }: { children: string; revealCol: {run.chars.map((char, ci) => ( {char} @@ -115,7 +123,7 @@ export function Logo({ compact, narrow, mobile }: { compact?: boolean; narrow?: // Reveal: sweep columns left-to-right, slightly behind the drop const revealProgress = Math.max(0, Math.min(1, (progress - 0.1) / 0.7)) - const maxWidth = compact ? 8 : narrow ? 40 : 46 + const maxWidth = compact ? 8 : narrow ? 35 : 69 const revealCol = Math.round(revealProgress * (maxWidth + 4)) - 2 const taglineOpacity = Math.max(0, Math.min(1, (progress - 0.7) / 0.3)) diff --git a/packages/docs/app/(home)/layout.tsx b/packages/docs/app/(home)/layout.tsx index 333bbd17..41265a07 100644 --- a/packages/docs/app/(home)/layout.tsx +++ b/packages/docs/app/(home)/layout.tsx @@ -1,5 +1,15 @@ import type { ReactNode } from "react" +export const viewport = { + themeColor: "#1a1a2e", + viewportFit: "cover" as const, +} + export default function Layout({ children }: { children: ReactNode }) { - return
{children}
+ return ( + <> + +
{children}
+ + ) } diff --git a/packages/docs/app/(home)/page.tsx b/packages/docs/app/(home)/page.tsx index a3b42f35..fef47031 100644 --- a/packages/docs/app/(home)/page.tsx +++ b/packages/docs/app/(home)/page.tsx @@ -1,12 +1,30 @@ // @ts-nocheck — OpenTUI intrinsic elements conflict with React's HTML/SVG types "use client" +import { useState, useEffect } from "react" import { TUI } from "@gridland/web" import { LandingApp } from "@gridland/demo/landing" import { useKeyboard } from "@gridland/utils" +function useResponsiveFontSize() { + const [fontSize, setFontSize] = useState(14) + useEffect(() => { + const update = () => { + // Block font needs ~72 cols. Monospace char width ≈ fontSize * 0.6. + // Compute the largest font size that gives at least 72 cols. + const needed = Math.floor(window.innerWidth / (72 * 0.6)) + setFontSize(Math.max(9, Math.min(14, needed))) + } + update() + window.addEventListener("resize", update) + return () => window.removeEventListener("resize", update) + }, []) + return fontSize +} + export default function HomePage() { + const fontSize = useResponsiveFontSize() return ( - + ) diff --git a/packages/docs/app/docs/layout.tsx b/packages/docs/app/docs/layout.tsx index f0c6d1f3..19a879fa 100644 --- a/packages/docs/app/docs/layout.tsx +++ b/packages/docs/app/docs/layout.tsx @@ -2,6 +2,13 @@ import { DocsLayout } from "fumadocs-ui/layouts/docs" import type { ReactNode } from "react" import { source } from "@/lib/source" +export const viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f5f5f5" }, + { media: "(prefers-color-scheme: dark)", color: "#121212" }, + ], +} + export default function Layout({ children }: { children: ReactNode }) { return ( void\n clear: () => void\n}\n\nexport interface SuggestionsContext {\n suggestions: Suggestion[]\n selectedIndex: number\n setSuggestions: (s: Suggestion[]) => void\n setSelectedIndex: (i: number) => void\n clear: () => void\n}\n\ninterface PromptInputControllerProps {\n textInput: TextInputContext\n suggestions: SuggestionsContext\n}\n\nconst PromptInputControllerCtx = createContext(null)\n\n/** Access lifted PromptInput state from outside the component. Requires ``. */\nexport function usePromptInputController(): PromptInputControllerProps {\n const ctx = useContext(PromptInputControllerCtx)\n if (!ctx) {\n throw new Error(\"Wrap your component inside to use usePromptInputController().\")\n }\n return ctx\n}\n\nconst useOptionalController = () => useContext(PromptInputControllerCtx)\n\nexport type PromptInputProviderProps = PropsWithChildren<{\n initialInput?: string\n}>\n\n/**\n * Optional provider that lifts PromptInput state outside of PromptInput.\n * Without it, PromptInput stays fully self-managed.\n */\nexport function PromptInputProvider({ initialInput = \"\", children }: PromptInputProviderProps) {\n const [value, setValueState] = useState(initialInput)\n const clearInput = useCallback(() => setValueState(\"\"), [])\n\n const [suggestions, setSuggestions] = useState([])\n const [selectedIndex, setSelectedIndex] = useState(0)\n const clearSuggestions = useCallback(() => {\n setSuggestions([])\n setSelectedIndex(0)\n }, [])\n\n const controller = useMemo(\n () => ({\n textInput: { value, setValue: setValueState, clear: clearInput },\n suggestions: {\n suggestions,\n selectedIndex,\n setSuggestions,\n setSelectedIndex,\n clear: clearSuggestions,\n },\n }),\n [value, clearInput, suggestions, selectedIndex, clearSuggestions],\n )\n\n return (\n \n {children}\n \n )\n}\n\n// ============================================================================\n// Component Context (rendering state for subcomponents)\n// ============================================================================\n\nexport interface PromptInputContextValue {\n value: string\n disabled: boolean\n status?: ChatStatus\n onStop?: () => void\n statusHintText: string\n placeholder: string\n prompt: string\n promptColor: string\n suggestions: Suggestion[]\n sugIdx: number\n maxSuggestions: number\n errorText: string\n model?: string\n theme: ReturnType\n}\n\nconst PromptInputContext = createContext(null)\n\n/** Hook for accessing PromptInput state from compound subcomponents. */\nexport function usePromptInput(): PromptInputContextValue {\n const ctx = useContext(PromptInputContext)\n if (!ctx) {\n throw new Error(\"usePromptInput must be used within a component\")\n }\n return ctx\n}\n\n// ============================================================================\n// Props\n// ============================================================================\n\nexport interface PromptInputProps {\n /** Controlled input value */\n value?: string\n /** Default value for uncontrolled mode */\n defaultValue?: string\n /**\n * Called when user submits a message.\n * Receives `{ text }`. Compatible with Vercel AI SDK's `sendMessage` and\n * easily mapped to any other SDK. If the handler returns a Promise, input\n * is cleared on resolve and preserved on reject so the user can retry.\n */\n onSubmit?: (message: PromptInputMessage) => void | Promise\n /** Callback when input value changes */\n onChange?: (text: string) => void\n /** Placeholder text when input is empty */\n placeholder?: string\n /** Prompt character shown before input */\n prompt?: string\n /** Color of the prompt character */\n promptColor?: string\n /**\n * AI chat status — drives disabled state and status indicator.\n * When provided, takes precedence over `disabled`/`disabledText`.\n */\n status?: ChatStatus\n /** Called when user presses Escape during streaming to stop generation */\n onStop?: () => void\n /** Text shown when status is \"submitted\" */\n submittedText?: string\n /** Text shown when status is \"streaming\" */\n streamingText?: string\n /** Text shown when status is \"error\" */\n errorText?: string\n /** Disable input. Ignored when `status` is provided. */\n disabled?: boolean\n /** Text shown when disabled. Ignored when `status` is provided. */\n disabledText?: string\n /** Slash commands for autocomplete */\n commands?: { cmd: string; desc?: string }[]\n /** File paths for @ mention autocomplete */\n files?: string[]\n /** Custom suggestion provider — overrides commands/files */\n getSuggestions?: (value: string) => Suggestion[]\n /** Max visible suggestions */\n maxSuggestions?: number\n /** Enable command history with up/down arrows */\n enableHistory?: boolean\n /** Model name displayed below the input */\n model?: string\n /** Show horizontal dividers above and below the input */\n showDividers?: boolean\n /** Auto-focus the input on mount (ensures canvas has keyboard focus in the browser) */\n autoFocus?: boolean\n /** Keyboard hook from @opentui/react */\n useKeyboard?: (handler: (event: any) => void) => void\n /** Compound mode: provide subcomponents as children */\n children?: ReactNode\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction computeDefaultSuggestions(\n input: string,\n commands: { cmd: string; desc?: string }[],\n files: string[],\n): Suggestion[] {\n if (input.startsWith(\"/\") && commands.length > 0) {\n return commands\n .filter((c) => c.cmd.startsWith(input))\n .map((c) => ({ text: c.cmd, desc: c.desc }))\n }\n if (input.includes(\"@\") && files.length > 0) {\n const query = input.split(\"@\").pop() ?? \"\"\n return files\n .filter((f) => f.toLowerCase().includes(query.toLowerCase()))\n .map((f) => ({ text: \"@\" + f }))\n }\n return []\n}\n\nfunction resolveStatusHintText(\n status: ChatStatus | undefined,\n submittedText: string,\n streamingText: string,\n errorText: string,\n disabledText: string,\n): string {\n if (status === \"submitted\") return submittedText\n if (status === \"streaming\") return streamingText\n if (status === \"error\") return errorText\n return disabledText\n}\n\n// ============================================================================\n// Subcomponents\n// ============================================================================\n\nconst DIVIDER_LINE = \"─\".repeat(500)\n\n/** Horizontal divider line that extends into parent border gutters. */\nfunction PromptInputDivider() {\n const { theme } = usePromptInput()\n return (\n \n {DIVIDER_LINE}\n \n )\n}\n\n/** Autocomplete suggestion list. */\nfunction PromptInputSuggestions() {\n const { suggestions, sugIdx, maxSuggestions, theme } = usePromptInput()\n const visible = suggestions.slice(0, maxSuggestions)\n if (visible.length === 0) return null\n return (\n \n {visible.map((sug, i) => {\n const active = i === sugIdx\n return (\n \n \n {active ? \"▸ \" : \" \"}\n \n \n {sug.text}\n \n {sug.desc && (\n {\" \" + sug.desc}\n )}\n \n )\n })}\n \n )\n}\n\nconst CURSOR_CHAR = \"\\u258D\"\n\n/** Prompt char + text with syntax highlighting + cursor. */\nfunction PromptInputTextarea() {\n const { value, disabled, statusHintText, placeholder, prompt, promptColor, theme } = usePromptInput()\n return (\n \n {prompt}\n {value.length === 0 ? (\n <>\n {!disabled && {CURSOR_CHAR}}\n {disabled ? statusHintText : \" \" + placeholder}\n \n ) : (\n <>\n {value}\n {!disabled && {CURSOR_CHAR}}\n \n )}\n \n )\n}\n\n/**\n * Status indicator: ⏎ ready, ◐ submitted, ■ streaming, ✕ error.\n * When `status` and `onStop` are provided via context, the streaming icon\n * doubles as a stop button (Escape triggers onStop).\n */\nfunction PromptInputSubmit(props: { status?: ChatStatus; onStop?: () => void }) {\n const ctx = usePromptInput()\n const status = props.status ?? ctx.status\n const onStop = props.onStop ?? ctx.onStop\n const { disabled, theme } = ctx\n\n const isGenerating = status === \"submitted\" || status === \"streaming\"\n\n const icon =\n status === \"submitted\" ? \"◐\"\n : status === \"streaming\" ? (onStop ? \"■\" : \"◐\")\n : status === \"error\" ? \"✕\"\n : \"⏎\"\n\n const color =\n status === \"error\" ? theme.error\n : isGenerating ? theme.muted\n : disabled ? theme.muted\n : theme.primary\n\n return (\n \n {\" \" + icon}\n \n )\n}\n\n/** Error/hint text below input. */\nfunction PromptInputStatusText() {\n const { status, errorText, theme } = usePromptInput()\n if (status !== \"error\") return null\n return (\n \n {errorText}\n \n )\n}\n\n/** Model label shown below the input. */\nfunction PromptInputModel() {\n const { model, theme } = usePromptInput()\n if (!model) return null\n return (\n model: {model}\n )\n}\n\n// ============================================================================\n// Root component\n// ============================================================================\n\nexport function PromptInput({\n value: controlledValue,\n defaultValue = \"\",\n onSubmit,\n onChange,\n placeholder = \"Type a message...\",\n prompt = \"❯ \",\n promptColor,\n status,\n onStop,\n submittedText = \"Thinking...\",\n streamingText: streamingLabel = \"Generating...\",\n errorText = \"An error occurred. Try again.\",\n disabled: disabledProp = false,\n disabledText = \"Generating...\",\n commands = [],\n files = [],\n getSuggestions: customGetSuggestions,\n maxSuggestions = 5,\n enableHistory = true,\n model,\n showDividers = true,\n autoFocus = false,\n useKeyboard: useKeyboardProp,\n children,\n}: PromptInputProps) {\n const theme = useTheme()\n const useKeyboard = useKeyboardContext(useKeyboardProp)\n\n // Auto-focus: ensure the canvas has DOM focus so keyboard events reach useKeyboard\n useEffect(() => {\n if (!autoFocus) return\n if (typeof document === \"undefined\") return\n const canvas = document.querySelector(\"canvas\")\n if (canvas && document.activeElement !== canvas) {\n canvas.focus()\n }\n }, [autoFocus])\n const resolvedPromptColor = promptColor ?? theme.muted\n\n // Status-driven state\n const disabled = status ? status === \"submitted\" || status === \"streaming\" : disabledProp\n const statusHintText = resolveStatusHintText(status, submittedText, streamingLabel, errorText, disabledText)\n\n // ── Dual-mode state: provider-managed or self-managed ──────────────────\n const controller = useOptionalController()\n const usingProvider = !!controller\n\n const isControlled = controlledValue !== undefined\n const controlledRef = useRef(isControlled)\n if (controlledRef.current !== isControlled) {\n console.warn(\"PromptInput: switching between controlled and uncontrolled is not supported.\")\n }\n\n // Local state (used when no provider and not controlled)\n const [localValue, setLocalValue] = useState(defaultValue)\n const [localSuggestions, setLocalSuggestions] = useState([])\n const [localSugIdx, setLocalSugIdx] = useState(0)\n const [history, setHistory] = useState([])\n const [histIdx, setHistIdx] = useState(-1)\n\n // Resolve value from: controlled prop > provider > local\n const value = isControlled\n ? controlledValue\n : usingProvider\n ? controller.textInput.value\n : localValue\n\n const suggestions = usingProvider ? controller.suggestions.suggestions : localSuggestions\n const sugIdx = usingProvider ? controller.suggestions.selectedIndex : localSugIdx\n\n // ── State updaters (unified across modes) ──────────────────────────────\n\n const valueRef = useRef(defaultValue)\n if (isControlled) valueRef.current = controlledValue\n else if (usingProvider) valueRef.current = controller.textInput.value\n else valueRef.current = localValue\n\n const suggestionsRef = useRef([])\n suggestionsRef.current = suggestions\n const sugIdxRef = useRef(0)\n sugIdxRef.current = sugIdx\n const historyRef = useRef([])\n historyRef.current = history\n const histIdxRef = useRef(-1)\n histIdxRef.current = histIdx\n\n const setSug = useCallback((next: Suggestion[]) => {\n suggestionsRef.current = next\n if (usingProvider) {\n controller.suggestions.setSuggestions(next)\n } else {\n setLocalSuggestions(next)\n }\n }, [usingProvider, controller])\n\n const setSugI = useCallback((next: number) => {\n sugIdxRef.current = next\n if (usingProvider) {\n controller.suggestions.setSelectedIndex(next)\n } else {\n setLocalSugIdx(next)\n }\n }, [usingProvider, controller])\n\n const setHist = useCallback((next: string[]) => {\n historyRef.current = next\n setHistory(next)\n }, [])\n\n const setHistI = useCallback((next: number) => {\n histIdxRef.current = next\n setHistIdx(next)\n }, [])\n\n const computeSuggestions = useCallback((input: string): Suggestion[] => {\n if (customGetSuggestions) return customGetSuggestions(input)\n return computeDefaultSuggestions(input, commands, files)\n }, [customGetSuggestions, commands, files])\n\n const updateValue = useCallback((next: string) => {\n valueRef.current = next\n if (isControlled) {\n // controlled: only fire onChange, parent owns state\n } else if (usingProvider) {\n controller.textInput.setValue(next)\n } else {\n setLocalValue(next)\n }\n onChange?.(next)\n const sug = computeSuggestions(next)\n setSug(sug)\n setSugI(0)\n }, [isControlled, usingProvider, controller, onChange, computeSuggestions, setSug, setSugI])\n\n // ── Submit handler (auto-clears on success, preserves on error) ────────\n\n const clearInput = useCallback(() => {\n if (usingProvider) {\n controller.textInput.clear()\n } else if (!isControlled) {\n setLocalValue(\"\")\n }\n onChange?.(\"\")\n }, [usingProvider, controller, isControlled, onChange])\n\n const handleSubmit = useCallback((text: string) => {\n if (!onSubmit) return\n\n const result = onSubmit({ text })\n\n // Handle async onSubmit: clear on resolve, preserve on reject\n if (result instanceof Promise) {\n result.then(\n () => clearInput(),\n () => { /* Don't clear on error — user may want to retry */ },\n )\n } else {\n // Sync onSubmit completed without throwing — clear\n clearInput()\n }\n }, [onSubmit, clearInput])\n\n // ── Keyboard handler ───────────────────────────────────────────────────\n\n useKeyboard?.((event: any) => {\n // Escape during submitted/streaming calls onStop\n if (event.name === \"escape\" && (status === \"streaming\" || status === \"submitted\") && onStop) {\n onStop()\n return\n }\n\n if (disabled) return\n\n if (event.name === \"return\") {\n if (suggestionsRef.current.length > 0) {\n const sel = suggestionsRef.current[sugIdxRef.current]\n if (sel) {\n if (valueRef.current.startsWith(\"/\")) {\n // Slash commands: submit immediately on selection\n setSug([])\n updateValue(\"\")\n if (enableHistory) {\n setHist([sel.text, ...historyRef.current])\n }\n setHistI(-1)\n handleSubmit(sel.text)\n } else {\n const base = valueRef.current.slice(0, valueRef.current.lastIndexOf(\"@\"))\n updateValue(base + sel.text + \" \")\n setSug([])\n }\n }\n } else {\n const trimmed = valueRef.current.trim()\n if (!trimmed) return\n if (enableHistory) {\n setHist([trimmed, ...historyRef.current])\n }\n updateValue(\"\")\n setHistI(-1)\n setSug([])\n handleSubmit(trimmed)\n }\n return\n }\n\n if (event.name === \"tab\" && suggestionsRef.current.length > 0) {\n setSugI((sugIdxRef.current + 1) % suggestionsRef.current.length)\n return\n }\n\n if (event.name === \"up\") {\n if (suggestionsRef.current.length > 0) {\n setSugI(Math.max(0, sugIdxRef.current - 1))\n } else if (enableHistory && historyRef.current.length > 0) {\n const idx = Math.min(historyRef.current.length - 1, histIdxRef.current + 1)\n setHistI(idx)\n updateValue(historyRef.current[idx]!)\n }\n return\n }\n\n if (event.name === \"down\") {\n if (suggestionsRef.current.length > 0) {\n setSugI(Math.min(suggestionsRef.current.length - 1, sugIdxRef.current + 1))\n } else if (enableHistory && histIdxRef.current > 0) {\n const nextIdx = histIdxRef.current - 1\n setHistI(nextIdx)\n updateValue(historyRef.current[nextIdx]!)\n } else if (enableHistory && histIdxRef.current === 0) {\n setHistI(-1)\n updateValue(\"\")\n }\n return\n }\n\n if (event.name === \"escape\") {\n if (suggestionsRef.current.length > 0) {\n setSug([])\n }\n return\n }\n\n // Character-level input fallback (used when intrinsic is not available, e.g. in tests)\n if (event.name === \"backspace\" || event.name === \"delete\") {\n updateValue(valueRef.current.slice(0, -1))\n return\n }\n\n if (event.ctrl || event.meta) return\n\n if (event.name === \"space\") {\n updateValue(valueRef.current + \" \")\n return\n }\n\n if (event.name && event.name.length === 1) {\n updateValue(valueRef.current + event.name)\n }\n })\n\n // ── Build context for subcomponents ────────────────────────────────────\n\n const visibleSuggestions = suggestions.slice(0, maxSuggestions)\n\n const ctxValue: PromptInputContextValue = {\n value,\n disabled,\n status,\n onStop,\n statusHintText,\n placeholder,\n prompt,\n promptColor: resolvedPromptColor,\n suggestions: visibleSuggestions,\n sugIdx,\n maxSuggestions,\n errorText,\n model,\n theme,\n }\n\n // ── Render ─────────────────────────────────────────────────────────────\n\n if (children) {\n return (\n \n \n {children}\n \n \n )\n }\n\n return (\n \n \n {showDividers && }\n \n \n \n \n \n \n {showDividers && }\n \n \n )\n}\n\n// ── Attach subcomponents ─────────────────────────────────────────────────\n\nPromptInput.Textarea = PromptInputTextarea\nPromptInput.Suggestions = PromptInputSuggestions\nPromptInput.Submit = PromptInputSubmit\nPromptInput.Divider = PromptInputDivider\nPromptInput.StatusText = PromptInputStatusText\nPromptInput.Model = PromptInputModel\n" + "content": "import {\n useState,\n useRef,\n useCallback,\n useEffect,\n useMemo,\n createContext,\n useContext,\n type ReactNode,\n type PropsWithChildren,\n} from \"react\"\nimport { textStyle } from \"./text-style\"\nimport { useTheme } from \"./theme\"\nimport { useKeyboardContext } from \"./provider\"\n\n/** Chat lifecycle status. Compatible with any AI SDK. */\nexport type ChatStatus = \"ready\" | \"submitted\" | \"streaming\" | \"error\"\n\nexport interface Suggestion {\n text: string\n desc?: string\n}\n\n/** Message shape passed to onSubmit. */\nexport interface PromptInputMessage {\n text: string\n}\n\n// ============================================================================\n// Provider (lifted state)\n// ============================================================================\n\nexport interface TextInputContext {\n value: string\n setValue: (v: string) => void\n clear: () => void\n}\n\nexport interface SuggestionsContext {\n suggestions: Suggestion[]\n selectedIndex: number\n setSuggestions: (s: Suggestion[]) => void\n setSelectedIndex: (i: number) => void\n clear: () => void\n}\n\ninterface PromptInputControllerProps {\n textInput: TextInputContext\n suggestions: SuggestionsContext\n}\n\nconst PromptInputControllerCtx = createContext(null)\n\n/** Access lifted PromptInput state from outside the component. Requires ``. */\nexport function usePromptInputController(): PromptInputControllerProps {\n const ctx = useContext(PromptInputControllerCtx)\n if (!ctx) {\n throw new Error(\"Wrap your component inside to use usePromptInputController().\")\n }\n return ctx\n}\n\nconst useOptionalController = () => useContext(PromptInputControllerCtx)\n\nexport type PromptInputProviderProps = PropsWithChildren<{\n initialInput?: string\n}>\n\n/**\n * Optional provider that lifts PromptInput state outside of PromptInput.\n * Without it, PromptInput stays fully self-managed.\n */\nexport function PromptInputProvider({ initialInput = \"\", children }: PromptInputProviderProps) {\n const [value, setValueState] = useState(initialInput)\n const clearInput = useCallback(() => setValueState(\"\"), [])\n\n const [suggestions, setSuggestions] = useState([])\n const [selectedIndex, setSelectedIndex] = useState(0)\n const clearSuggestions = useCallback(() => {\n setSuggestions([])\n setSelectedIndex(0)\n }, [])\n\n const controller = useMemo(\n () => ({\n textInput: { value, setValue: setValueState, clear: clearInput },\n suggestions: {\n suggestions,\n selectedIndex,\n setSuggestions,\n setSelectedIndex,\n clear: clearSuggestions,\n },\n }),\n [value, clearInput, suggestions, selectedIndex, clearSuggestions],\n )\n\n return (\n \n {children}\n \n )\n}\n\n// ============================================================================\n// Component Context (rendering state for subcomponents)\n// ============================================================================\n\nexport interface PromptInputContextValue {\n value: string\n disabled: boolean\n status?: ChatStatus\n onStop?: () => void\n statusHintText: string\n placeholder: string\n prompt: string\n promptColor: string\n suggestions: Suggestion[]\n sugIdx: number\n maxSuggestions: number\n errorText: string\n model?: string\n dividerColor?: string\n dividerDashed?: boolean\n theme: ReturnType\n}\n\nconst PromptInputContext = createContext(null)\n\n/** Hook for accessing PromptInput state from compound subcomponents. */\nexport function usePromptInput(): PromptInputContextValue {\n const ctx = useContext(PromptInputContext)\n if (!ctx) {\n throw new Error(\"usePromptInput must be used within a component\")\n }\n return ctx\n}\n\n// ============================================================================\n// Props\n// ============================================================================\n\nexport interface PromptInputProps {\n /** Controlled input value */\n value?: string\n /** Default value for uncontrolled mode */\n defaultValue?: string\n /**\n * Called when user submits a message.\n * Receives `{ text }`. Compatible with Vercel AI SDK's `sendMessage` and\n * easily mapped to any other SDK. If the handler returns a Promise, input\n * is cleared on resolve and preserved on reject so the user can retry.\n */\n onSubmit?: (message: PromptInputMessage) => void | Promise\n /** Callback when input value changes */\n onChange?: (text: string) => void\n /** Placeholder text when input is empty */\n placeholder?: string\n /** Prompt character shown before input */\n prompt?: string\n /** Color of the prompt character */\n promptColor?: string\n /**\n * AI chat status — drives disabled state and status indicator.\n * When provided, takes precedence over `disabled`/`disabledText`.\n */\n status?: ChatStatus\n /** Called when user presses Escape during streaming to stop generation */\n onStop?: () => void\n /** Text shown when status is \"submitted\" */\n submittedText?: string\n /** Text shown when status is \"streaming\" */\n streamingText?: string\n /** Text shown when status is \"error\" */\n errorText?: string\n /** Disable input. Ignored when `status` is provided. */\n disabled?: boolean\n /** Text shown when disabled. Ignored when `status` is provided. */\n disabledText?: string\n /** Slash commands for autocomplete */\n commands?: { cmd: string; desc?: string }[]\n /** File paths for @ mention autocomplete */\n files?: string[]\n /** Custom suggestion provider — overrides commands/files */\n getSuggestions?: (value: string) => Suggestion[]\n /** Max visible suggestions */\n maxSuggestions?: number\n /** Enable command history with up/down arrows */\n enableHistory?: boolean\n /** Model name displayed below the input */\n model?: string\n /** Show horizontal dividers above and below the input */\n showDividers?: boolean\n /** Auto-focus the input on mount (ensures canvas has keyboard focus in the browser) */\n autoFocus?: boolean\n /** Override divider line color (e.g. for focus indicators) */\n dividerColor?: string\n /** Use dashed divider lines (╌) instead of solid (─) */\n dividerDashed?: boolean\n /** Keyboard hook from @opentui/react */\n useKeyboard?: (handler: (event: any) => void) => void\n /** Compound mode: provide subcomponents as children */\n children?: ReactNode\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction computeDefaultSuggestions(\n input: string,\n commands: { cmd: string; desc?: string }[],\n files: string[],\n): Suggestion[] {\n if (input.startsWith(\"/\") && commands.length > 0) {\n return commands\n .filter((c) => c.cmd.startsWith(input))\n .map((c) => ({ text: c.cmd, desc: c.desc }))\n }\n if (input.includes(\"@\") && files.length > 0) {\n const query = input.split(\"@\").pop() ?? \"\"\n return files\n .filter((f) => f.toLowerCase().includes(query.toLowerCase()))\n .map((f) => ({ text: \"@\" + f }))\n }\n return []\n}\n\nfunction resolveStatusHintText(\n status: ChatStatus | undefined,\n submittedText: string,\n streamingText: string,\n errorText: string,\n disabledText: string,\n): string {\n if (status === \"submitted\") return submittedText\n if (status === \"streaming\") return streamingText\n if (status === \"error\") return errorText\n return disabledText\n}\n\n// ============================================================================\n// Subcomponents\n// ============================================================================\n\n/** Horizontal divider line that extends into parent border gutters. */\nfunction PromptInputDivider() {\n const { dividerColor, dividerDashed, theme } = usePromptInput()\n const color = dividerColor ?? theme.muted\n const char = dividerDashed ? \"╌\" : \"─\"\n return (\n \n {char.repeat(500)}\n \n )\n}\n\n/** Autocomplete suggestion list. */\nfunction PromptInputSuggestions() {\n const { suggestions, sugIdx, maxSuggestions, theme } = usePromptInput()\n const visible = suggestions.slice(0, maxSuggestions)\n if (visible.length === 0) return null\n return (\n \n {visible.map((sug, i) => {\n const active = i === sugIdx\n return (\n \n \n {active ? \"▸ \" : \" \"}\n \n \n {sug.text}\n \n {sug.desc && (\n {\" \" + sug.desc}\n )}\n \n )\n })}\n \n )\n}\n\nconst CURSOR_CHAR = \"\\u258D\"\n\n/** Prompt char + text with syntax highlighting + cursor. */\nfunction PromptInputTextarea() {\n const { value, disabled, statusHintText, placeholder, prompt, promptColor, theme } = usePromptInput()\n return (\n \n {prompt}\n {value.length === 0 ? (\n <>\n {!disabled && {CURSOR_CHAR}}\n {disabled ? statusHintText : \" \" + placeholder}\n \n ) : (\n <>\n {value}\n {!disabled && {CURSOR_CHAR}}\n \n )}\n \n )\n}\n\n/**\n * Status indicator: ⏎ ready, ◐ submitted, ■ streaming, ✕ error.\n * When `status` and `onStop` are provided via context, the streaming icon\n * doubles as a stop button (Escape triggers onStop).\n */\nfunction PromptInputSubmit(props: { status?: ChatStatus; onStop?: () => void }) {\n const ctx = usePromptInput()\n const status = props.status ?? ctx.status\n const onStop = props.onStop ?? ctx.onStop\n const { disabled, theme } = ctx\n\n const isGenerating = status === \"submitted\" || status === \"streaming\"\n\n const icon =\n status === \"submitted\" ? \"◐\"\n : status === \"streaming\" ? (onStop ? \"■\" : \"◐\")\n : status === \"error\" ? \"✕\"\n : \"⏎\"\n\n const color =\n status === \"error\" ? theme.error\n : isGenerating ? theme.muted\n : disabled ? theme.muted\n : theme.primary\n\n return (\n \n {\" \" + icon}\n \n )\n}\n\n/** Error/hint text below input. */\nfunction PromptInputStatusText() {\n const { status, errorText, theme } = usePromptInput()\n if (status !== \"error\") return null\n return (\n \n {errorText}\n \n )\n}\n\n/** Model label shown below the input. */\nfunction PromptInputModel() {\n const { model, theme } = usePromptInput()\n if (!model) return null\n return (\n model: {model}\n )\n}\n\n// ============================================================================\n// Root component\n// ============================================================================\n\nexport function PromptInput({\n value: controlledValue,\n defaultValue = \"\",\n onSubmit,\n onChange,\n placeholder = \"Type a message...\",\n prompt = \"❯ \",\n promptColor,\n status,\n onStop,\n submittedText = \"Thinking...\",\n streamingText: streamingLabel = \"Generating...\",\n errorText = \"An error occurred. Try again.\",\n disabled: disabledProp = false,\n disabledText = \"Generating...\",\n commands = [],\n files = [],\n getSuggestions: customGetSuggestions,\n maxSuggestions = 5,\n enableHistory = true,\n model,\n showDividers = true,\n autoFocus = false,\n dividerColor,\n dividerDashed,\n useKeyboard: useKeyboardProp,\n children,\n}: PromptInputProps) {\n const theme = useTheme()\n const useKeyboard = useKeyboardContext(useKeyboardProp)\n\n // Auto-focus: ensure the canvas has DOM focus so keyboard events reach useKeyboard\n useEffect(() => {\n if (!autoFocus) return\n if (typeof document === \"undefined\") return\n const canvas = document.querySelector(\"canvas\")\n if (canvas && document.activeElement !== canvas) {\n canvas.focus()\n }\n }, [autoFocus])\n const resolvedPromptColor = promptColor ?? theme.muted\n\n // Status-driven state\n const disabled = status ? status === \"submitted\" || status === \"streaming\" : disabledProp\n const statusHintText = resolveStatusHintText(status, submittedText, streamingLabel, errorText, disabledText)\n\n // ── Dual-mode state: provider-managed or self-managed ──────────────────\n const controller = useOptionalController()\n const usingProvider = !!controller\n\n const isControlled = controlledValue !== undefined\n const controlledRef = useRef(isControlled)\n if (controlledRef.current !== isControlled) {\n console.warn(\"PromptInput: switching between controlled and uncontrolled is not supported.\")\n }\n\n // Local state (used when no provider and not controlled)\n const [localValue, setLocalValue] = useState(defaultValue)\n const [localSuggestions, setLocalSuggestions] = useState([])\n const [localSugIdx, setLocalSugIdx] = useState(0)\n const [history, setHistory] = useState([])\n const [histIdx, setHistIdx] = useState(-1)\n\n // Resolve value from: controlled prop > provider > local\n const value = isControlled\n ? controlledValue\n : usingProvider\n ? controller.textInput.value\n : localValue\n\n const suggestions = usingProvider ? controller.suggestions.suggestions : localSuggestions\n const sugIdx = usingProvider ? controller.suggestions.selectedIndex : localSugIdx\n\n // ── State updaters (unified across modes) ──────────────────────────────\n\n const valueRef = useRef(defaultValue)\n if (isControlled) valueRef.current = controlledValue\n else if (usingProvider) valueRef.current = controller.textInput.value\n else valueRef.current = localValue\n\n const suggestionsRef = useRef([])\n suggestionsRef.current = suggestions\n const sugIdxRef = useRef(0)\n sugIdxRef.current = sugIdx\n const historyRef = useRef([])\n historyRef.current = history\n const histIdxRef = useRef(-1)\n histIdxRef.current = histIdx\n\n const setSug = useCallback((next: Suggestion[]) => {\n suggestionsRef.current = next\n if (usingProvider) {\n controller.suggestions.setSuggestions(next)\n } else {\n setLocalSuggestions(next)\n }\n }, [usingProvider, controller])\n\n const setSugI = useCallback((next: number) => {\n sugIdxRef.current = next\n if (usingProvider) {\n controller.suggestions.setSelectedIndex(next)\n } else {\n setLocalSugIdx(next)\n }\n }, [usingProvider, controller])\n\n const setHist = useCallback((next: string[]) => {\n historyRef.current = next\n setHistory(next)\n }, [])\n\n const setHistI = useCallback((next: number) => {\n histIdxRef.current = next\n setHistIdx(next)\n }, [])\n\n const computeSuggestions = useCallback((input: string): Suggestion[] => {\n if (customGetSuggestions) return customGetSuggestions(input)\n return computeDefaultSuggestions(input, commands, files)\n }, [customGetSuggestions, commands, files])\n\n const updateValue = useCallback((next: string) => {\n valueRef.current = next\n if (isControlled) {\n // controlled: only fire onChange, parent owns state\n } else if (usingProvider) {\n controller.textInput.setValue(next)\n } else {\n setLocalValue(next)\n }\n onChange?.(next)\n const sug = computeSuggestions(next)\n setSug(sug)\n setSugI(0)\n }, [isControlled, usingProvider, controller, onChange, computeSuggestions, setSug, setSugI])\n\n // ── Submit handler (auto-clears on success, preserves on error) ────────\n\n const clearInput = useCallback(() => {\n if (usingProvider) {\n controller.textInput.clear()\n } else if (!isControlled) {\n setLocalValue(\"\")\n }\n onChange?.(\"\")\n }, [usingProvider, controller, isControlled, onChange])\n\n const handleSubmit = useCallback((text: string) => {\n if (!onSubmit) return\n\n const result = onSubmit({ text })\n\n // Handle async onSubmit: clear on resolve, preserve on reject\n if (result instanceof Promise) {\n result.then(\n () => clearInput(),\n () => { /* Don't clear on error — user may want to retry */ },\n )\n } else {\n // Sync onSubmit completed without throwing — clear\n clearInput()\n }\n }, [onSubmit, clearInput])\n\n // ── Keyboard handler ───────────────────────────────────────────────────\n\n useKeyboard?.((event: any) => {\n // Escape during submitted/streaming calls onStop\n if (event.name === \"escape\" && (status === \"streaming\" || status === \"submitted\") && onStop) {\n onStop()\n return\n }\n\n if (disabled) return\n\n if (event.name === \"return\") {\n if (suggestionsRef.current.length > 0) {\n const sel = suggestionsRef.current[sugIdxRef.current]\n if (sel) {\n if (valueRef.current.startsWith(\"/\")) {\n // Slash commands: submit immediately on selection\n setSug([])\n updateValue(\"\")\n if (enableHistory) {\n setHist([sel.text, ...historyRef.current])\n }\n setHistI(-1)\n handleSubmit(sel.text)\n } else {\n const base = valueRef.current.slice(0, valueRef.current.lastIndexOf(\"@\"))\n updateValue(base + sel.text + \" \")\n setSug([])\n }\n }\n } else {\n const trimmed = valueRef.current.trim()\n if (!trimmed) return\n if (enableHistory) {\n setHist([trimmed, ...historyRef.current])\n }\n updateValue(\"\")\n setHistI(-1)\n setSug([])\n handleSubmit(trimmed)\n }\n return\n }\n\n if (event.name === \"tab\" && suggestionsRef.current.length > 0) {\n setSugI((sugIdxRef.current + 1) % suggestionsRef.current.length)\n return\n }\n\n if (event.name === \"up\") {\n if (suggestionsRef.current.length > 0) {\n setSugI(Math.max(0, sugIdxRef.current - 1))\n } else if (enableHistory && historyRef.current.length > 0) {\n const idx = Math.min(historyRef.current.length - 1, histIdxRef.current + 1)\n setHistI(idx)\n updateValue(historyRef.current[idx]!)\n }\n return\n }\n\n if (event.name === \"down\") {\n if (suggestionsRef.current.length > 0) {\n setSugI(Math.min(suggestionsRef.current.length - 1, sugIdxRef.current + 1))\n } else if (enableHistory && histIdxRef.current > 0) {\n const nextIdx = histIdxRef.current - 1\n setHistI(nextIdx)\n updateValue(historyRef.current[nextIdx]!)\n } else if (enableHistory && histIdxRef.current === 0) {\n setHistI(-1)\n updateValue(\"\")\n }\n return\n }\n\n if (event.name === \"escape\") {\n if (suggestionsRef.current.length > 0) {\n setSug([])\n }\n return\n }\n\n // Character-level input fallback (used when intrinsic is not available, e.g. in tests)\n if (event.name === \"backspace\" || event.name === \"delete\") {\n updateValue(valueRef.current.slice(0, -1))\n return\n }\n\n if (event.ctrl || event.meta) return\n\n if (event.name === \"space\") {\n updateValue(valueRef.current + \" \")\n return\n }\n\n if (event.name && event.name.length === 1) {\n updateValue(valueRef.current + event.name)\n }\n })\n\n // ── Build context for subcomponents ────────────────────────────────────\n\n const visibleSuggestions = suggestions.slice(0, maxSuggestions)\n\n const ctxValue: PromptInputContextValue = {\n value,\n disabled,\n status,\n onStop,\n statusHintText,\n placeholder,\n prompt,\n promptColor: resolvedPromptColor,\n suggestions: visibleSuggestions,\n sugIdx,\n maxSuggestions,\n errorText,\n model,\n dividerColor,\n dividerDashed,\n theme,\n }\n\n // ── Render ─────────────────────────────────────────────────────────────\n\n if (children) {\n return (\n \n \n {children}\n \n \n )\n }\n\n return (\n \n \n {showDividers && }\n \n \n \n \n \n \n {showDividers && }\n \n \n )\n}\n\n// ── Attach subcomponents ─────────────────────────────────────────────────\n\nPromptInput.Textarea = PromptInputTextarea\nPromptInput.Suggestions = PromptInputSuggestions\nPromptInput.Submit = PromptInputSubmit\nPromptInput.Divider = PromptInputDivider\nPromptInput.StatusText = PromptInputStatusText\nPromptInput.Model = PromptInputModel\n" } ] }, diff --git a/packages/ui/registry/prompt-input.json b/packages/ui/registry/prompt-input.json index c8c23cdd..aeaee032 100644 --- a/packages/ui/registry/prompt-input.json +++ b/packages/ui/registry/prompt-input.json @@ -13,7 +13,7 @@ { "path": "registry/ui/prompt-input.tsx", "type": "registry:ui", - "content": "import {\n useState,\n useRef,\n useCallback,\n useEffect,\n useMemo,\n createContext,\n useContext,\n type ReactNode,\n type PropsWithChildren,\n} from \"react\"\nimport { textStyle } from \"./text-style\"\nimport { useTheme } from \"./theme\"\nimport { useKeyboardContext } from \"./provider\"\n\n/** Chat lifecycle status. Compatible with any AI SDK. */\nexport type ChatStatus = \"ready\" | \"submitted\" | \"streaming\" | \"error\"\n\nexport interface Suggestion {\n text: string\n desc?: string\n}\n\n/** Message shape passed to onSubmit. */\nexport interface PromptInputMessage {\n text: string\n}\n\n// ============================================================================\n// Provider (lifted state)\n// ============================================================================\n\nexport interface TextInputContext {\n value: string\n setValue: (v: string) => void\n clear: () => void\n}\n\nexport interface SuggestionsContext {\n suggestions: Suggestion[]\n selectedIndex: number\n setSuggestions: (s: Suggestion[]) => void\n setSelectedIndex: (i: number) => void\n clear: () => void\n}\n\ninterface PromptInputControllerProps {\n textInput: TextInputContext\n suggestions: SuggestionsContext\n}\n\nconst PromptInputControllerCtx = createContext(null)\n\n/** Access lifted PromptInput state from outside the component. Requires ``. */\nexport function usePromptInputController(): PromptInputControllerProps {\n const ctx = useContext(PromptInputControllerCtx)\n if (!ctx) {\n throw new Error(\"Wrap your component inside to use usePromptInputController().\")\n }\n return ctx\n}\n\nconst useOptionalController = () => useContext(PromptInputControllerCtx)\n\nexport type PromptInputProviderProps = PropsWithChildren<{\n initialInput?: string\n}>\n\n/**\n * Optional provider that lifts PromptInput state outside of PromptInput.\n * Without it, PromptInput stays fully self-managed.\n */\nexport function PromptInputProvider({ initialInput = \"\", children }: PromptInputProviderProps) {\n const [value, setValueState] = useState(initialInput)\n const clearInput = useCallback(() => setValueState(\"\"), [])\n\n const [suggestions, setSuggestions] = useState([])\n const [selectedIndex, setSelectedIndex] = useState(0)\n const clearSuggestions = useCallback(() => {\n setSuggestions([])\n setSelectedIndex(0)\n }, [])\n\n const controller = useMemo(\n () => ({\n textInput: { value, setValue: setValueState, clear: clearInput },\n suggestions: {\n suggestions,\n selectedIndex,\n setSuggestions,\n setSelectedIndex,\n clear: clearSuggestions,\n },\n }),\n [value, clearInput, suggestions, selectedIndex, clearSuggestions],\n )\n\n return (\n \n {children}\n \n )\n}\n\n// ============================================================================\n// Component Context (rendering state for subcomponents)\n// ============================================================================\n\nexport interface PromptInputContextValue {\n value: string\n disabled: boolean\n status?: ChatStatus\n onStop?: () => void\n statusHintText: string\n placeholder: string\n prompt: string\n promptColor: string\n suggestions: Suggestion[]\n sugIdx: number\n maxSuggestions: number\n errorText: string\n model?: string\n theme: ReturnType\n}\n\nconst PromptInputContext = createContext(null)\n\n/** Hook for accessing PromptInput state from compound subcomponents. */\nexport function usePromptInput(): PromptInputContextValue {\n const ctx = useContext(PromptInputContext)\n if (!ctx) {\n throw new Error(\"usePromptInput must be used within a component\")\n }\n return ctx\n}\n\n// ============================================================================\n// Props\n// ============================================================================\n\nexport interface PromptInputProps {\n /** Controlled input value */\n value?: string\n /** Default value for uncontrolled mode */\n defaultValue?: string\n /**\n * Called when user submits a message.\n * Receives `{ text }`. Compatible with Vercel AI SDK's `sendMessage` and\n * easily mapped to any other SDK. If the handler returns a Promise, input\n * is cleared on resolve and preserved on reject so the user can retry.\n */\n onSubmit?: (message: PromptInputMessage) => void | Promise\n /** Callback when input value changes */\n onChange?: (text: string) => void\n /** Placeholder text when input is empty */\n placeholder?: string\n /** Prompt character shown before input */\n prompt?: string\n /** Color of the prompt character */\n promptColor?: string\n /**\n * AI chat status — drives disabled state and status indicator.\n * When provided, takes precedence over `disabled`/`disabledText`.\n */\n status?: ChatStatus\n /** Called when user presses Escape during streaming to stop generation */\n onStop?: () => void\n /** Text shown when status is \"submitted\" */\n submittedText?: string\n /** Text shown when status is \"streaming\" */\n streamingText?: string\n /** Text shown when status is \"error\" */\n errorText?: string\n /** Disable input. Ignored when `status` is provided. */\n disabled?: boolean\n /** Text shown when disabled. Ignored when `status` is provided. */\n disabledText?: string\n /** Slash commands for autocomplete */\n commands?: { cmd: string; desc?: string }[]\n /** File paths for @ mention autocomplete */\n files?: string[]\n /** Custom suggestion provider — overrides commands/files */\n getSuggestions?: (value: string) => Suggestion[]\n /** Max visible suggestions */\n maxSuggestions?: number\n /** Enable command history with up/down arrows */\n enableHistory?: boolean\n /** Model name displayed below the input */\n model?: string\n /** Show horizontal dividers above and below the input */\n showDividers?: boolean\n /** Auto-focus the input on mount (ensures canvas has keyboard focus in the browser) */\n autoFocus?: boolean\n /** Keyboard hook from @opentui/react */\n useKeyboard?: (handler: (event: any) => void) => void\n /** Compound mode: provide subcomponents as children */\n children?: ReactNode\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction computeDefaultSuggestions(\n input: string,\n commands: { cmd: string; desc?: string }[],\n files: string[],\n): Suggestion[] {\n if (input.startsWith(\"/\") && commands.length > 0) {\n return commands\n .filter((c) => c.cmd.startsWith(input))\n .map((c) => ({ text: c.cmd, desc: c.desc }))\n }\n if (input.includes(\"@\") && files.length > 0) {\n const query = input.split(\"@\").pop() ?? \"\"\n return files\n .filter((f) => f.toLowerCase().includes(query.toLowerCase()))\n .map((f) => ({ text: \"@\" + f }))\n }\n return []\n}\n\nfunction resolveStatusHintText(\n status: ChatStatus | undefined,\n submittedText: string,\n streamingText: string,\n errorText: string,\n disabledText: string,\n): string {\n if (status === \"submitted\") return submittedText\n if (status === \"streaming\") return streamingText\n if (status === \"error\") return errorText\n return disabledText\n}\n\n// ============================================================================\n// Subcomponents\n// ============================================================================\n\nconst DIVIDER_LINE = \"─\".repeat(500)\n\n/** Horizontal divider line that extends into parent border gutters. */\nfunction PromptInputDivider() {\n const { theme } = usePromptInput()\n return (\n \n {DIVIDER_LINE}\n \n )\n}\n\n/** Autocomplete suggestion list. */\nfunction PromptInputSuggestions() {\n const { suggestions, sugIdx, maxSuggestions, theme } = usePromptInput()\n const visible = suggestions.slice(0, maxSuggestions)\n if (visible.length === 0) return null\n return (\n \n {visible.map((sug, i) => {\n const active = i === sugIdx\n return (\n \n \n {active ? \"▸ \" : \" \"}\n \n \n {sug.text}\n \n {sug.desc && (\n {\" \" + sug.desc}\n )}\n \n )\n })}\n \n )\n}\n\nconst CURSOR_CHAR = \"\\u258D\"\n\n/** Prompt char + text with syntax highlighting + cursor. */\nfunction PromptInputTextarea() {\n const { value, disabled, statusHintText, placeholder, prompt, promptColor, theme } = usePromptInput()\n return (\n \n {prompt}\n {value.length === 0 ? (\n <>\n {!disabled && {CURSOR_CHAR}}\n {disabled ? statusHintText : \" \" + placeholder}\n \n ) : (\n <>\n {value}\n {!disabled && {CURSOR_CHAR}}\n \n )}\n \n )\n}\n\n/**\n * Status indicator: ⏎ ready, ◐ submitted, ■ streaming, ✕ error.\n * When `status` and `onStop` are provided via context, the streaming icon\n * doubles as a stop button (Escape triggers onStop).\n */\nfunction PromptInputSubmit(props: { status?: ChatStatus; onStop?: () => void }) {\n const ctx = usePromptInput()\n const status = props.status ?? ctx.status\n const onStop = props.onStop ?? ctx.onStop\n const { disabled, theme } = ctx\n\n const isGenerating = status === \"submitted\" || status === \"streaming\"\n\n const icon =\n status === \"submitted\" ? \"◐\"\n : status === \"streaming\" ? (onStop ? \"■\" : \"◐\")\n : status === \"error\" ? \"✕\"\n : \"⏎\"\n\n const color =\n status === \"error\" ? theme.error\n : isGenerating ? theme.muted\n : disabled ? theme.muted\n : theme.primary\n\n return (\n \n {\" \" + icon}\n \n )\n}\n\n/** Error/hint text below input. */\nfunction PromptInputStatusText() {\n const { status, errorText, theme } = usePromptInput()\n if (status !== \"error\") return null\n return (\n \n {errorText}\n \n )\n}\n\n/** Model label shown below the input. */\nfunction PromptInputModel() {\n const { model, theme } = usePromptInput()\n if (!model) return null\n return (\n model: {model}\n )\n}\n\n// ============================================================================\n// Root component\n// ============================================================================\n\nexport function PromptInput({\n value: controlledValue,\n defaultValue = \"\",\n onSubmit,\n onChange,\n placeholder = \"Type a message...\",\n prompt = \"❯ \",\n promptColor,\n status,\n onStop,\n submittedText = \"Thinking...\",\n streamingText: streamingLabel = \"Generating...\",\n errorText = \"An error occurred. Try again.\",\n disabled: disabledProp = false,\n disabledText = \"Generating...\",\n commands = [],\n files = [],\n getSuggestions: customGetSuggestions,\n maxSuggestions = 5,\n enableHistory = true,\n model,\n showDividers = true,\n autoFocus = false,\n useKeyboard: useKeyboardProp,\n children,\n}: PromptInputProps) {\n const theme = useTheme()\n const useKeyboard = useKeyboardContext(useKeyboardProp)\n\n // Auto-focus: ensure the canvas has DOM focus so keyboard events reach useKeyboard\n useEffect(() => {\n if (!autoFocus) return\n if (typeof document === \"undefined\") return\n const canvas = document.querySelector(\"canvas\")\n if (canvas && document.activeElement !== canvas) {\n canvas.focus()\n }\n }, [autoFocus])\n const resolvedPromptColor = promptColor ?? theme.muted\n\n // Status-driven state\n const disabled = status ? status === \"submitted\" || status === \"streaming\" : disabledProp\n const statusHintText = resolveStatusHintText(status, submittedText, streamingLabel, errorText, disabledText)\n\n // ── Dual-mode state: provider-managed or self-managed ──────────────────\n const controller = useOptionalController()\n const usingProvider = !!controller\n\n const isControlled = controlledValue !== undefined\n const controlledRef = useRef(isControlled)\n if (controlledRef.current !== isControlled) {\n console.warn(\"PromptInput: switching between controlled and uncontrolled is not supported.\")\n }\n\n // Local state (used when no provider and not controlled)\n const [localValue, setLocalValue] = useState(defaultValue)\n const [localSuggestions, setLocalSuggestions] = useState([])\n const [localSugIdx, setLocalSugIdx] = useState(0)\n const [history, setHistory] = useState([])\n const [histIdx, setHistIdx] = useState(-1)\n\n // Resolve value from: controlled prop > provider > local\n const value = isControlled\n ? controlledValue\n : usingProvider\n ? controller.textInput.value\n : localValue\n\n const suggestions = usingProvider ? controller.suggestions.suggestions : localSuggestions\n const sugIdx = usingProvider ? controller.suggestions.selectedIndex : localSugIdx\n\n // ── State updaters (unified across modes) ──────────────────────────────\n\n const valueRef = useRef(defaultValue)\n if (isControlled) valueRef.current = controlledValue\n else if (usingProvider) valueRef.current = controller.textInput.value\n else valueRef.current = localValue\n\n const suggestionsRef = useRef([])\n suggestionsRef.current = suggestions\n const sugIdxRef = useRef(0)\n sugIdxRef.current = sugIdx\n const historyRef = useRef([])\n historyRef.current = history\n const histIdxRef = useRef(-1)\n histIdxRef.current = histIdx\n\n const setSug = useCallback((next: Suggestion[]) => {\n suggestionsRef.current = next\n if (usingProvider) {\n controller.suggestions.setSuggestions(next)\n } else {\n setLocalSuggestions(next)\n }\n }, [usingProvider, controller])\n\n const setSugI = useCallback((next: number) => {\n sugIdxRef.current = next\n if (usingProvider) {\n controller.suggestions.setSelectedIndex(next)\n } else {\n setLocalSugIdx(next)\n }\n }, [usingProvider, controller])\n\n const setHist = useCallback((next: string[]) => {\n historyRef.current = next\n setHistory(next)\n }, [])\n\n const setHistI = useCallback((next: number) => {\n histIdxRef.current = next\n setHistIdx(next)\n }, [])\n\n const computeSuggestions = useCallback((input: string): Suggestion[] => {\n if (customGetSuggestions) return customGetSuggestions(input)\n return computeDefaultSuggestions(input, commands, files)\n }, [customGetSuggestions, commands, files])\n\n const updateValue = useCallback((next: string) => {\n valueRef.current = next\n if (isControlled) {\n // controlled: only fire onChange, parent owns state\n } else if (usingProvider) {\n controller.textInput.setValue(next)\n } else {\n setLocalValue(next)\n }\n onChange?.(next)\n const sug = computeSuggestions(next)\n setSug(sug)\n setSugI(0)\n }, [isControlled, usingProvider, controller, onChange, computeSuggestions, setSug, setSugI])\n\n // ── Submit handler (auto-clears on success, preserves on error) ────────\n\n const clearInput = useCallback(() => {\n if (usingProvider) {\n controller.textInput.clear()\n } else if (!isControlled) {\n setLocalValue(\"\")\n }\n onChange?.(\"\")\n }, [usingProvider, controller, isControlled, onChange])\n\n const handleSubmit = useCallback((text: string) => {\n if (!onSubmit) return\n\n const result = onSubmit({ text })\n\n // Handle async onSubmit: clear on resolve, preserve on reject\n if (result instanceof Promise) {\n result.then(\n () => clearInput(),\n () => { /* Don't clear on error — user may want to retry */ },\n )\n } else {\n // Sync onSubmit completed without throwing — clear\n clearInput()\n }\n }, [onSubmit, clearInput])\n\n // ── Keyboard handler ───────────────────────────────────────────────────\n\n useKeyboard?.((event: any) => {\n // Escape during submitted/streaming calls onStop\n if (event.name === \"escape\" && (status === \"streaming\" || status === \"submitted\") && onStop) {\n onStop()\n return\n }\n\n if (disabled) return\n\n if (event.name === \"return\") {\n if (suggestionsRef.current.length > 0) {\n const sel = suggestionsRef.current[sugIdxRef.current]\n if (sel) {\n if (valueRef.current.startsWith(\"/\")) {\n // Slash commands: submit immediately on selection\n setSug([])\n updateValue(\"\")\n if (enableHistory) {\n setHist([sel.text, ...historyRef.current])\n }\n setHistI(-1)\n handleSubmit(sel.text)\n } else {\n const base = valueRef.current.slice(0, valueRef.current.lastIndexOf(\"@\"))\n updateValue(base + sel.text + \" \")\n setSug([])\n }\n }\n } else {\n const trimmed = valueRef.current.trim()\n if (!trimmed) return\n if (enableHistory) {\n setHist([trimmed, ...historyRef.current])\n }\n updateValue(\"\")\n setHistI(-1)\n setSug([])\n handleSubmit(trimmed)\n }\n return\n }\n\n if (event.name === \"tab\" && suggestionsRef.current.length > 0) {\n setSugI((sugIdxRef.current + 1) % suggestionsRef.current.length)\n return\n }\n\n if (event.name === \"up\") {\n if (suggestionsRef.current.length > 0) {\n setSugI(Math.max(0, sugIdxRef.current - 1))\n } else if (enableHistory && historyRef.current.length > 0) {\n const idx = Math.min(historyRef.current.length - 1, histIdxRef.current + 1)\n setHistI(idx)\n updateValue(historyRef.current[idx]!)\n }\n return\n }\n\n if (event.name === \"down\") {\n if (suggestionsRef.current.length > 0) {\n setSugI(Math.min(suggestionsRef.current.length - 1, sugIdxRef.current + 1))\n } else if (enableHistory && histIdxRef.current > 0) {\n const nextIdx = histIdxRef.current - 1\n setHistI(nextIdx)\n updateValue(historyRef.current[nextIdx]!)\n } else if (enableHistory && histIdxRef.current === 0) {\n setHistI(-1)\n updateValue(\"\")\n }\n return\n }\n\n if (event.name === \"escape\") {\n if (suggestionsRef.current.length > 0) {\n setSug([])\n }\n return\n }\n\n // Character-level input fallback (used when intrinsic is not available, e.g. in tests)\n if (event.name === \"backspace\" || event.name === \"delete\") {\n updateValue(valueRef.current.slice(0, -1))\n return\n }\n\n if (event.ctrl || event.meta) return\n\n if (event.name === \"space\") {\n updateValue(valueRef.current + \" \")\n return\n }\n\n if (event.name && event.name.length === 1) {\n updateValue(valueRef.current + event.name)\n }\n })\n\n // ── Build context for subcomponents ────────────────────────────────────\n\n const visibleSuggestions = suggestions.slice(0, maxSuggestions)\n\n const ctxValue: PromptInputContextValue = {\n value,\n disabled,\n status,\n onStop,\n statusHintText,\n placeholder,\n prompt,\n promptColor: resolvedPromptColor,\n suggestions: visibleSuggestions,\n sugIdx,\n maxSuggestions,\n errorText,\n model,\n theme,\n }\n\n // ── Render ─────────────────────────────────────────────────────────────\n\n if (children) {\n return (\n \n \n {children}\n \n \n )\n }\n\n return (\n \n \n {showDividers && }\n \n \n \n \n \n \n {showDividers && }\n \n \n )\n}\n\n// ── Attach subcomponents ─────────────────────────────────────────────────\n\nPromptInput.Textarea = PromptInputTextarea\nPromptInput.Suggestions = PromptInputSuggestions\nPromptInput.Submit = PromptInputSubmit\nPromptInput.Divider = PromptInputDivider\nPromptInput.StatusText = PromptInputStatusText\nPromptInput.Model = PromptInputModel\n" + "content": "import {\n useState,\n useRef,\n useCallback,\n useEffect,\n useMemo,\n createContext,\n useContext,\n type ReactNode,\n type PropsWithChildren,\n} from \"react\"\nimport { textStyle } from \"./text-style\"\nimport { useTheme } from \"./theme\"\nimport { useKeyboardContext } from \"./provider\"\n\n/** Chat lifecycle status. Compatible with any AI SDK. */\nexport type ChatStatus = \"ready\" | \"submitted\" | \"streaming\" | \"error\"\n\nexport interface Suggestion {\n text: string\n desc?: string\n}\n\n/** Message shape passed to onSubmit. */\nexport interface PromptInputMessage {\n text: string\n}\n\n// ============================================================================\n// Provider (lifted state)\n// ============================================================================\n\nexport interface TextInputContext {\n value: string\n setValue: (v: string) => void\n clear: () => void\n}\n\nexport interface SuggestionsContext {\n suggestions: Suggestion[]\n selectedIndex: number\n setSuggestions: (s: Suggestion[]) => void\n setSelectedIndex: (i: number) => void\n clear: () => void\n}\n\ninterface PromptInputControllerProps {\n textInput: TextInputContext\n suggestions: SuggestionsContext\n}\n\nconst PromptInputControllerCtx = createContext(null)\n\n/** Access lifted PromptInput state from outside the component. Requires ``. */\nexport function usePromptInputController(): PromptInputControllerProps {\n const ctx = useContext(PromptInputControllerCtx)\n if (!ctx) {\n throw new Error(\"Wrap your component inside to use usePromptInputController().\")\n }\n return ctx\n}\n\nconst useOptionalController = () => useContext(PromptInputControllerCtx)\n\nexport type PromptInputProviderProps = PropsWithChildren<{\n initialInput?: string\n}>\n\n/**\n * Optional provider that lifts PromptInput state outside of PromptInput.\n * Without it, PromptInput stays fully self-managed.\n */\nexport function PromptInputProvider({ initialInput = \"\", children }: PromptInputProviderProps) {\n const [value, setValueState] = useState(initialInput)\n const clearInput = useCallback(() => setValueState(\"\"), [])\n\n const [suggestions, setSuggestions] = useState([])\n const [selectedIndex, setSelectedIndex] = useState(0)\n const clearSuggestions = useCallback(() => {\n setSuggestions([])\n setSelectedIndex(0)\n }, [])\n\n const controller = useMemo(\n () => ({\n textInput: { value, setValue: setValueState, clear: clearInput },\n suggestions: {\n suggestions,\n selectedIndex,\n setSuggestions,\n setSelectedIndex,\n clear: clearSuggestions,\n },\n }),\n [value, clearInput, suggestions, selectedIndex, clearSuggestions],\n )\n\n return (\n \n {children}\n \n )\n}\n\n// ============================================================================\n// Component Context (rendering state for subcomponents)\n// ============================================================================\n\nexport interface PromptInputContextValue {\n value: string\n disabled: boolean\n status?: ChatStatus\n onStop?: () => void\n statusHintText: string\n placeholder: string\n prompt: string\n promptColor: string\n suggestions: Suggestion[]\n sugIdx: number\n maxSuggestions: number\n errorText: string\n model?: string\n dividerColor?: string\n dividerDashed?: boolean\n theme: ReturnType\n}\n\nconst PromptInputContext = createContext(null)\n\n/** Hook for accessing PromptInput state from compound subcomponents. */\nexport function usePromptInput(): PromptInputContextValue {\n const ctx = useContext(PromptInputContext)\n if (!ctx) {\n throw new Error(\"usePromptInput must be used within a component\")\n }\n return ctx\n}\n\n// ============================================================================\n// Props\n// ============================================================================\n\nexport interface PromptInputProps {\n /** Controlled input value */\n value?: string\n /** Default value for uncontrolled mode */\n defaultValue?: string\n /**\n * Called when user submits a message.\n * Receives `{ text }`. Compatible with Vercel AI SDK's `sendMessage` and\n * easily mapped to any other SDK. If the handler returns a Promise, input\n * is cleared on resolve and preserved on reject so the user can retry.\n */\n onSubmit?: (message: PromptInputMessage) => void | Promise\n /** Callback when input value changes */\n onChange?: (text: string) => void\n /** Placeholder text when input is empty */\n placeholder?: string\n /** Prompt character shown before input */\n prompt?: string\n /** Color of the prompt character */\n promptColor?: string\n /**\n * AI chat status — drives disabled state and status indicator.\n * When provided, takes precedence over `disabled`/`disabledText`.\n */\n status?: ChatStatus\n /** Called when user presses Escape during streaming to stop generation */\n onStop?: () => void\n /** Text shown when status is \"submitted\" */\n submittedText?: string\n /** Text shown when status is \"streaming\" */\n streamingText?: string\n /** Text shown when status is \"error\" */\n errorText?: string\n /** Disable input. Ignored when `status` is provided. */\n disabled?: boolean\n /** Text shown when disabled. Ignored when `status` is provided. */\n disabledText?: string\n /** Slash commands for autocomplete */\n commands?: { cmd: string; desc?: string }[]\n /** File paths for @ mention autocomplete */\n files?: string[]\n /** Custom suggestion provider — overrides commands/files */\n getSuggestions?: (value: string) => Suggestion[]\n /** Max visible suggestions */\n maxSuggestions?: number\n /** Enable command history with up/down arrows */\n enableHistory?: boolean\n /** Model name displayed below the input */\n model?: string\n /** Show horizontal dividers above and below the input */\n showDividers?: boolean\n /** Auto-focus the input on mount (ensures canvas has keyboard focus in the browser) */\n autoFocus?: boolean\n /** Override divider line color (e.g. for focus indicators) */\n dividerColor?: string\n /** Use dashed divider lines (╌) instead of solid (─) */\n dividerDashed?: boolean\n /** Keyboard hook from @opentui/react */\n useKeyboard?: (handler: (event: any) => void) => void\n /** Compound mode: provide subcomponents as children */\n children?: ReactNode\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction computeDefaultSuggestions(\n input: string,\n commands: { cmd: string; desc?: string }[],\n files: string[],\n): Suggestion[] {\n if (input.startsWith(\"/\") && commands.length > 0) {\n return commands\n .filter((c) => c.cmd.startsWith(input))\n .map((c) => ({ text: c.cmd, desc: c.desc }))\n }\n if (input.includes(\"@\") && files.length > 0) {\n const query = input.split(\"@\").pop() ?? \"\"\n return files\n .filter((f) => f.toLowerCase().includes(query.toLowerCase()))\n .map((f) => ({ text: \"@\" + f }))\n }\n return []\n}\n\nfunction resolveStatusHintText(\n status: ChatStatus | undefined,\n submittedText: string,\n streamingText: string,\n errorText: string,\n disabledText: string,\n): string {\n if (status === \"submitted\") return submittedText\n if (status === \"streaming\") return streamingText\n if (status === \"error\") return errorText\n return disabledText\n}\n\n// ============================================================================\n// Subcomponents\n// ============================================================================\n\n/** Horizontal divider line that extends into parent border gutters. */\nfunction PromptInputDivider() {\n const { dividerColor, dividerDashed, theme } = usePromptInput()\n const color = dividerColor ?? theme.muted\n const char = dividerDashed ? \"╌\" : \"─\"\n return (\n \n {char.repeat(500)}\n \n )\n}\n\n/** Autocomplete suggestion list. */\nfunction PromptInputSuggestions() {\n const { suggestions, sugIdx, maxSuggestions, theme } = usePromptInput()\n const visible = suggestions.slice(0, maxSuggestions)\n if (visible.length === 0) return null\n return (\n \n {visible.map((sug, i) => {\n const active = i === sugIdx\n return (\n \n \n {active ? \"▸ \" : \" \"}\n \n \n {sug.text}\n \n {sug.desc && (\n {\" \" + sug.desc}\n )}\n \n )\n })}\n \n )\n}\n\nconst CURSOR_CHAR = \"\\u258D\"\n\n/** Prompt char + text with syntax highlighting + cursor. */\nfunction PromptInputTextarea() {\n const { value, disabled, statusHintText, placeholder, prompt, promptColor, theme } = usePromptInput()\n return (\n \n {prompt}\n {value.length === 0 ? (\n <>\n {!disabled && {CURSOR_CHAR}}\n {disabled ? statusHintText : \" \" + placeholder}\n \n ) : (\n <>\n {value}\n {!disabled && {CURSOR_CHAR}}\n \n )}\n \n )\n}\n\n/**\n * Status indicator: ⏎ ready, ◐ submitted, ■ streaming, ✕ error.\n * When `status` and `onStop` are provided via context, the streaming icon\n * doubles as a stop button (Escape triggers onStop).\n */\nfunction PromptInputSubmit(props: { status?: ChatStatus; onStop?: () => void }) {\n const ctx = usePromptInput()\n const status = props.status ?? ctx.status\n const onStop = props.onStop ?? ctx.onStop\n const { disabled, theme } = ctx\n\n const isGenerating = status === \"submitted\" || status === \"streaming\"\n\n const icon =\n status === \"submitted\" ? \"◐\"\n : status === \"streaming\" ? (onStop ? \"■\" : \"◐\")\n : status === \"error\" ? \"✕\"\n : \"⏎\"\n\n const color =\n status === \"error\" ? theme.error\n : isGenerating ? theme.muted\n : disabled ? theme.muted\n : theme.primary\n\n return (\n \n {\" \" + icon}\n \n )\n}\n\n/** Error/hint text below input. */\nfunction PromptInputStatusText() {\n const { status, errorText, theme } = usePromptInput()\n if (status !== \"error\") return null\n return (\n \n {errorText}\n \n )\n}\n\n/** Model label shown below the input. */\nfunction PromptInputModel() {\n const { model, theme } = usePromptInput()\n if (!model) return null\n return (\n model: {model}\n )\n}\n\n// ============================================================================\n// Root component\n// ============================================================================\n\nexport function PromptInput({\n value: controlledValue,\n defaultValue = \"\",\n onSubmit,\n onChange,\n placeholder = \"Type a message...\",\n prompt = \"❯ \",\n promptColor,\n status,\n onStop,\n submittedText = \"Thinking...\",\n streamingText: streamingLabel = \"Generating...\",\n errorText = \"An error occurred. Try again.\",\n disabled: disabledProp = false,\n disabledText = \"Generating...\",\n commands = [],\n files = [],\n getSuggestions: customGetSuggestions,\n maxSuggestions = 5,\n enableHistory = true,\n model,\n showDividers = true,\n autoFocus = false,\n dividerColor,\n dividerDashed,\n useKeyboard: useKeyboardProp,\n children,\n}: PromptInputProps) {\n const theme = useTheme()\n const useKeyboard = useKeyboardContext(useKeyboardProp)\n\n // Auto-focus: ensure the canvas has DOM focus so keyboard events reach useKeyboard\n useEffect(() => {\n if (!autoFocus) return\n if (typeof document === \"undefined\") return\n const canvas = document.querySelector(\"canvas\")\n if (canvas && document.activeElement !== canvas) {\n canvas.focus()\n }\n }, [autoFocus])\n const resolvedPromptColor = promptColor ?? theme.muted\n\n // Status-driven state\n const disabled = status ? status === \"submitted\" || status === \"streaming\" : disabledProp\n const statusHintText = resolveStatusHintText(status, submittedText, streamingLabel, errorText, disabledText)\n\n // ── Dual-mode state: provider-managed or self-managed ──────────────────\n const controller = useOptionalController()\n const usingProvider = !!controller\n\n const isControlled = controlledValue !== undefined\n const controlledRef = useRef(isControlled)\n if (controlledRef.current !== isControlled) {\n console.warn(\"PromptInput: switching between controlled and uncontrolled is not supported.\")\n }\n\n // Local state (used when no provider and not controlled)\n const [localValue, setLocalValue] = useState(defaultValue)\n const [localSuggestions, setLocalSuggestions] = useState([])\n const [localSugIdx, setLocalSugIdx] = useState(0)\n const [history, setHistory] = useState([])\n const [histIdx, setHistIdx] = useState(-1)\n\n // Resolve value from: controlled prop > provider > local\n const value = isControlled\n ? controlledValue\n : usingProvider\n ? controller.textInput.value\n : localValue\n\n const suggestions = usingProvider ? controller.suggestions.suggestions : localSuggestions\n const sugIdx = usingProvider ? controller.suggestions.selectedIndex : localSugIdx\n\n // ── State updaters (unified across modes) ──────────────────────────────\n\n const valueRef = useRef(defaultValue)\n if (isControlled) valueRef.current = controlledValue\n else if (usingProvider) valueRef.current = controller.textInput.value\n else valueRef.current = localValue\n\n const suggestionsRef = useRef([])\n suggestionsRef.current = suggestions\n const sugIdxRef = useRef(0)\n sugIdxRef.current = sugIdx\n const historyRef = useRef([])\n historyRef.current = history\n const histIdxRef = useRef(-1)\n histIdxRef.current = histIdx\n\n const setSug = useCallback((next: Suggestion[]) => {\n suggestionsRef.current = next\n if (usingProvider) {\n controller.suggestions.setSuggestions(next)\n } else {\n setLocalSuggestions(next)\n }\n }, [usingProvider, controller])\n\n const setSugI = useCallback((next: number) => {\n sugIdxRef.current = next\n if (usingProvider) {\n controller.suggestions.setSelectedIndex(next)\n } else {\n setLocalSugIdx(next)\n }\n }, [usingProvider, controller])\n\n const setHist = useCallback((next: string[]) => {\n historyRef.current = next\n setHistory(next)\n }, [])\n\n const setHistI = useCallback((next: number) => {\n histIdxRef.current = next\n setHistIdx(next)\n }, [])\n\n const computeSuggestions = useCallback((input: string): Suggestion[] => {\n if (customGetSuggestions) return customGetSuggestions(input)\n return computeDefaultSuggestions(input, commands, files)\n }, [customGetSuggestions, commands, files])\n\n const updateValue = useCallback((next: string) => {\n valueRef.current = next\n if (isControlled) {\n // controlled: only fire onChange, parent owns state\n } else if (usingProvider) {\n controller.textInput.setValue(next)\n } else {\n setLocalValue(next)\n }\n onChange?.(next)\n const sug = computeSuggestions(next)\n setSug(sug)\n setSugI(0)\n }, [isControlled, usingProvider, controller, onChange, computeSuggestions, setSug, setSugI])\n\n // ── Submit handler (auto-clears on success, preserves on error) ────────\n\n const clearInput = useCallback(() => {\n if (usingProvider) {\n controller.textInput.clear()\n } else if (!isControlled) {\n setLocalValue(\"\")\n }\n onChange?.(\"\")\n }, [usingProvider, controller, isControlled, onChange])\n\n const handleSubmit = useCallback((text: string) => {\n if (!onSubmit) return\n\n const result = onSubmit({ text })\n\n // Handle async onSubmit: clear on resolve, preserve on reject\n if (result instanceof Promise) {\n result.then(\n () => clearInput(),\n () => { /* Don't clear on error — user may want to retry */ },\n )\n } else {\n // Sync onSubmit completed without throwing — clear\n clearInput()\n }\n }, [onSubmit, clearInput])\n\n // ── Keyboard handler ───────────────────────────────────────────────────\n\n useKeyboard?.((event: any) => {\n // Escape during submitted/streaming calls onStop\n if (event.name === \"escape\" && (status === \"streaming\" || status === \"submitted\") && onStop) {\n onStop()\n return\n }\n\n if (disabled) return\n\n if (event.name === \"return\") {\n if (suggestionsRef.current.length > 0) {\n const sel = suggestionsRef.current[sugIdxRef.current]\n if (sel) {\n if (valueRef.current.startsWith(\"/\")) {\n // Slash commands: submit immediately on selection\n setSug([])\n updateValue(\"\")\n if (enableHistory) {\n setHist([sel.text, ...historyRef.current])\n }\n setHistI(-1)\n handleSubmit(sel.text)\n } else {\n const base = valueRef.current.slice(0, valueRef.current.lastIndexOf(\"@\"))\n updateValue(base + sel.text + \" \")\n setSug([])\n }\n }\n } else {\n const trimmed = valueRef.current.trim()\n if (!trimmed) return\n if (enableHistory) {\n setHist([trimmed, ...historyRef.current])\n }\n updateValue(\"\")\n setHistI(-1)\n setSug([])\n handleSubmit(trimmed)\n }\n return\n }\n\n if (event.name === \"tab\" && suggestionsRef.current.length > 0) {\n setSugI((sugIdxRef.current + 1) % suggestionsRef.current.length)\n return\n }\n\n if (event.name === \"up\") {\n if (suggestionsRef.current.length > 0) {\n setSugI(Math.max(0, sugIdxRef.current - 1))\n } else if (enableHistory && historyRef.current.length > 0) {\n const idx = Math.min(historyRef.current.length - 1, histIdxRef.current + 1)\n setHistI(idx)\n updateValue(historyRef.current[idx]!)\n }\n return\n }\n\n if (event.name === \"down\") {\n if (suggestionsRef.current.length > 0) {\n setSugI(Math.min(suggestionsRef.current.length - 1, sugIdxRef.current + 1))\n } else if (enableHistory && histIdxRef.current > 0) {\n const nextIdx = histIdxRef.current - 1\n setHistI(nextIdx)\n updateValue(historyRef.current[nextIdx]!)\n } else if (enableHistory && histIdxRef.current === 0) {\n setHistI(-1)\n updateValue(\"\")\n }\n return\n }\n\n if (event.name === \"escape\") {\n if (suggestionsRef.current.length > 0) {\n setSug([])\n }\n return\n }\n\n // Character-level input fallback (used when intrinsic is not available, e.g. in tests)\n if (event.name === \"backspace\" || event.name === \"delete\") {\n updateValue(valueRef.current.slice(0, -1))\n return\n }\n\n if (event.ctrl || event.meta) return\n\n if (event.name === \"space\") {\n updateValue(valueRef.current + \" \")\n return\n }\n\n if (event.name && event.name.length === 1) {\n updateValue(valueRef.current + event.name)\n }\n })\n\n // ── Build context for subcomponents ────────────────────────────────────\n\n const visibleSuggestions = suggestions.slice(0, maxSuggestions)\n\n const ctxValue: PromptInputContextValue = {\n value,\n disabled,\n status,\n onStop,\n statusHintText,\n placeholder,\n prompt,\n promptColor: resolvedPromptColor,\n suggestions: visibleSuggestions,\n sugIdx,\n maxSuggestions,\n errorText,\n model,\n dividerColor,\n dividerDashed,\n theme,\n }\n\n // ── Render ─────────────────────────────────────────────────────────────\n\n if (children) {\n return (\n \n \n {children}\n \n \n )\n }\n\n return (\n \n \n {showDividers && }\n \n \n \n \n \n \n {showDividers && }\n \n \n )\n}\n\n// ── Attach subcomponents ─────────────────────────────────────────────────\n\nPromptInput.Textarea = PromptInputTextarea\nPromptInput.Suggestions = PromptInputSuggestions\nPromptInput.Submit = PromptInputSubmit\nPromptInput.Divider = PromptInputDivider\nPromptInput.StatusText = PromptInputStatusText\nPromptInput.Model = PromptInputModel\n" } ] } diff --git a/packages/ui/registry/ui/prompt-input.tsx b/packages/ui/registry/ui/prompt-input.tsx index c2950e0b..0333fd15 100644 --- a/packages/ui/registry/ui/prompt-input.tsx +++ b/packages/ui/registry/ui/prompt-input.tsx @@ -120,6 +120,8 @@ export interface PromptInputContextValue { maxSuggestions: number errorText: string model?: string + dividerColor?: string + dividerDashed?: boolean theme: ReturnType } @@ -191,6 +193,10 @@ export interface PromptInputProps { showDividers?: boolean /** Auto-focus the input on mount (ensures canvas has keyboard focus in the browser) */ autoFocus?: boolean + /** Override divider line color (e.g. for focus indicators) */ + dividerColor?: string + /** Use dashed divider lines (╌) instead of solid (─) */ + dividerDashed?: boolean /** Keyboard hook from @opentui/react */ useKeyboard?: (handler: (event: any) => void) => void /** Compound mode: provide subcomponents as children */ @@ -237,14 +243,14 @@ function resolveStatusHintText( // Subcomponents // ============================================================================ -const DIVIDER_LINE = "─".repeat(500) - /** Horizontal divider line that extends into parent border gutters. */ function PromptInputDivider() { - const { theme } = usePromptInput() + const { dividerColor, dividerDashed, theme } = usePromptInput() + const color = dividerColor ?? theme.muted + const char = dividerDashed ? "╌" : "─" return ( - {DIVIDER_LINE} + {char.repeat(500)} ) } @@ -378,6 +384,8 @@ export function PromptInput({ model, showDividers = true, autoFocus = false, + dividerColor, + dividerDashed, useKeyboard: useKeyboardProp, children, }: PromptInputProps) { @@ -635,6 +643,8 @@ export function PromptInput({ maxSuggestions, errorText, model, + dividerColor, + dividerDashed, theme, } diff --git a/packages/web/src/TUI.tsx b/packages/web/src/TUI.tsx index d576bd61..eeb1421e 100644 --- a/packages/web/src/TUI.tsx +++ b/packages/web/src/TUI.tsx @@ -101,7 +101,7 @@ export function TUI({ const cols = Math.max(1, Math.floor(containerRect.width / cellSize.width)) const rows = Math.max(1, Math.floor(containerRect.height / cellSize.height)) - const renderer = new BrowserRenderer(canvas, cols, rows, { backgroundColor }) + const renderer = new BrowserRenderer(canvas, cols, rows, { backgroundColor, fontSize, fontFamily }) renderer.renderContext.cursorHighlight = cursorHighlight if (cursorHighlightColor) renderer.renderContext.cursorHighlightColor = cursorHighlightColor renderer.renderContext.cursorHighlightOpacity = cursorHighlightOpacity diff --git a/packages/web/src/browser-renderer.ts b/packages/web/src/browser-renderer.ts index dcb68da4..384367f8 100644 --- a/packages/web/src/browser-renderer.ts +++ b/packages/web/src/browser-renderer.ts @@ -38,7 +38,7 @@ export class BrowserRenderer { public mouseCell: { col: number; row: number } | null = null private backgroundColor: string | null = null - constructor(canvas: HTMLCanvasElement, cols: number, rows: number, options?: { backgroundColor?: string }) { + constructor(canvas: HTMLCanvasElement, cols: number, rows: number, options?: { backgroundColor?: string; fontSize?: number; fontFamily?: string }) { this.canvas = canvas this.cols = cols this.rows = rows @@ -48,7 +48,7 @@ export class BrowserRenderer { this.ctx2d = ctx2d // Measure cell size - this.painter = new CanvasPainter() + this.painter = new CanvasPainter({ fontSize: options?.fontSize, fontFamily: options?.fontFamily }) const cellSize = this.painter.measureCell(this.ctx2d) this.cellWidth = cellSize.width this.cellHeight = cellSize.height