// Top-level footer layout for direct interactive mode. // // Renders the footer region as a vertical stack: // 1. Spacer row (visual separation from scrollback) // 2. Composer frame with left-border accent -- swaps between prompt, // permission, and question bodies via Switch/Match // 3. Meta row showing agent name and model label in the normal composer view // 4. Bottom border + status row (spinner, interrupt hint, duration, usage) // // All state comes from the parent RunFooter through SolidJS signals. // The view itself is stateless except for derived memos. /** @jsxImportSource @opentui/solid */ import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import "opentui-spinner/solid" import { createColors, createFrames } from "../tui/ui/spinner" import { RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "./footer.command" import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu" import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent" import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt" import { RunPermissionBody } from "./footer.permission" import { RunQuestionBody } from "./footer.question" import { printableBinding, promptBindings, promptHit, promptInfo } from "./prompt.shared" import type { FooterKeybinds, FooterPromptRoute, FooterState, FooterSubagentState, FooterView, PermissionReply, QuestionReject, QuestionReply, RunAgent, RunCommand, RunDiffStyle, RunInput, RunPrompt, RunProvider, RunResource, } from "./types" import { RUN_THEME_FALLBACK, type RunTheme } from "./theme" const EMPTY_BORDER = { topLeft: "", bottomLeft: "", vertical: "", topRight: "", bottomRight: "", horizontal: " ", bottomT: "", topT: "", cross: "", leftT: "", rightT: "", } type RunFooterViewProps = { directory: string findFiles: (query: string) => Promise agents: () => RunAgent[] resources: () => RunResource[] commands: () => RunCommand[] | undefined providers: () => RunProvider[] | undefined currentModel: () => RunInput["model"] variants: () => string[] currentVariant: () => string | undefined state: () => FooterState view?: () => FooterView subagent?: () => FooterSubagentState theme?: RunTheme diffStyle?: RunDiffStyle keybinds: FooterKeybinds history?: RunPrompt[] agent: string onSubmit: (input: RunPrompt) => boolean onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise onQuestionReject: (input: QuestionReject) => void | Promise onCycle: () => void onInterrupt: () => boolean onInputClear: () => void onExitRequest?: () => boolean onRequestExit?: (fn: (() => boolean) | undefined) => void onExit: () => void onModelSelect: (model: NonNullable) => void onVariantSelect: (variant: string | undefined) => void onRows: (rows: number) => void onLayout: (input: { route: FooterPromptRoute; tabs: boolean; autocomplete: boolean }) => void onStatus: (text: string) => void onSubagentSelect?: (sessionID: string | undefined) => void } function subagentShortcut(event: { name: string ctrl?: boolean meta?: boolean shift?: boolean super?: boolean }): number | undefined { if (!event.ctrl || event.meta || event.super) { return undefined } if (!/^[0-9]$/.test(event.name)) { return undefined } const slot = Number(event.name) return slot === 0 ? 9 : slot - 1 } export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt" export function RunFooterView(props: RunFooterViewProps) { const term = useTerminalDimensions() const active = createMemo(() => props.view?.() ?? { type: "prompt" }) const subagent = createMemo(() => { return ( props.subagent?.() ?? { tabs: [], details: {}, permissions: [], questions: [], } ) }) const [route, setRoute] = createSignal({ type: "composer" }) const prompt = createMemo(() => active().type === "prompt" && route().type === "composer") const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent") const commanding = createMemo(() => active().type === "prompt" && route().type === "command") const modeling = createMemo(() => active().type === "prompt" && route().type === "model") const varianting = createMemo(() => active().type === "prompt" && route().type === "variant") const panel = createMemo(() => commanding() || modeling() || varianting()) const selected = createMemo(() => { const current = route() return current.type === "subagent" ? current.sessionID : undefined }) const tabs = createMemo(() => subagent().tabs) const showTabs = createMemo(() => active().type === "prompt" && tabs().length > 0) const detail = createMemo(() => { const current = route() return current.type === "subagent" ? subagent().details[current.sessionID] : undefined }) const command = createMemo(() => printableBinding(props.keybinds.commandList, props.keybinds.leader)) const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader)) const commandKeys = createMemo(() => promptBindings(props.keybinds.commandList, props.keybinds.leader)) const hints = createMemo(() => hintFlags(term().width)) const busy = createMemo(() => props.state().phase === "running") const armed = createMemo(() => props.state().interrupt > 0) const exiting = createMemo(() => props.state().exit > 0) const queue = createMemo(() => props.state().queue) const duration = createMemo(() => props.state().duration) const usage = createMemo(() => props.state().usage) const interruptKey = createMemo(() => interrupt() || "/exit") const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK) const theme = createMemo(() => runTheme().footer) const block = createMemo(() => runTheme().block) const spin = createMemo(() => { return { frames: createFrames({ color: theme().highlight, style: "blocks", inactiveFactor: 0.6, minAlpha: 0.3, }), color: createColors({ color: theme().highlight, style: "blocks", inactiveFactor: 0.6, minAlpha: 0.3, }), } }) const permission = createMemo | undefined>(() => { const view = active() return view.type === "permission" ? view : undefined }) const question = createMemo | undefined>(() => { const view = active() return view.type === "question" ? view : undefined }) const promptView = createMemo(() => { if (active().type !== "prompt") { return active().type } const current = route() return current.type === "composer" ? "prompt" : current.type }) const openCommand = () => { setRoute({ type: "command" }) props.onSubagentSelect?.(undefined) } const openModel = () => { setRoute({ type: "model" }) props.onSubagentSelect?.(undefined) } const openVariant = () => { setRoute({ type: "variant" }) props.onSubagentSelect?.(undefined) } const closePanel = () => { setRoute({ type: "composer" }) } const openTab = (sessionID: string) => { setRoute({ type: "subagent", sessionID }) props.onSubagentSelect?.(sessionID) } const closeTab = () => { setRoute({ type: "composer" }) props.onSubagentSelect?.(undefined) } const toggleTab = (sessionID: string) => { const current = route() if (current.type === "subagent" && current.sessionID === sessionID) { closeTab() return } openTab(sessionID) } const cycleTab = (dir: -1 | 1) => { if (tabs().length === 0) { return } const routeState = route() const current = routeState.type === "subagent" ? tabs().findIndex((item) => item.sessionID === routeState.sessionID) : -1 const index = current === -1 ? 0 : (current + dir + tabs().length) % tabs().length const next = tabs()[index] if (!next) { return } openTab(next.sessionID) } const composer = createPromptState({ directory: props.directory, findFiles: props.findFiles, agents: props.agents, resources: props.resources, commands: props.commands, keybinds: props.keybinds, state: props.state, view: promptView, prompt, width: () => term().width, theme, history: props.history, onSubmit: props.onSubmit, onCycle: props.onCycle, onInterrupt: props.onInterrupt, onInputClear: props.onInputClear, onExitRequest: props.onExitRequest, onExit: props.onExit, onRows: props.onRows, onStatus: props.onStatus, }) const shell = createMemo(() => prompt() && composer.shell()) const menu = createMemo(() => prompt() && composer.visible()) createEffect(() => { props.onRequestExit?.(composer.requestExit) }) onCleanup(() => { props.onRequestExit?.(undefined) }) useKeyboard((event) => { if (event.defaultPrevented) { return } if (active().type !== "prompt") { return } if (route().type !== "composer") { return } if (composer.visible()) { return } if (!promptHit(commandKeys(), promptInfo(event))) { return } event.preventDefault() openCommand() }) useKeyboard((event) => { if (active().type !== "prompt") { return } const slot = subagentShortcut(event) if (slot !== undefined) { const next = tabs()[slot] if (!next) { return } event.preventDefault() toggleTab(next.sessionID) } }) createEffect(() => { const current = route() if (current.type !== "subagent") { return } if (tabs().some((item) => item.sessionID === current.sessionID)) { return } closeTab() }) createEffect(() => { if (active().type === "prompt") { return } const current = route() if (current.type !== "command" && current.type !== "model" && current.type !== "variant") { return } closePanel() }) createEffect(() => { props.onLayout({ route: route(), tabs: tabs().length > 0, autocomplete: menu(), }) }) return ( { props.onCycle() closePanel() }} onCommand={(name) => { composer.submitText(`/${name}`) closePanel() }} onNew={() => { composer.submitText("/new") closePanel() }} onExit={props.onExit} /> { props.onModelSelect(model) closePanel() }} /> { props.onVariantSelect(variant) closePanel() }} /> {shell() ? "Shell" : props.agent} {props.state().model} } > Press Ctrl-c again to exit {interruptKey()}{" "} {armed() ? "again to interrupt" : "interrupt"} 0}> · {duration()} 0}> {queue()} queued 0}> {usage()} 0 && hints().command}> {command()} commands } > esc exit shell mode } > } > term().width} diffStyle={props.diffStyle} onCycle={cycleTab} onClose={closeTab} /> ) }