// Prompt textarea component and its state machine for direct interactive mode. // // createPromptState() wires keybinds, history navigation, leader-key sequences, // and `@` autocomplete for files, subagents, and MCP resources. // It produces a PromptState that RunPromptBody renders as an OpenTUI textarea, // while the footer view renders the current menu state below it. /** @jsxImportSource @opentui/solid */ import { pathToFileURL } from "bun" import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable, } from "@opentui/core" import { useKeyboard, useRenderer } from "@opentui/solid" import fuzzysort from "fuzzysort" import path from "path" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import * as Locale from "@/util/locale" import { createPromptHistory, displayCharAt, displaySlice, isExitCommand, mentionTriggerIndex, isNewCommand, movePromptHistory, promptCycle, promptHit, promptInfo, promptKeys, pushPromptHistory, } from "./prompt.shared" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" import type { FooterKeybinds, FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource } from "./types" const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS const AUTOCOMPLETE_BOTTOM_ROWS = 1 export const TEXTAREA_MIN_ROWS = 1 export const TEXTAREA_MAX_ROWS = 6 export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS export const HINT_BREAKPOINTS = { send: 50, newline: 66, history: 80, command: 95, } type Mention = Extract type Auto = RunFooterMenuItem & { kind: "mention" value: string part: Mention directory?: boolean } type SlashOption = RunFooterMenuItem & { kind: "slash" name: string } type PromptOption = Auto | SlashOption type MenuMode = false | "mention" | "slash" type PromptInput = { directory: string findFiles: (query: string) => Promise agents: Accessor resources: Accessor commands: Accessor keybinds: FooterKeybinds state: Accessor view: Accessor prompt: Accessor width: Accessor theme: Accessor history?: RunPrompt[] onSubmit: (input: RunPrompt) => boolean | Promise onCycle: () => void onInterrupt: () => boolean onInputClear: () => void onExitRequest?: () => boolean onExit: () => void onRows: (rows: number) => void onStatus: (text: string) => void } export type PromptState = { placeholder: Accessor bindings: Accessor visible: Accessor options: Accessor selected: Accessor offset: Accessor rows: Accessor requestExit: () => boolean onSubmit: () => void submitText: (text: string) => void onKeyDown: (event: KeyEvent) => void onContentChange: () => void replaceDraft: (text: string) => void bind: (area?: TextareaRenderable) => void } function clamp(rows: number): number { return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows)) } function clonePrompt(prompt: RunPrompt): RunPrompt { return { text: prompt.text, parts: structuredClone(prompt.parts), } } function removeLineRange(input: string) { const hash = input.lastIndexOf("#") return hash === -1 ? input : input.slice(0, hash) } function extractLineRange(input: string) { const hash = input.lastIndexOf("#") if (hash === -1) { return { base: input } } const base = input.slice(0, hash) const line = input.slice(hash + 1) const match = line.match(/^(\d+)(?:-(\d*))?$/) if (!match) { return { base } } const start = Number(match[1]) const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined return { base, line: { start, end } } } function slashHead(text: string) { if (!text.startsWith("/")) { return } for (let i = 1; i < text.length; i++) { switch (text[i]) { case " ": case "\t": case "\n": return { name: text.slice(1, i), arguments: text.slice(i + 1), end: i } } } return { name: text.slice(1), arguments: "", end: text.length } } function slashQuery(text: string, cursor: number) { const head = slashHead(text.slice(0, cursor)) if (!head || head.end !== cursor) { return } return head.name } function parseSlashCommand(text: string, commands: RunCommand[] | undefined) { const head = slashHead(text) if (!head || head.name.length === 0) { return { type: "none" as const } } if (!commands) { return { type: "pending" as const } } if (!commands.some((item) => item.name === head.name)) { return { type: "none" as const } } return { type: "command" as const, command: { name: head.name, arguments: head.arguments } } } export function hintFlags(width: number) { return { send: width >= HINT_BREAKPOINTS.send, newline: width >= HINT_BREAKPOINTS.newline, history: width >= HINT_BREAKPOINTS.history, command: width >= HINT_BREAKPOINTS.command, } } export function RunPromptBody(props: { theme: () => RunFooterTheme placeholder: () => StyledText | string bindings: () => KeyBinding[] onSubmit: () => void onKeyDown: (event: KeyEvent) => void onContentChange: () => void bind: (area?: TextareaRenderable) => void }) { const renderer = useRenderer() let area: TextareaRenderable | undefined let pasteTick: ReturnType | undefined const refreshPasteLayout = () => { if (pasteTick) { clearTimeout(pasteTick) } pasteTick = setTimeout(() => { pasteTick = undefined if (!area || area.isDestroyed) { return } // Paste can leave the textarea layout stale until the next edit. area.getLayoutNode().markDirty() renderer.requestRender() void renderer .idle() .then(() => { if (!area || area.isDestroyed) { return } props.onContentChange() }) .catch(() => {}) }, 0) } onMount(() => { props.bind(area) }) onCleanup(() => { if (pasteTick) { clearTimeout(pasteTick) } props.bind(undefined) }) return (