import { render, useKeyboard } from "@opentui/solid"; import { TextAttributes } from "@opentui/core"; import { ErrorBoundary, Match, Switch, createSignal, createEffect, } from "solid-js"; import { batch } from "solid-js"; import { getFiles } from "@services/file-picker/files"; import { abortCurrentOperation } from "@services/chat-tui-service"; import versionData from "@/version.json"; import { ExitProvider, useExit, RouteProvider, useRoute, AppStoreProvider, useAppStore, setAppStoreRef, ThemeProvider, useTheme, KeybindProvider, DialogProvider, } from "@tui-solid/context"; import { ToastProvider, Toast, useToast } from "@tui-solid/ui/toast"; import { Home } from "@tui-solid/routes/home"; import { Session } from "@tui-solid/routes/session"; import type { TuiInput, TuiOutput } from "@tui-solid/types"; import type { MCPServerDisplay } from "@/types/tui"; import type { PermissionScope, LearningScope } from "@/types/tui"; import type { MCPAddFormData } from "@/types/mcp"; interface AgentOption { id: string; name: string; description?: string; } interface AppProps extends TuiInput { onExit: (output: TuiOutput) => void; onSubmit: (input: string) => Promise; onCommand: (command: string) => Promise; onModelSelect: (model: string) => Promise; onThemeSelect: (theme: string) => void; onAgentSelect?: (agentId: string) => Promise; onMCPSelect?: (serverId: string) => Promise; onMCPAdd?: (data: MCPAddFormData) => Promise; onFileSelect?: (file: string) => void; onProviderSelect?: (providerId: string) => Promise; onCascadeToggle?: (enabled: boolean) => Promise; onPermissionResponse: (allowed: boolean, scope?: PermissionScope) => void; onLearningResponse: ( save: boolean, scope?: LearningScope, editedContent?: string, ) => void; onBrainSetJwtToken?: (jwtToken: string) => Promise; onBrainSetApiKey?: (apiKey: string) => Promise; onBrainLogout?: () => Promise; plan?: { id: string; title: string; items: Array<{ id: string; text: string; completed: boolean }>; } | null; agents?: AgentOption[]; currentAgent?: string; mcpServers?: MCPServerDisplay[]; files?: string[]; } function ErrorFallback(props: { error: Error }) { const theme = useTheme(); return ( Application Error {props.error.message} Press Ctrl+C twice to exit ); } function AppContent(props: AppProps) { const route = useRoute(); const app = useAppStore(); const exit = useExit(); const toast = useToast(); const theme = useTheme(); const [fileList, setFileList] = createSignal([]); setAppStoreRef(app); // Load files when file_picker mode is activated createEffect(() => { if (app.mode() === "file_picker") { const cwd = process.cwd(); const entries = getFiles(cwd, cwd); const paths = entries.map((e) => e.relativePath); setFileList(paths); } }); // Initialize version from version.json app.setVersion(versionData.version); // Initialize theme from props (from config) if (props.theme) { theme.setTheme(props.theme); } // Initialize provider and model from props (from config) if (props.provider) { app.setSessionInfo( props.sessionId ?? "", props.provider, props.model ?? "", ); } // Initialize cascade setting from props (from config) if (props.cascadeEnabled !== undefined) { app.setCascadeEnabled(props.cascadeEnabled); } // Always navigate to session view (skip home page) // Use existing sessionId or create a new one if (!route.isSession()) { const sessionId = props.sessionId ?? `session-${Date.now()}`; batch(() => { app.setSessionInfo(sessionId, app.provider(), app.model()); route.goToSession(sessionId); }); } if (props.availableModels && props.availableModels.length > 0) { app.setAvailableModels(props.availableModels); } // Handle initial prompt after store is initialized if (props.initialPrompt && props.initialPrompt.trim()) { setTimeout(async () => { app.addLog({ type: "user", content: props.initialPrompt! }); app.setMode("thinking"); await props.onSubmit(props.initialPrompt!); }, 100); } useKeyboard((evt) => { // ESC aborts current operation if (evt.name === "escape") { const aborted = abortCurrentOperation(); if (aborted) { toast.info("Operation cancelled"); evt.preventDefault(); return; } } // Ctrl+C exits the application if (evt.ctrl && evt.name === "c") { if (app.interruptPending()) { exit.exit(0); evt.preventDefault(); return; } app.setInterruptPending(true); toast.warning("Press Ctrl+C again to exit"); setTimeout(() => { app.setInterruptPending(false); }, 2000); evt.preventDefault(); return; } if (evt.name === "/" && app.mode() === "idle" && !app.inputBuffer()) { app.openCommandMenu(); evt.preventDefault(); return; } }); const handleSubmit = async (input: string): Promise => { if (!input.trim()) return; if (route.isHome()) { const sessionId = `session-${Date.now()}`; batch(() => { app.setSessionInfo(sessionId, app.provider(), app.model()); route.goToSession(sessionId); }); } app.addLog({ type: "user", content: input }); app.clearInput(); app.setMode("thinking"); try { await props.onSubmit(input); } finally { app.setMode("idle"); } }; const handleCommand = async (command: string): Promise => { // Start a session if on home page for commands that produce output if (route.isHome()) { const sessionId = `session-${Date.now()}`; batch(() => { app.setSessionInfo(sessionId, app.provider(), app.model()); route.goToSession(sessionId); }); } try { await props.onCommand(command); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : String(err)); } }; const handleModelSelect = async (model: string): Promise => { // Start a session if on home page if (route.isHome()) { const sessionId = `session-${Date.now()}`; batch(() => { app.setSessionInfo(sessionId, app.provider(), app.model()); route.goToSession(sessionId); }); } app.setMode("idle"); try { await props.onModelSelect(model); app.setModel(model); toast.success(`Model changed to ${model}`); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : String(err)); } }; const handleThemeSelect = (themeName: string): void => { // Start a session if on home page if (route.isHome()) { const sessionId = `session-${Date.now()}`; batch(() => { app.setSessionInfo(sessionId, app.provider(), app.model()); route.goToSession(sessionId); }); } app.setMode("idle"); props.onThemeSelect(themeName); toast.success(`Theme changed to ${themeName}`); }; const handlePermissionResponse = ( allowed: boolean, scope?: PermissionScope, ): void => { // Don't set mode here - the resolve callback in permissions.ts // handles the mode transition to "tool_execution" props.onPermissionResponse(allowed, scope); }; const handleLearningResponse = ( save: boolean, scope?: LearningScope, editedContent?: string, ): void => { // Don't set mode here - the resolve callback handles the mode transition props.onLearningResponse(save, scope, editedContent); }; const handleAgentSelect = async (agentId: string): Promise => { app.setMode("idle"); try { await props.onAgentSelect?.(agentId); toast.success(`Agent changed to ${agentId}`); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : String(err)); } }; const handleMCPSelect = async (serverId: string): Promise => { app.setMode("idle"); try { await props.onMCPSelect?.(serverId); toast.success(`MCP server selected: ${serverId}`); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : String(err)); } }; const handleMCPAdd = async (data: MCPAddFormData): Promise => { app.setMode("idle"); try { await props.onMCPAdd?.(data); toast.success(`MCP server added: ${data.name}`); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : String(err)); } }; const handleFileSelect = (file: string): void => { app.setMode("idle"); // Insert the file reference into the textarea as @path const fileRef = `@${file} `; app.insertText(fileRef); props.onFileSelect?.(file); }; const handleProviderSelect = async (providerId: string): Promise => { app.setMode("idle"); try { await props.onProviderSelect?.(providerId); app.setProvider(providerId); toast.success(`Provider changed to ${providerId}`); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : String(err)); } }; const handleCascadeToggle = async (): Promise => { const newValue = !app.cascadeEnabled(); app.setCascadeEnabled(newValue); try { await props.onCascadeToggle?.(newValue); toast.success(`Cascade mode ${newValue ? "enabled" : "disabled"}`); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : String(err)); } }; return ( ); } function App(props: AppProps) { return ( }> props.onExit({ exitCode: 0, sessionId: props.sessionId })} > ); } export interface TuiRenderOptions extends TuiInput { onSubmit: (input: string) => Promise; onCommand: (command: string) => Promise; onModelSelect: (model: string) => Promise; onThemeSelect: (theme: string) => void; onAgentSelect?: (agentId: string) => Promise; onMCPSelect?: (serverId: string) => Promise; onMCPAdd?: (data: MCPAddFormData) => Promise; onFileSelect?: (file: string) => void; onProviderSelect?: (providerId: string) => Promise; onCascadeToggle?: (enabled: boolean) => Promise; onPermissionResponse: (allowed: boolean, scope?: PermissionScope) => void; onLearningResponse: ( save: boolean, scope?: LearningScope, editedContent?: string, ) => void; onBrainSetJwtToken?: (jwtToken: string) => Promise; onBrainSetApiKey?: (apiKey: string) => Promise; onBrainLogout?: () => Promise; plan?: { id: string; title: string; items: Array<{ id: string; text: string; completed: boolean }>; } | null; agents?: AgentOption[]; currentAgent?: string; mcpServers?: MCPServerDisplay[]; files?: string[]; } export function tui(options: TuiRenderOptions): Promise { return new Promise((resolve) => { render(() => , { targetFps: 60, exitOnCtrlC: false, useKittyKeyboard: {}, useMouse: false, }); }); } export { appStore } from "@tui-solid/context/app";