Files
codetyper.cli/src/tui-solid/app.tsx
Carlos Gutierrez fbd9afa177 Fix MCP form input and add reactive MCP server state
- Fix space key not working in MCP add form by handling evt.name === "space"
  - Add support for multi-character paste sequences via evt.sequence
  - Add MCPServerDisplay type to types/tui.ts for UI display
  - Add mcpServers reactive state to app store with setMcpServers,
    addMcpServer, and updateMcpServerStatus actions
  - Update session.tsx to use store's mcpServers instead of static props
  - Update execute.tsx to update store when server is added/connected
  - Remove duplicate MCPServer interfaces from app.tsx, session.tsx,
    and mcp-select.tsx in favor of shared MCPServerDisplay type
2026-02-02 14:10:19 -05:00

463 lines
13 KiB
TypeScript

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<void>;
onCommand: (command: string) => Promise<void>;
onModelSelect: (model: string) => Promise<void>;
onThemeSelect: (theme: string) => void;
onAgentSelect?: (agentId: string) => Promise<void>;
onMCPSelect?: (serverId: string) => Promise<void>;
onMCPAdd?: (data: MCPAddFormData) => Promise<void>;
onFileSelect?: (file: string) => void;
onProviderSelect?: (providerId: string) => Promise<void>;
onCascadeToggle?: (enabled: boolean) => Promise<void>;
onPermissionResponse: (allowed: boolean, scope?: PermissionScope) => void;
onLearningResponse: (
save: boolean,
scope?: LearningScope,
editedContent?: string,
) => void;
onBrainSetJwtToken?: (jwtToken: string) => Promise<void>;
onBrainSetApiKey?: (apiKey: string) => Promise<void>;
onBrainLogout?: () => Promise<void>;
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 (
<box
flexDirection="column"
paddingLeft={2}
paddingRight={2}
paddingTop={2}
paddingBottom={2}
>
<text fg={theme.colors.error} attributes={TextAttributes.BOLD}>
Application Error
</text>
<text fg={theme.colors.error} marginTop={1}>
{props.error.message}
</text>
<text fg={theme.colors.textDim} marginTop={2}>
Press Ctrl+C twice to exit
</text>
</box>
);
}
function AppContent(props: AppProps) {
const route = useRoute();
const app = useAppStore();
const exit = useExit();
const toast = useToast();
const theme = useTheme();
const [fileList, setFileList] = createSignal<string[]>([]);
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<void> => {
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<void> => {
// 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<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");
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 (
<box
flexDirection="column"
flexGrow={1}
backgroundColor={theme.colors.background}
>
<Switch>
<Match when={route.isHome()}>
<Home
onSubmit={handleSubmit}
onCommand={handleCommand}
onModelSelect={handleModelSelect}
onThemeSelect={handleThemeSelect}
onFileSelect={handleFileSelect}
files={fileList()}
/>
</Match>
<Match when={route.isSession()}>
<Session
onSubmit={handleSubmit}
onCommand={handleCommand}
onModelSelect={handleModelSelect}
onThemeSelect={handleThemeSelect}
onAgentSelect={handleAgentSelect}
onMCPSelect={handleMCPSelect}
onMCPAdd={handleMCPAdd}
onFileSelect={handleFileSelect}
onProviderSelect={handleProviderSelect}
onCascadeToggle={handleCascadeToggle}
onPermissionResponse={handlePermissionResponse}
onLearningResponse={handleLearningResponse}
onBrainSetJwtToken={props.onBrainSetJwtToken}
onBrainSetApiKey={props.onBrainSetApiKey}
onBrainLogout={props.onBrainLogout}
plan={props.plan}
agents={props.agents}
currentAgent={props.currentAgent}
mcpServers={props.mcpServers}
files={fileList()}
/>
</Match>
</Switch>
<Toast />
</box>
);
}
function App(props: AppProps) {
return (
<ErrorBoundary fallback={(err: Error) => <ErrorFallback error={err} />}>
<ExitProvider
onExit={() => props.onExit({ exitCode: 0, sessionId: props.sessionId })}
>
<RouteProvider>
<ToastProvider>
<ThemeProvider>
<AppStoreProvider>
<KeybindProvider>
<DialogProvider>
<AppContent {...props} />
</DialogProvider>
</KeybindProvider>
</AppStoreProvider>
</ThemeProvider>
</ToastProvider>
</RouteProvider>
</ExitProvider>
</ErrorBoundary>
);
}
export interface TuiRenderOptions extends TuiInput {
onSubmit: (input: string) => Promise<void>;
onCommand: (command: string) => Promise<void>;
onModelSelect: (model: string) => Promise<void>;
onThemeSelect: (theme: string) => void;
onAgentSelect?: (agentId: string) => Promise<void>;
onMCPSelect?: (serverId: string) => Promise<void>;
onMCPAdd?: (data: MCPAddFormData) => Promise<void>;
onFileSelect?: (file: string) => void;
onProviderSelect?: (providerId: string) => Promise<void>;
onCascadeToggle?: (enabled: boolean) => Promise<void>;
onPermissionResponse: (allowed: boolean, scope?: PermissionScope) => void;
onLearningResponse: (
save: boolean,
scope?: LearningScope,
editedContent?: string,
) => void;
onBrainSetJwtToken?: (jwtToken: string) => Promise<void>;
onBrainSetApiKey?: (apiKey: string) => Promise<void>;
onBrainLogout?: () => Promise<void>;
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<TuiOutput> {
return new Promise<TuiOutput>((resolve) => {
render(() => <App {...options} onExit={resolve} />, {
targetFps: 60,
exitOnCtrlC: false,
useKittyKeyboard: {},
useMouse: false,
});
});
}
export { appStore } from "@tui-solid/context/app";