/** @jsxImportSource @opentui/solid */ /** * TuiApp - OpenTUI/Solid application shell * DD §13.2. Projection-only display plus single OpenTUI textarea input owner. * * @module packages/tui/src/TuiApp */ import { createCliRenderer, type CliRenderer, type TextareaRenderable, type KeyEvent } from '@opentui/core' import { render, useRenderer, useTerminalDimensions } from '@opentui/solid' import { createEffect, createSignal, For, onCleanup, onMount, Show } from 'solid-js' import type { SessionProjection } from './types.js' export interface TuiAppProps { client: { subscribe(handler: (projection: SessionProjection) => void): () => void get_snapshot(): SessionProjection | null } onSubmit?: (input: string) => void | Promise onSlashCommand?: (input: string) => void | Promise onResolvePermission?: (prompt_id: string, selected_option: string) => void | Promise onExit?: () => void | Promise } export interface TuiAppState { projection: SessionProjection | null active_view: 'tasks' | 'agents' | 'tools' | 'diff' | 'help' busy: boolean status: string } const THEME = { bg: '#0b0f14', surface: '#111827', surface2: '#1f2937', text: '#e5e7eb', muted: '#9ca3af', faint: '#6b7280', accent: '#22d3ee', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', border: '#374151', } const PROMPT_HISTORY_LIMIT = 200 const TEXTAREA_MIN_ROWS = 1 const TEXTAREA_MAX_ROWS = 6 const EXIT_CONFIRM_MS = 5000 type PromptHistoryState = { items: string[] index: number | null draft: string } type PromptHistoryMove = { state: PromptHistoryState apply: boolean text?: string cursor?: number } function createPromptHistory(): PromptHistoryState { return { items: [], index: null, draft: '' } } function pushPromptHistory(state: PromptHistoryState, prompt: string): PromptHistoryState { const text = prompt.trim() if (!text) return state if (state.items[state.items.length - 1] === text) { return { ...state, index: null, draft: '' } } return { items: [...state.items, text].slice(-PROMPT_HISTORY_LIMIT), index: null, draft: '' } } function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptHistoryMove { if (state.items.length === 0) return { state, apply: false } if (dir === -1 && cursor !== 0) return { state, apply: false } if (dir === 1 && cursor !== text.length) return { state, apply: false } if (state.index === null) { if (dir === 1) return { state, apply: false } const idx = state.items.length - 1 return { state: { ...state, index: idx, draft: text }, text: state.items[idx], cursor: 0, apply: true } } const idx = state.index + dir if (idx < 0) return { state, apply: false } if (idx >= state.items.length) { return { state: { ...state, index: null }, text: state.draft, cursor: state.draft.length, apply: true } } return { state: { ...state, index: idx }, text: state.items[idx], cursor: dir === -1 ? 0 : state.items[idx].length, apply: true } } export class TuiApp { private client: TuiAppProps['client'] private onSubmit?: TuiAppProps['onSubmit'] private onSlashCommand?: TuiAppProps['onSlashCommand'] private onResolvePermission?: TuiAppProps['onResolvePermission'] private onExit?: TuiAppProps['onExit'] private renderer: CliRenderer | null = null private unsubscribeClient: (() => void) | null = null private setProjection?: (projection: SessionProjection | null) => void private setView?: (view: TuiAppState['active_view']) => void private setBusy?: (busy: boolean) => void private setStatus?: (status: string) => void constructor(props: TuiAppProps) { this.client = props.client this.onSubmit = props.onSubmit this.onSlashCommand = props.onSlashCommand this.onResolvePermission = props.onResolvePermission this.onExit = props.onExit } async start(): Promise { if (this.renderer) return this.renderer = await createCliRenderer({ targetFps: 30, maxFps: 60, useMouse: false, autoFocus: false, exitOnCtrlC: false, screenMode: 'alternate-screen', externalOutputMode: 'passthrough', consoleMode: 'disabled', clearOnShutdown: true, openConsoleOnError: false, useKittyKeyboard: {}, backgroundColor: THEME.bg, }) this.renderer.setBackgroundColor(THEME.bg) await render(() => ( { this.setProjection = bindings.setProjection this.setView = bindings.setView this.setBusy = bindings.setBusy this.setStatus = bindings.setStatus }} onSubmit={(input) => this.submit(input)} onSlashCommand={(input) => this.slash(input)} onResolvePermission={(prompt_id, selected_option) => this.resolvePermission(prompt_id, selected_option)} onExit={() => this.exit()} /> ), this.renderer) this.unsubscribeClient = this.client.subscribe((projection) => { this.setProjection?.(projection) }) } stop(): void { this.unsubscribeClient?.() this.unsubscribeClient = null this.setProjection = undefined this.setView = undefined this.setBusy = undefined this.setStatus = undefined if (this.renderer && !this.renderer.isDestroyed) { this.renderer.setTerminalTitle('') this.renderer.externalOutputMode = 'passthrough' this.renderer.destroy() } this.renderer = null } set_view(view: TuiAppState['active_view']): void { this.setView?.(view) } set_busy(busy: boolean, status?: string): void { this.setBusy?.(busy) if (status) this.setStatus?.(status) } set_status(status: string): void { this.setStatus?.(status) } private async submit(input: string): Promise { const text = input.trim() if (!text) return this.setBusy?.(true) this.setStatus?.(`Running: ${text.slice(0, 72)}`) try { await this.onSubmit?.(text) this.setStatus?.('Ready') } catch (error) { this.setStatus?.(error instanceof Error ? error.message : String(error)) throw error } finally { this.setBusy?.(false) } } private async slash(input: string): Promise { const text = input.trim() if (!text) return if (text === '/quit' || text === '/exit') { await this.exit() return } this.setStatus?.(`Command: ${text}`) await this.onSlashCommand?.(text) } private async resolvePermission(prompt_id: string, selected_option: string): Promise { if (!this.onResolvePermission) { this.setStatus?.('Permission selection requires runtime resolver') return } this.setStatus?.(`Permission: ${selected_option}`) await this.onResolvePermission(prompt_id, selected_option) } private async exit(): Promise { await this.onExit?.() } } type StateBindings = { setProjection: (projection: SessionProjection | null) => void setView: (view: TuiAppState['active_view']) => void setBusy: (busy: boolean) => void setStatus: (status: string) => void } type FooterPhase = 'idle' | 'running' | 'permission' | 'confirm_exit' | 'error' function AirCodingView(props: { initialProjection: SessionProjection | null bindState: (bindings: StateBindings) => void onSubmit: (input: string) => void | Promise onSlashCommand: (input: string) => void | Promise onResolvePermission: (prompt_id: string, selected_option: string) => void | Promise onExit: () => void | Promise }) { const renderer = useRenderer() const dimensions = useTerminalDimensions() const [projection, setProjection] = createSignal(props.initialProjection) const [activeView, setActiveView] = createSignal('tasks') const [busy, setBusy] = createSignal(false) const [status, setStatus] = createSignal('Ready') const [footerPhase, setFooterPhase] = createSignal('idle') const [toast, setToast] = createSignal('') let textarea: TextareaRenderable | undefined let history = createPromptHistory() let pasteTick: ReturnType | undefined let exitConfirmUntil = 0 props.bindState({ setProjection, setView: setActiveView, setBusy: (next) => { setBusy(next) setFooterPhase(next ? 'running' : 'idle') }, setStatus: (next) => { setStatus(next) if (/error|failed|blocked|失败|错误/i.test(next)) setFooterPhase('error') else if (!busy()) setFooterPhase('idle') }, }) const focusPrompt = () => { if (textarea && !textarea.isDestroyed) textarea.focus() } const submitPrompt = () => { if (!textarea || textarea.isDestroyed || busy()) return const text = textarea.plainText.trim() if (!text) return history = pushPromptHistory(history, text) textarea.setText('') exitConfirmUntil = 0 setFooterPhase('running') setStatus(text.startsWith('/') ? `Command: ${text}` : `Sending: ${text.slice(0, 72)}`) if (text.startsWith('/')) { void props.onSlashCommand(text) } else { void props.onSubmit(text) } focusPrompt() } const refreshPasteLayout = () => { if (pasteTick) clearTimeout(pasteTick) pasteTick = setTimeout(() => { pasteTick = undefined if (!textarea || textarea.isDestroyed) return textarea.getLayoutNode().markDirty() renderer.requestRender() void renderer.idle().then(() => renderer.requestRender()).catch(() => {}) }, 0) } const applyHistoryMove = (dir: -1 | 1) => { if (!textarea || textarea.isDestroyed) return false const text = textarea.plainText const move = movePromptHistory(history, dir, text, textarea.cursorOffset) history = move.state if (!move.apply) return false textarea.setText(move.text ?? '') textarea.cursorOffset = move.cursor ?? 0 textarea.getLayoutNode().markDirty() renderer.requestRender() return true } const activePermission = () => projection()?.permission_prompts[0] const resolvePermissionByIndex = (index: number) => { const prompt = activePermission() if (!prompt) return false const options = prompt.options.length > 0 ? prompt.options : ['allow', 'deny'] const selected = options[index] if (!selected) return false setFooterPhase('permission') setStatus(`Permission: ${selected}`) void props.onResolvePermission(prompt.prompt_id, selected) return true } const handleKeyDown = (event: KeyEvent) => { const prompt = activePermission() if (prompt) { if (event.name === 'left' || event.name === 'h') { event.preventDefault() setToast('Use 1/2/3 to choose a permission option') return } if (/^[1-9]$/.test(event.name) && resolvePermissionByIndex(Number(event.name) - 1)) { event.preventDefault() return } if ((event.name === 'a' || event.name === 'y') && resolvePermissionByIndex(0)) { event.preventDefault() return } if ((event.name === 'd' || event.name === 'n') && resolvePermissionByIndex(Math.min(1, (prompt.options.length || 2) - 1))) { event.preventDefault() return } } if (event.name === 'return') { event.preventDefault() submitPrompt() return } if (event.ctrl && event.name === 'c') { event.preventDefault() if (textarea && !textarea.isDestroyed && textarea.plainText.length > 0) { textarea.setText('') history = { ...history, index: null, draft: '' } setFooterPhase('idle') setStatus('Draft cleared; press Ctrl+C again to exit') focusPrompt() return } const now = Date.now() if (now < exitConfirmUntil) { void props.onExit() return } exitConfirmUntil = now + EXIT_CONFIRM_MS setFooterPhase('confirm_exit') setStatus('Press Ctrl+C again within 5s to exit') return } if (event.name === 'up' && applyHistoryMove(-1)) { event.preventDefault() return } if (event.name === 'down' && applyHistoryMove(1)) { event.preventDefault() return } if (event.name === 'escape') { event.preventDefault() exitConfirmUntil = 0 setActiveView('tasks') setFooterPhase(busy() ? 'running' : activePermission() ? 'permission' : 'idle') focusPrompt() return } if (event.ctrl && event.name === 'l') { event.preventDefault() renderer.requestRender() return } if (!event.ctrl || event.meta) return const next = shortcutToView(event.name) if (next) { event.preventDefault() setActiveView(next) focusPrompt() } } onMount(() => { renderer.setTerminalTitle('AirCoding') focusPrompt() }) onCleanup(() => { if (pasteTick) clearTimeout(pasteTick) renderer.setTerminalTitle('') }) createEffect(() => { const hasPermission = (projection()?.permission_prompts.length ?? 0) > 0 if (hasPermission) setFooterPhase('permission') else if (!busy() && footerPhase() === 'permission') setFooterPhase('idle') }) createEffect(() => { projection() activeView() busy() status() footerPhase() toast() renderer.requestRender() }) return (