Terminal-based AI coding agent with interactive TUI for autonomous code generation.
Features: - Interactive TUI with React/Ink - Autonomous agent with tool calls (bash, read, write, edit, glob, grep) - Permission system with pattern-based rules - Session management with auto-compaction - Dual providers: GitHub Copilot and Ollama - MCP server integration - Todo panel and theme system - Streaming responses - GitHub-compatible project context
This commit is contained in:
439
src/tui-solid/app.tsx
Normal file
439
src/tui-solid/app.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
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 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 { PermissionScope, LearningScope } from "@/types/tui";
|
||||
import type { MCPAddFormData } from "@/types/mcp";
|
||||
|
||||
interface AgentOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MCPServer {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "connected" | "disconnected" | "error";
|
||||
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;
|
||||
plan?: {
|
||||
id: string;
|
||||
title: string;
|
||||
items: Array<{ id: string; text: string; completed: boolean }>;
|
||||
} | null;
|
||||
agents?: AgentOption[];
|
||||
currentAgent?: string;
|
||||
mcpServers?: MCPServer[];
|
||||
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 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);
|
||||
}
|
||||
|
||||
// Navigate to session if resuming
|
||||
if (props.sessionId) {
|
||||
route.goToSession(props.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) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
if (app.interruptPending()) {
|
||||
exit.exit(0);
|
||||
} else {
|
||||
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 => {
|
||||
app.setMode("idle");
|
||||
props.onPermissionResponse(allowed, scope);
|
||||
};
|
||||
|
||||
const handleLearningResponse = (
|
||||
save: boolean,
|
||||
scope?: LearningScope,
|
||||
editedContent?: string,
|
||||
): void => {
|
||||
app.setMode("idle");
|
||||
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}
|
||||
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;
|
||||
plan?: {
|
||||
id: string;
|
||||
title: string;
|
||||
items: Array<{ id: string; text: string; completed: boolean }>;
|
||||
} | null;
|
||||
agents?: AgentOption[];
|
||||
currentAgent?: string;
|
||||
mcpServers?: MCPServer[];
|
||||
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";
|
||||
122
src/tui-solid/components/agent-select.tsx
Normal file
122
src/tui-solid/components/agent-select.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
|
||||
interface AgentOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface AgentSelectProps {
|
||||
agents: AgentOption[];
|
||||
currentAgent: string;
|
||||
onSelect: (agentId: string) => void;
|
||||
onClose: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function AgentSelect(props: AgentSelectProps) {
|
||||
const theme = useTheme();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const agent = props.agents[selectedIndex()];
|
||||
if (agent) {
|
||||
props.onSelect(agent.id);
|
||||
props.onClose();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : props.agents.length - 1,
|
||||
);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
setSelectedIndex((prev) =>
|
||||
prev < props.agents.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.secondary}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.secondary} attributes={TextAttributes.BOLD}>
|
||||
Select Agent
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Current: </text>
|
||||
<text fg={theme.colors.primary}>{props.currentAgent}</text>
|
||||
</box>
|
||||
|
||||
<For each={props.agents}>
|
||||
{(agent, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
const isCurrent = () => agent.id === props.currentAgent;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.secondary : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.secondary : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{agent.name}
|
||||
</text>
|
||||
<Show when={isCurrent()}>
|
||||
<text fg={theme.colors.success}> (current)</text>
|
||||
</Show>
|
||||
<Show when={agent.description}>
|
||||
<text fg={theme.colors.textDim}> - {agent.description}</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter select | Esc close
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
94
src/tui-solid/components/bouncing-loader.tsx
Normal file
94
src/tui-solid/components/bouncing-loader.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createSignal, onCleanup, onMount, For } from "solid-js";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
|
||||
const LOADER_COLORS = [
|
||||
"#ff00ff",
|
||||
"#ff33ff",
|
||||
"#cc66ff",
|
||||
"#9966ff",
|
||||
"#6699ff",
|
||||
"#33ccff",
|
||||
"#00ffff",
|
||||
"#33ffcc",
|
||||
] as const;
|
||||
|
||||
const LOADER_CONFIG = {
|
||||
dotCount: 8,
|
||||
frameInterval: 100,
|
||||
dotChar: "●",
|
||||
emptyChar: "○",
|
||||
} as const;
|
||||
|
||||
interface DotInfo {
|
||||
char: string;
|
||||
color: string;
|
||||
dim: boolean;
|
||||
}
|
||||
|
||||
export function BouncingLoader() {
|
||||
const [position, setPosition] = createSignal(0);
|
||||
const [direction, setDirection] = createSignal(1);
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(() => {
|
||||
intervalId = setInterval(() => {
|
||||
const dir = direction();
|
||||
const prev = position();
|
||||
const next = prev + dir;
|
||||
|
||||
if (next >= LOADER_CONFIG.dotCount - 1) {
|
||||
setDirection(-1);
|
||||
setPosition(LOADER_CONFIG.dotCount - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (next <= 0) {
|
||||
setDirection(1);
|
||||
setPosition(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setPosition(next);
|
||||
}, LOADER_CONFIG.frameInterval);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
const dots = (): DotInfo[] => {
|
||||
const pos = position();
|
||||
return Array.from({ length: LOADER_CONFIG.dotCount }, (_, i) => {
|
||||
const distance = Math.abs(i - pos);
|
||||
const isActive = distance === 0;
|
||||
const isTrail = distance <= 2;
|
||||
const colorIndex = i % LOADER_COLORS.length;
|
||||
const color = LOADER_COLORS[colorIndex];
|
||||
|
||||
return {
|
||||
char:
|
||||
isActive || isTrail ? LOADER_CONFIG.dotChar : LOADER_CONFIG.emptyChar,
|
||||
color,
|
||||
dim: !isActive && !isTrail,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<For each={dots()}>
|
||||
{(dot) => (
|
||||
<text
|
||||
fg={dot.color}
|
||||
attributes={dot.dim ? TextAttributes.DIM : TextAttributes.NONE}
|
||||
>
|
||||
{dot.char}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
230
src/tui-solid/components/command-menu.tsx
Normal file
230
src/tui-solid/components/command-menu.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import type { SlashCommand, CommandCategory } from "@/types/tui";
|
||||
import { SLASH_COMMANDS, COMMAND_CATEGORIES } from "@constants/tui-components";
|
||||
|
||||
export { SLASH_COMMANDS } from "@constants/tui-components";
|
||||
|
||||
interface CommandMenuProps {
|
||||
onSelect: (command: string) => void;
|
||||
onCancel?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface CommandWithIndex extends SlashCommand {
|
||||
flatIndex: number;
|
||||
}
|
||||
|
||||
const filterCommands = (
|
||||
commands: readonly SlashCommand[],
|
||||
filter: string,
|
||||
): SlashCommand[] => {
|
||||
if (!filter) return [...commands];
|
||||
const query = filter.toLowerCase();
|
||||
return commands.filter(
|
||||
(cmd) =>
|
||||
cmd.name.toLowerCase().includes(query) ||
|
||||
cmd.description.toLowerCase().includes(query),
|
||||
);
|
||||
};
|
||||
|
||||
const groupCommandsByCategory = (
|
||||
commands: SlashCommand[],
|
||||
): Array<{ category: CommandCategory; commands: SlashCommand[] }> => {
|
||||
return COMMAND_CATEGORIES.map((cat) => ({
|
||||
category: cat,
|
||||
commands: commands.filter((cmd) => cmd.category === cat),
|
||||
})).filter((group) => group.commands.length > 0);
|
||||
};
|
||||
|
||||
const capitalizeCategory = (category: string): string =>
|
||||
category.charAt(0).toUpperCase() + category.slice(1);
|
||||
|
||||
export function CommandMenu(props: CommandMenuProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const filteredCommands = createMemo(() =>
|
||||
filterCommands(SLASH_COMMANDS, app.commandMenu().filter),
|
||||
);
|
||||
|
||||
const groupedCommands = createMemo(() =>
|
||||
groupCommandsByCategory(filteredCommands()),
|
||||
);
|
||||
|
||||
const commandsWithIndex = createMemo((): CommandWithIndex[] => {
|
||||
let flatIndex = 0;
|
||||
return groupedCommands().flatMap((group) =>
|
||||
group.commands.map((cmd) => ({
|
||||
...cmd,
|
||||
flatIndex: flatIndex++,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive() || !app.commandMenu().isOpen) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
app.closeCommandMenu();
|
||||
props.onCancel?.();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const commands = filteredCommands();
|
||||
if (commands.length > 0) {
|
||||
const selected = commands[app.commandMenu().selectedIndex];
|
||||
if (selected) {
|
||||
props.onSelect(selected.name);
|
||||
}
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
const newIndex =
|
||||
app.commandMenu().selectedIndex > 0
|
||||
? app.commandMenu().selectedIndex - 1
|
||||
: filteredCommands().length - 1;
|
||||
app.setCommandSelectedIndex(newIndex);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
const newIndex =
|
||||
app.commandMenu().selectedIndex < filteredCommands().length - 1
|
||||
? app.commandMenu().selectedIndex + 1
|
||||
: 0;
|
||||
app.setCommandSelectedIndex(newIndex);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "tab") {
|
||||
const commands = filteredCommands();
|
||||
if (commands.length > 0) {
|
||||
const selected = commands[app.commandMenu().selectedIndex];
|
||||
if (selected) {
|
||||
props.onSelect(selected.name);
|
||||
}
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "backspace" || evt.name === "delete") {
|
||||
if (app.commandMenu().filter.length > 0) {
|
||||
app.setCommandFilter(app.commandMenu().filter.slice(0, -1));
|
||||
} else {
|
||||
app.closeCommandMenu();
|
||||
props.onCancel?.();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
app.setCommandFilter(app.commandMenu().filter + evt.name);
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={app.commandMenu().isOpen}>
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.primary}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
Commands
|
||||
</text>
|
||||
<Show when={app.commandMenu().filter}>
|
||||
<text fg={theme.colors.textDim}> - filtering: </text>
|
||||
<text fg={theme.colors.warning}>{app.commandMenu().filter}</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={filteredCommands().length > 0}
|
||||
fallback={
|
||||
<text fg={theme.colors.textDim}>
|
||||
No commands match "{app.commandMenu().filter}"
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="column">
|
||||
<For each={groupedCommands()}>
|
||||
{(group) => (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text
|
||||
fg={theme.colors.textDim}
|
||||
attributes={TextAttributes.BOLD}
|
||||
>
|
||||
{capitalizeCategory(group.category)}
|
||||
</text>
|
||||
<For each={group.commands}>
|
||||
{(cmd) => {
|
||||
const cmdWithIndex = () =>
|
||||
commandsWithIndex().find((c) => c.name === cmd.name);
|
||||
const isSelected = () =>
|
||||
cmdWithIndex()?.flatIndex ===
|
||||
app.commandMenu().selectedIndex;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.primary : undefined}
|
||||
attributes={
|
||||
isSelected()
|
||||
? TextAttributes.BOLD
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
isSelected()
|
||||
? theme.colors.primary
|
||||
: theme.colors.success
|
||||
}
|
||||
>
|
||||
/{cmd.name}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
- {cmd.description}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
Esc to close | Enter/Tab to select | Type to filter
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
115
src/tui-solid/components/diff-view.tsx
Normal file
115
src/tui-solid/components/diff-view.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { DiffLineData } from "@/types/tui";
|
||||
|
||||
interface DiffViewProps {
|
||||
lines: DiffLineData[];
|
||||
filePath?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function DiffView(props: DiffViewProps) {
|
||||
const theme = useTheme();
|
||||
const compact = () => props.compact ?? false;
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<Show when={props.filePath}>
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text fg={theme.colors.diffHeader} attributes={TextAttributes.BOLD}>
|
||||
{props.filePath}
|
||||
</text>
|
||||
<Show when={(props.additions ?? 0) > 0 || (props.deletions ?? 0) > 0}>
|
||||
<text fg={theme.colors.textDim}> (</text>
|
||||
<Show when={(props.additions ?? 0) > 0}>
|
||||
<text fg={theme.colors.diffAdded}>+{props.additions}</text>
|
||||
</Show>
|
||||
<Show
|
||||
when={(props.additions ?? 0) > 0 && (props.deletions ?? 0) > 0}
|
||||
>
|
||||
<text fg={theme.colors.textDim}>/</text>
|
||||
</Show>
|
||||
<Show when={(props.deletions ?? 0) > 0}>
|
||||
<text fg={theme.colors.diffRemoved}>-{props.deletions}</text>
|
||||
</Show>
|
||||
<text fg={theme.colors.textDim}>)</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<For each={props.lines}>
|
||||
{(line) => <DiffLine line={line} compact={compact()} />}
|
||||
</For>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiffLineProps {
|
||||
line: DiffLineData;
|
||||
compact: boolean;
|
||||
}
|
||||
|
||||
function DiffLine(props: DiffLineProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
const lineColor = (): string => {
|
||||
// Use white text for add/remove lines since they have colored backgrounds
|
||||
if (props.line.type === "add" || props.line.type === "remove") {
|
||||
return theme.colors.text;
|
||||
}
|
||||
const colorMap: Record<string, string> = {
|
||||
context: theme.colors.diffContext,
|
||||
header: theme.colors.diffHeader,
|
||||
hunk: theme.colors.diffHunk,
|
||||
summary: theme.colors.textDim,
|
||||
};
|
||||
return colorMap[props.line.type] ?? theme.colors.text;
|
||||
};
|
||||
|
||||
const prefix = (): string => {
|
||||
const prefixMap: Record<string, string> = {
|
||||
add: "+",
|
||||
remove: "-",
|
||||
context: " ",
|
||||
header: "",
|
||||
hunk: "",
|
||||
summary: "",
|
||||
};
|
||||
return prefixMap[props.line.type] ?? " ";
|
||||
};
|
||||
|
||||
const bgColor = (): string | undefined => {
|
||||
if (props.line.type === "add") return theme.colors.bgAdded;
|
||||
if (props.line.type === "remove") return theme.colors.bgRemoved;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<Show
|
||||
when={
|
||||
!props.compact &&
|
||||
(props.line.type === "add" ||
|
||||
props.line.type === "remove" ||
|
||||
props.line.type === "context")
|
||||
}
|
||||
>
|
||||
<text fg={theme.colors.textDim} width={4}>
|
||||
{props.line.oldLineNum?.toString().padStart(3) ?? " "}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim} width={4}>
|
||||
{props.line.newLineNum?.toString().padStart(3) ?? " "}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={lineColor()} bg={bgColor()}>
|
||||
{prefix()}
|
||||
{props.line.content}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export { parseDiffOutput, isDiffContent } from "@/utils/diff";
|
||||
176
src/tui-solid/components/file-picker.tsx
Normal file
176
src/tui-solid/components/file-picker.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
|
||||
interface FilePickerProps {
|
||||
files: string[];
|
||||
onSelect: (file: string) => void;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 15;
|
||||
|
||||
export function FilePicker(props: FilePickerProps) {
|
||||
const theme = useTheme();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [scrollOffset, setScrollOffset] = createSignal(0);
|
||||
const [filter, setFilter] = createSignal("");
|
||||
|
||||
const filteredFiles = createMemo(() => {
|
||||
const query = filter().toLowerCase();
|
||||
if (!query) return props.files;
|
||||
return props.files.filter((f) => f.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const file = filteredFiles()[selectedIndex()];
|
||||
if (file) {
|
||||
props.onSelect(file);
|
||||
props.onClose();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
const files = filteredFiles();
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev > 0 ? prev - 1 : files.length - 1;
|
||||
if (newIndex < scrollOffset()) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
if (prev === 0 && newIndex === files.length - 1) {
|
||||
setScrollOffset(Math.max(0, files.length - MAX_VISIBLE));
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
const files = filteredFiles();
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev < files.length - 1 ? prev + 1 : 0;
|
||||
if (newIndex >= scrollOffset() + MAX_VISIBLE) {
|
||||
setScrollOffset(newIndex - MAX_VISIBLE + 1);
|
||||
}
|
||||
if (prev === files.length - 1 && newIndex === 0) {
|
||||
setScrollOffset(0);
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "backspace" || evt.name === "delete") {
|
||||
if (filter().length > 0) {
|
||||
setFilter(filter().slice(0, -1));
|
||||
setSelectedIndex(0);
|
||||
setScrollOffset(0);
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFilter(filter() + evt.name);
|
||||
setSelectedIndex(0);
|
||||
setScrollOffset(0);
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
const visibleFiles = createMemo(() =>
|
||||
filteredFiles().slice(scrollOffset(), scrollOffset() + MAX_VISIBLE),
|
||||
);
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.primary}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
{props.title ?? "Select File"}
|
||||
</text>
|
||||
<Show when={filter()}>
|
||||
<text fg={theme.colors.textDim}> - filtering: </text>
|
||||
<text fg={theme.colors.warning}>{filter()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={filteredFiles().length > 0}
|
||||
fallback={
|
||||
<text fg={theme.colors.textDim}>No files match "{filter()}"</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="column">
|
||||
<Show when={scrollOffset() > 0}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
↑ {scrollOffset()} more above
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<For each={visibleFiles()}>
|
||||
{(file, visibleIndex) => {
|
||||
const actualIndex = () => scrollOffset() + visibleIndex();
|
||||
const isSelected = () => actualIndex() === selectedIndex();
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.primary : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text fg={isSelected() ? theme.colors.primary : undefined}>
|
||||
{file}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={scrollOffset() + MAX_VISIBLE < filteredFiles().length}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
↓ {filteredFiles().length - scrollOffset() - MAX_VISIBLE} more
|
||||
below
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter select | Type to filter | Esc close
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
92
src/tui-solid/components/header.tsx
Normal file
92
src/tui-solid/components/header.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Show, createMemo } from "solid-js";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
|
||||
interface HeaderProps {
|
||||
showBanner?: boolean;
|
||||
}
|
||||
|
||||
const MODE_LABELS = {
|
||||
agent: "Agent",
|
||||
ask: "Ask",
|
||||
"code-review": "Code Review",
|
||||
} as const;
|
||||
|
||||
const MODE_DESCRIPTIONS = {
|
||||
agent: "Full access - can modify files",
|
||||
ask: "Read-only - answers questions",
|
||||
"code-review": "Review PRs and diffs",
|
||||
} as const;
|
||||
|
||||
const MODE_COLORS = {
|
||||
agent: "warning",
|
||||
ask: "info",
|
||||
"code-review": "success",
|
||||
} as const;
|
||||
|
||||
export function Header(props: HeaderProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
const showBanner = () => props.showBanner ?? true;
|
||||
|
||||
const modeColor = createMemo(() => {
|
||||
const colorKey = MODE_COLORS[app.interactionMode()];
|
||||
return theme.colors[colorKey];
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
borderColor={theme.colors.border}
|
||||
border={["bottom"]}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={showBanner()}>
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
CodeTyper
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.colors.textDim}>v{app.version()}</text>
|
||||
<text fg={theme.colors.textDim}>|</text>
|
||||
<box flexDirection="row">
|
||||
<text fg={modeColor()} attributes={TextAttributes.BOLD}>
|
||||
[{MODE_LABELS[app.interactionMode()]}]
|
||||
</text>
|
||||
<Show when={app.currentAgent() !== "default"}>
|
||||
<text fg={theme.colors.secondary} attributes={TextAttributes.BOLD}>
|
||||
{" "}
|
||||
@{app.currentAgent()}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
- {MODE_DESCRIPTIONS[app.interactionMode()]}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Provider: </text>
|
||||
<text fg={theme.colors.secondary}>{app.provider()}</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Model: </text>
|
||||
<text fg={theme.colors.accent}>{app.model() || "auto"}</text>
|
||||
</box>
|
||||
<Show when={app.sessionId()}>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Session: </text>
|
||||
<text fg={theme.colors.info}>
|
||||
{app.sessionId()?.replace("session-", "").slice(-5)}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
25
src/tui-solid/components/index.ts
Normal file
25
src/tui-solid/components/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export { StatusBar } from "./status-bar";
|
||||
export { Logo } from "./logo";
|
||||
export { ThinkingIndicator } from "./thinking-indicator";
|
||||
export { BouncingLoader } from "./bouncing-loader";
|
||||
export { LogPanel } from "./log-panel";
|
||||
export { LogEntryDisplay } from "./log-entry";
|
||||
export { StreamingMessage } from "./streaming-message";
|
||||
export { InputArea } from "./input-area";
|
||||
export { Header } from "./header";
|
||||
export { CommandMenu, SLASH_COMMANDS } from "./command-menu";
|
||||
export { ModelSelect } from "./model-select";
|
||||
export { AgentSelect } from "./agent-select";
|
||||
export { ThemeSelect } from "./theme-select";
|
||||
export { MCPSelect } from "./mcp-select";
|
||||
export { MCPAddForm } from "./mcp-add-form";
|
||||
export { ModeSelect } from "./mode-select";
|
||||
export { ProviderSelect } from "./provider-select";
|
||||
export { FilePicker } from "./file-picker";
|
||||
export { SelectMenu } from "./select-menu";
|
||||
export type { SelectOption } from "./select-menu";
|
||||
export { PermissionModal } from "./permission-modal";
|
||||
export { LearningModal } from "./learning-modal";
|
||||
export { TodoPanel } from "./todo-panel";
|
||||
export type { TodoItem, Plan } from "./todo-panel";
|
||||
export { DiffView, parseDiffOutput, isDiffContent } from "./diff-view";
|
||||
248
src/tui-solid/components/input-area.tsx
Normal file
248
src/tui-solid/components/input-area.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { createMemo, Show, onMount, onCleanup } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextareaRenderable, type PasteEvent } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
|
||||
/** Minimum lines to trigger paste summary */
|
||||
const MIN_PASTE_LINES = 3;
|
||||
/** Minimum characters to trigger paste summary */
|
||||
const MIN_PASTE_CHARS = 150;
|
||||
|
||||
interface InputAreaProps {
|
||||
onSubmit: (input: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Stores pasted content that was summarized */
|
||||
type PastedBlock = {
|
||||
id: number;
|
||||
content: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export function InputArea(props: InputAreaProps) {
|
||||
let inputRef: TextareaRenderable;
|
||||
let pasteCounter = 0;
|
||||
let pastedBlocks: Map<string, PastedBlock> = new Map();
|
||||
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
|
||||
const isLocked = createMemo(() => app.isInputLocked());
|
||||
|
||||
/**
|
||||
* Insert pasted text as virtual placeholder
|
||||
*/
|
||||
const pasteText = (text: string, virtualText: string): void => {
|
||||
if (!inputRef) return;
|
||||
|
||||
pasteCounter++;
|
||||
const id = pasteCounter;
|
||||
|
||||
// Insert the placeholder text
|
||||
inputRef.insertText(virtualText + " ");
|
||||
|
||||
// Store the actual content - use placeholder as key for simple lookup
|
||||
const block: PastedBlock = {
|
||||
id,
|
||||
content: text,
|
||||
placeholder: virtualText,
|
||||
};
|
||||
pastedBlocks.set(virtualText, block);
|
||||
|
||||
app.setInputBuffer(inputRef.plainText);
|
||||
};
|
||||
|
||||
/**
|
||||
* Expand all pasted blocks back to their original content
|
||||
*/
|
||||
const expandPastedContent = (inputText: string): string => {
|
||||
if (pastedBlocks.size === 0) return inputText;
|
||||
|
||||
let result = inputText;
|
||||
|
||||
// Simple string replacement - replace each placeholder with actual content
|
||||
for (const block of pastedBlocks.values()) {
|
||||
// Replace placeholder (with or without trailing space)
|
||||
result = result.replace(block.placeholder + " ", block.content);
|
||||
result = result.replace(block.placeholder, block.content);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all pasted blocks
|
||||
*/
|
||||
const clearPastedBlocks = (): void => {
|
||||
pastedBlocks.clear();
|
||||
pasteCounter = 0;
|
||||
};
|
||||
const isMenuOpen = createMemo(() => {
|
||||
const mode = app.mode();
|
||||
return (
|
||||
app.commandMenu().isOpen ||
|
||||
mode === "command_menu" ||
|
||||
mode === "model_select" ||
|
||||
mode === "theme_select" ||
|
||||
mode === "agent_select" ||
|
||||
mode === "mode_select" ||
|
||||
mode === "mcp_select" ||
|
||||
mode === "mcp_add" ||
|
||||
mode === "file_picker" ||
|
||||
mode === "permission_prompt" ||
|
||||
mode === "learning_prompt"
|
||||
);
|
||||
});
|
||||
const placeholder = () =>
|
||||
props.placeholder ?? "Ask anything... (@ for files, / for commands)";
|
||||
|
||||
const borderColor = createMemo(() => {
|
||||
if (isLocked()) return theme.colors.borderWarning;
|
||||
if (app.inputBuffer()) return theme.colors.borderFocus;
|
||||
return theme.colors.border;
|
||||
});
|
||||
|
||||
// Handle "/" to open command menu when input is empty
|
||||
// Handle Enter to submit (backup in case onSubmit doesn't fire)
|
||||
// Handle Ctrl+Tab to toggle interaction mode
|
||||
useKeyboard((evt) => {
|
||||
// Ctrl+Tab works even when locked or menus are open
|
||||
if (evt.ctrl && evt.name === "tab") {
|
||||
app.toggleInteractionMode();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLocked()) return;
|
||||
// Don't capture keys when any menu/modal is open
|
||||
if (isMenuOpen()) return;
|
||||
|
||||
if (evt.name === "/" && !app.inputBuffer()) {
|
||||
app.openCommandMenu();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "@") {
|
||||
app.setMode("file_picker");
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return" && !evt.shift && !evt.ctrl && !evt.meta) {
|
||||
handleSubmit();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
// Get value from app store (synced via onContentChange) or directly from ref
|
||||
let value = (app.inputBuffer() || inputRef?.plainText || "").trim();
|
||||
if (value && !isLocked()) {
|
||||
// Expand pasted content placeholders back to actual content
|
||||
value = expandPastedContent(value);
|
||||
props.onSubmit(value);
|
||||
if (inputRef) inputRef.clear();
|
||||
app.setInputBuffer("");
|
||||
clearPastedBlocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle paste events - summarize large pastes
|
||||
*/
|
||||
const handlePaste = (event: PasteEvent): void => {
|
||||
if (isLocked()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize line endings (Windows ConPTY sends CR-only newlines)
|
||||
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const pastedContent = normalizedText.trim();
|
||||
|
||||
if (!pastedContent) return;
|
||||
|
||||
// Check if paste should be summarized
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1;
|
||||
if (lineCount >= MIN_PASTE_LINES || pastedContent.length > MIN_PASTE_CHARS) {
|
||||
event.preventDefault();
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`);
|
||||
}
|
||||
// Otherwise let default paste behavior handle it
|
||||
};
|
||||
|
||||
// Register insert function so external code can insert text
|
||||
onMount(() => {
|
||||
app.setInputInsertFn((text: string) => {
|
||||
if (inputRef) {
|
||||
inputRef.insertText(text);
|
||||
app.setInputBuffer(inputRef.plainText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
app.setInputInsertFn(null);
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={borderColor()}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
minHeight={6}
|
||||
>
|
||||
<Show
|
||||
when={!isLocked()}
|
||||
fallback={
|
||||
<box paddingTop={1} paddingBottom={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
Input locked while processing...
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => (inputRef = val)}
|
||||
placeholder={placeholder()}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.colors.text}
|
||||
focused={!isLocked() && !isMenuOpen()}
|
||||
onContentChange={() => {
|
||||
if (inputRef) {
|
||||
app.setInputBuffer(inputRef.plainText);
|
||||
}
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(evt) => {
|
||||
// Don't capture keys when any menu/modal is open
|
||||
if (isMenuOpen()) return;
|
||||
if (evt.name === "return" && !evt.shift && !evt.ctrl && !evt.meta) {
|
||||
handleSubmit();
|
||||
evt.preventDefault();
|
||||
}
|
||||
if (evt.name === "/" && !app.inputBuffer()) {
|
||||
app.openCommandMenu();
|
||||
evt.preventDefault();
|
||||
}
|
||||
if (evt.name === "@") {
|
||||
app.setMode("file_picker");
|
||||
evt.preventDefault();
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
248
src/tui-solid/components/learning-modal.tsx
Normal file
248
src/tui-solid/components/learning-modal.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { createSignal, Show, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { LearningPrompt, LearningScope } from "@/types/tui";
|
||||
|
||||
interface LearningModalProps {
|
||||
prompt: LearningPrompt;
|
||||
onRespond?: (
|
||||
save: boolean,
|
||||
scope?: LearningScope,
|
||||
editedContent?: string,
|
||||
) => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
scope: LearningScope;
|
||||
}> = [
|
||||
{ key: "l", label: "Save for this project", scope: "local" },
|
||||
{ key: "g", label: "Save globally", scope: "global" },
|
||||
];
|
||||
|
||||
export function LearningModal(props: LearningModalProps) {
|
||||
const theme = useTheme();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [isEditing, setIsEditing] = createSignal(false);
|
||||
const [editedContent] = createSignal(props.prompt.content);
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const handleResponse = (
|
||||
save: boolean,
|
||||
scope?: LearningScope,
|
||||
content?: string,
|
||||
): void => {
|
||||
// Call the resolve function on the prompt to complete the learning prompt
|
||||
if (props.prompt.resolve) {
|
||||
props.prompt.resolve({ save, scope, editedContent: content });
|
||||
}
|
||||
// Also call the onRespond callback for UI state updates
|
||||
props.onRespond?.(save, scope, content);
|
||||
};
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (isEditing()) {
|
||||
if (evt.name === "escape") {
|
||||
setIsEditing(false);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "return" && evt.ctrl) {
|
||||
setIsEditing(false);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : SCOPE_OPTIONS.length + 1,
|
||||
);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
setSelectedIndex((prev) =>
|
||||
prev < SCOPE_OPTIONS.length + 1 ? prev + 1 : 0,
|
||||
);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
if (selectedIndex() === SCOPE_OPTIONS.length) {
|
||||
setIsEditing(true);
|
||||
} else if (selectedIndex() === SCOPE_OPTIONS.length + 1) {
|
||||
handleResponse(false);
|
||||
} else {
|
||||
const option = SCOPE_OPTIONS[selectedIndex()];
|
||||
handleResponse(true, option.scope, editedContent());
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "escape") {
|
||||
handleResponse(false);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1) {
|
||||
const charLower = evt.name.toLowerCase();
|
||||
const optionIndex = SCOPE_OPTIONS.findIndex((o) => o.key === charLower);
|
||||
if (optionIndex !== -1) {
|
||||
const option = SCOPE_OPTIONS[optionIndex];
|
||||
handleResponse(true, option.scope, editedContent());
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (charLower === "e") {
|
||||
setIsEditing(true);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (charLower === "n") {
|
||||
handleResponse(false);
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.info}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.info} attributes={TextAttributes.BOLD}>
|
||||
💡 Learning Opportunity
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.textDim}>Category: </text>
|
||||
<text fg={theme.colors.primary}>{props.prompt.category}</text>
|
||||
</box>
|
||||
|
||||
<Show when={props.prompt.context}>
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.textDim}>Context: </text>
|
||||
<text fg={theme.colors.text}>{props.prompt.context}</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.textDim}>Learned content:</text>
|
||||
<box
|
||||
marginTop={1}
|
||||
borderColor={isEditing() ? theme.colors.primary : theme.colors.border}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={theme.colors.text} wrapMode="word">
|
||||
{editedContent()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<For each={SCOPE_OPTIONS}>
|
||||
{(option, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.primary : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text fg={theme.colors.success}>[{option.key}] </text>
|
||||
<text fg={isSelected() ? theme.colors.primary : undefined}>
|
||||
{option.label}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={
|
||||
selectedIndex() === SCOPE_OPTIONS.length
|
||||
? theme.colors.primary
|
||||
: undefined
|
||||
}
|
||||
attributes={
|
||||
selectedIndex() === SCOPE_OPTIONS.length
|
||||
? TextAttributes.BOLD
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{selectedIndex() === SCOPE_OPTIONS.length ? "> " : " "}
|
||||
</text>
|
||||
<text fg={theme.colors.warning}>[e] </text>
|
||||
<text
|
||||
fg={
|
||||
selectedIndex() === SCOPE_OPTIONS.length
|
||||
? theme.colors.primary
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Edit before saving
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={
|
||||
selectedIndex() === SCOPE_OPTIONS.length + 1
|
||||
? theme.colors.primary
|
||||
: undefined
|
||||
}
|
||||
attributes={
|
||||
selectedIndex() === SCOPE_OPTIONS.length + 1
|
||||
? TextAttributes.BOLD
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{selectedIndex() === SCOPE_OPTIONS.length + 1 ? "> " : " "}
|
||||
</text>
|
||||
<text fg={theme.colors.error}>[n] </text>
|
||||
<text
|
||||
fg={
|
||||
selectedIndex() === SCOPE_OPTIONS.length + 1
|
||||
? theme.colors.primary
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Don't save
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter select | Press shortcut key
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
236
src/tui-solid/components/log-entry.tsx
Normal file
236
src/tui-solid/components/log-entry.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Show, Switch, Match, For } from "solid-js";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { LogEntry, ToolStatus } from "@/types/tui";
|
||||
import {
|
||||
TOOL_STATUS_ICONS,
|
||||
TOOL_STATUS_COLORS,
|
||||
} from "@constants/tui-components";
|
||||
import { DiffView } from "@tui-solid/components/diff-view";
|
||||
import { StreamingMessage } from "@tui-solid/components/streaming-message";
|
||||
import { parseDiffOutput, isDiffContent } from "@/utils/diff";
|
||||
|
||||
interface LogEntryDisplayProps {
|
||||
entry: LogEntry;
|
||||
}
|
||||
|
||||
function UserEntry(props: { entry: LogEntry }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.roleUser} attributes={TextAttributes.BOLD}>
|
||||
You
|
||||
</text>
|
||||
<box marginLeft={2}>
|
||||
<text>{props.entry.content}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantEntry(props: { entry: LogEntry }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.roleAssistant} attributes={TextAttributes.BOLD}>
|
||||
CodeTyper
|
||||
</text>
|
||||
<box marginLeft={2}>
|
||||
<text wrapMode="word">{props.entry.content}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorEntry(props: { entry: LogEntry }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.error}>✗ Error: {props.entry.content}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemEntry(props: { entry: LogEntry }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.textDim}>⚙ {props.entry.content}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingEntry(props: { entry: LogEntry }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.modeThinking}>● {props.entry.content}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolEntry(props: { entry: LogEntry }) {
|
||||
const theme = useTheme();
|
||||
const toolStatus = (): ToolStatus =>
|
||||
props.entry.metadata?.toolStatus ?? "pending";
|
||||
const statusIcon = () => TOOL_STATUS_ICONS[toolStatus()];
|
||||
const statusColor = (): string => {
|
||||
const colorKey = TOOL_STATUS_COLORS[toolStatus()];
|
||||
const color = theme.colors[colorKey as keyof typeof theme.colors];
|
||||
if (typeof color === "string") return color;
|
||||
return theme.colors.textDim;
|
||||
};
|
||||
|
||||
const hasDiff = () =>
|
||||
props.entry.metadata?.diffData?.isDiff ||
|
||||
isDiffContent(props.entry.content);
|
||||
|
||||
const isMultiline = () => props.entry.content.includes("\n");
|
||||
const lines = () => props.entry.content.split("\n");
|
||||
|
||||
return (
|
||||
<Switch
|
||||
fallback={
|
||||
<DefaultToolEntry
|
||||
{...props}
|
||||
statusIcon={statusIcon()}
|
||||
statusColor={statusColor()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match when={hasDiff() && props.entry.metadata?.toolStatus === "success"}>
|
||||
<DiffToolEntry
|
||||
{...props}
|
||||
statusIcon={statusIcon()}
|
||||
statusColor={statusColor()}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={isMultiline()}>
|
||||
<MultilineToolEntry
|
||||
{...props}
|
||||
statusIcon={statusIcon()}
|
||||
statusColor={statusColor()}
|
||||
lines={lines()}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffToolEntry(props: {
|
||||
entry: LogEntry;
|
||||
statusIcon: string;
|
||||
statusColor: string;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const diffData = () => parseDiffOutput(props.entry.content);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<box flexDirection="row">
|
||||
<text fg={props.statusColor}>{props.statusIcon} </text>
|
||||
<text fg={theme.colors.roleTool}>
|
||||
{props.entry.metadata?.toolName ?? "tool"}
|
||||
</text>
|
||||
<Show when={props.entry.metadata?.toolDescription}>
|
||||
<text fg={theme.colors.textDim}>: </text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{props.entry.metadata?.toolDescription}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box marginLeft={2}>
|
||||
<DiffView
|
||||
lines={diffData().lines}
|
||||
filePath={diffData().filePath}
|
||||
additions={diffData().additions}
|
||||
deletions={diffData().deletions}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function MultilineToolEntry(props: {
|
||||
entry: LogEntry;
|
||||
statusIcon: string;
|
||||
statusColor: string;
|
||||
lines: string[];
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const hasDescription = () => Boolean(props.entry.metadata?.toolDescription);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<box flexDirection="row">
|
||||
<text fg={props.statusColor}>{props.statusIcon} </text>
|
||||
<text fg={theme.colors.roleTool}>
|
||||
{props.entry.metadata?.toolName ?? "tool"}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>: </text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{props.entry.metadata?.toolDescription ?? props.lines[0]}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="column" marginLeft={2}>
|
||||
<For each={hasDescription() ? props.lines : props.lines.slice(1)}>
|
||||
{(line) => <text fg={theme.colors.textDim}>{line}</text>}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultToolEntry(props: {
|
||||
entry: LogEntry;
|
||||
statusIcon: string;
|
||||
statusColor: string;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={props.statusColor}>{props.statusIcon} </text>
|
||||
<text fg={theme.colors.roleTool}>
|
||||
{props.entry.metadata?.toolName ?? "tool"}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>: </text>
|
||||
<text fg={theme.colors.textDim}>{props.entry.content}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultEntry(props: { entry: LogEntry }) {
|
||||
return (
|
||||
<box marginBottom={1}>
|
||||
<text>{props.entry.content}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogEntryDisplay(props: LogEntryDisplayProps) {
|
||||
return (
|
||||
<Switch fallback={<DefaultEntry entry={props.entry} />}>
|
||||
<Match when={props.entry.type === "user"}>
|
||||
<UserEntry entry={props.entry} />
|
||||
</Match>
|
||||
<Match when={props.entry.type === "assistant"}>
|
||||
<AssistantEntry entry={props.entry} />
|
||||
</Match>
|
||||
<Match when={props.entry.type === "assistant_streaming"}>
|
||||
<StreamingMessage entry={props.entry} />
|
||||
</Match>
|
||||
<Match when={props.entry.type === "tool"}>
|
||||
<ToolEntry entry={props.entry} />
|
||||
</Match>
|
||||
<Match when={props.entry.type === "error"}>
|
||||
<ErrorEntry entry={props.entry} />
|
||||
</Match>
|
||||
<Match when={props.entry.type === "system"}>
|
||||
<SystemEntry entry={props.entry} />
|
||||
</Match>
|
||||
<Match when={props.entry.type === "thinking"}>
|
||||
<ThinkingEntry entry={props.entry} />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
166
src/tui-solid/components/log-panel.tsx
Normal file
166
src/tui-solid/components/log-panel.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import { LogEntryDisplay } from "@tui-solid/components/log-entry";
|
||||
|
||||
const SCROLL_LINES = 3;
|
||||
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
|
||||
const MOUSE_DISABLE = "\x1b[?1000l\x1b[?1006l";
|
||||
const SGR_MOUSE_PATTERN = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
||||
|
||||
const parseMouseScroll = (data: string): "up" | "down" | null => {
|
||||
const match = data.match(SGR_MOUSE_PATTERN);
|
||||
if (!match) return null;
|
||||
|
||||
const button = parseInt(match[1], 10);
|
||||
if (button === 64) return "up";
|
||||
if (button === 65) return "down";
|
||||
return null;
|
||||
};
|
||||
|
||||
export function LogPanel() {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
let scrollboxRef: ScrollBoxRenderable | undefined;
|
||||
const [stickyEnabled, setStickyEnabled] = createSignal(true);
|
||||
|
||||
const logs = createMemo(() => {
|
||||
return app.logs().filter((entry) => {
|
||||
if (entry.type !== "tool") return true;
|
||||
if (entry.metadata?.quiet) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const hasContent = createMemo(() => logs().length > 0);
|
||||
|
||||
const canScroll = createMemo(() => {
|
||||
const mode = app.mode();
|
||||
return (
|
||||
mode === "idle" ||
|
||||
mode === "thinking" ||
|
||||
mode === "tool_execution" ||
|
||||
mode === "editing"
|
||||
);
|
||||
});
|
||||
|
||||
const scrollUp = (): void => {
|
||||
if (!scrollboxRef) return;
|
||||
setStickyEnabled(false);
|
||||
scrollboxRef.scrollBy(-SCROLL_LINES);
|
||||
};
|
||||
|
||||
const scrollDown = (): void => {
|
||||
if (!scrollboxRef) return;
|
||||
scrollboxRef.scrollBy(SCROLL_LINES);
|
||||
|
||||
const isAtBottom =
|
||||
scrollboxRef.scrollTop >=
|
||||
scrollboxRef.content.height - scrollboxRef.viewport.height - 1;
|
||||
if (isAtBottom) {
|
||||
setStickyEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = (): void => {
|
||||
if (!scrollboxRef) return;
|
||||
scrollboxRef.scrollTo(Infinity);
|
||||
setStickyEnabled(true);
|
||||
};
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!canScroll()) return;
|
||||
|
||||
if (evt.shift && evt.name === "up") {
|
||||
scrollUp();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.shift && evt.name === "down") {
|
||||
scrollDown();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.shift && evt.name === "home") {
|
||||
if (scrollboxRef) {
|
||||
setStickyEnabled(false);
|
||||
scrollboxRef.scrollTo(0);
|
||||
}
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.shift && evt.name === "end") {
|
||||
scrollToBottom();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
process.stdout.write(MOUSE_ENABLE);
|
||||
|
||||
const handleData = (data: Buffer): void => {
|
||||
if (!canScroll()) return;
|
||||
|
||||
const str = data.toString();
|
||||
const direction = parseMouseScroll(str);
|
||||
|
||||
if (direction === "up") {
|
||||
scrollUp();
|
||||
} else if (direction === "down") {
|
||||
scrollDown();
|
||||
}
|
||||
};
|
||||
|
||||
process.stdin.on("data", handleData);
|
||||
|
||||
onCleanup(() => {
|
||||
process.stdout.write(MOUSE_DISABLE);
|
||||
process.stdin.off("data", handleData);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
borderColor={theme.colors.border}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
>
|
||||
<Show
|
||||
when={hasContent()}
|
||||
fallback={
|
||||
<box flexGrow={1} alignItems="center" justifyContent="center">
|
||||
<text fg={theme.colors.textDim}>
|
||||
No messages yet. Type your prompt below.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox
|
||||
ref={scrollboxRef}
|
||||
stickyScroll={stickyEnabled()}
|
||||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
>
|
||||
<box flexDirection="column">
|
||||
<For each={logs()}>
|
||||
{(entry) => <LogEntryDisplay entry={entry} />}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
18
src/tui-solid/components/logo.tsx
Normal file
18
src/tui-solid/components/logo.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
const GRADIENT_COLORS = [
|
||||
"#ff55ff",
|
||||
"#dd66ff",
|
||||
"#bb77ff",
|
||||
"#9988ff",
|
||||
"#7799ff",
|
||||
"#55aaff",
|
||||
"#33bbff",
|
||||
"#00ccff",
|
||||
] as const;
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<box flexDirection="column" alignItems="center">
|
||||
<ascii_font text="CODETYPER" font="block" color={[...GRADIENT_COLORS]} />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
272
src/tui-solid/components/mcp-add-form.tsx
Normal file
272
src/tui-solid/components/mcp-add-form.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { MCPAddFormData } from "@/types/mcp";
|
||||
|
||||
interface MCPAddFormProps {
|
||||
onSubmit: (data: MCPAddFormData) => void;
|
||||
onClose: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
type FormField = "name" | "command" | "args" | "scope";
|
||||
|
||||
const FIELD_ORDER: FormField[] = ["name", "command", "args", "scope"];
|
||||
|
||||
const FIELD_LABELS: Record<FormField, string> = {
|
||||
name: "Server Name",
|
||||
command: "Command",
|
||||
args: "Arguments (space-separated)",
|
||||
scope: "Scope",
|
||||
};
|
||||
|
||||
const FIELD_PLACEHOLDERS: Record<FormField, string> = {
|
||||
name: "e.g., sqlite",
|
||||
command: "e.g., npx",
|
||||
args: "e.g., @modelcontextprotocol/server-sqlite",
|
||||
scope: "",
|
||||
};
|
||||
|
||||
export function MCPAddForm(props: MCPAddFormProps) {
|
||||
const theme = useTheme();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const [currentField, setCurrentField] = createSignal<FormField>("name");
|
||||
const [name, setName] = createSignal("");
|
||||
const [command, setCommand] = createSignal("");
|
||||
const [args, setArgs] = createSignal("");
|
||||
const [isGlobal, setIsGlobal] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
const getFieldValue = (field: FormField): string => {
|
||||
const fieldGetters: Record<FormField, () => string> = {
|
||||
name: name,
|
||||
command: command,
|
||||
args: args,
|
||||
scope: () => (isGlobal() ? "global" : "local"),
|
||||
};
|
||||
return fieldGetters[field]();
|
||||
};
|
||||
|
||||
const setFieldValue = (field: FormField, value: string): void => {
|
||||
const fieldSetters: Record<FormField, (v: string) => void> = {
|
||||
name: setName,
|
||||
command: setCommand,
|
||||
args: setArgs,
|
||||
scope: () => setIsGlobal(value === "global"),
|
||||
};
|
||||
fieldSetters[field](value);
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
setError(null);
|
||||
|
||||
if (!name().trim()) {
|
||||
setError("Server name is required");
|
||||
setCurrentField("name");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command().trim()) {
|
||||
setError("Command is required");
|
||||
setCurrentField("command");
|
||||
return;
|
||||
}
|
||||
|
||||
props.onSubmit({
|
||||
name: name().trim(),
|
||||
command: command().trim(),
|
||||
args: args().trim(),
|
||||
isGlobal: isGlobal(),
|
||||
});
|
||||
};
|
||||
|
||||
const moveToNextField = (): void => {
|
||||
const currentIndex = FIELD_ORDER.indexOf(currentField());
|
||||
if (currentIndex < FIELD_ORDER.length - 1) {
|
||||
setCurrentField(FIELD_ORDER[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const moveToPrevField = (): void => {
|
||||
const currentIndex = FIELD_ORDER.indexOf(currentField());
|
||||
if (currentIndex > 0) {
|
||||
setCurrentField(FIELD_ORDER[currentIndex - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const field = currentField();
|
||||
|
||||
if (evt.name === "return") {
|
||||
if (field === "scope") {
|
||||
handleSubmit();
|
||||
} else {
|
||||
moveToNextField();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "tab") {
|
||||
if (evt.shift) {
|
||||
moveToPrevField();
|
||||
} else {
|
||||
moveToNextField();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
if (field === "scope") {
|
||||
setIsGlobal(!isGlobal());
|
||||
} else {
|
||||
moveToPrevField();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
if (field === "scope") {
|
||||
setIsGlobal(!isGlobal());
|
||||
} else {
|
||||
moveToNextField();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === "scope") {
|
||||
if (evt.name === "space" || evt.name === "left" || evt.name === "right") {
|
||||
setIsGlobal(!isGlobal());
|
||||
evt.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "backspace" || evt.name === "delete") {
|
||||
const currentValue = getFieldValue(field);
|
||||
if (currentValue.length > 0) {
|
||||
setFieldValue(field, currentValue.slice(0, -1));
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFieldValue(field, getFieldValue(field) + evt.name);
|
||||
setError(null);
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
const renderField = (field: FormField) => {
|
||||
const isCurrentField = currentField() === field;
|
||||
const value = getFieldValue(field);
|
||||
const placeholder = FIELD_PLACEHOLDERS[field];
|
||||
|
||||
if (field === "scope") {
|
||||
return (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text
|
||||
fg={isCurrentField ? theme.colors.primary : theme.colors.text}
|
||||
attributes={isCurrentField ? TextAttributes.BOLD : TextAttributes.NONE}
|
||||
>
|
||||
{isCurrentField ? "> " : " "}
|
||||
{FIELD_LABELS[field]}:{" "}
|
||||
</text>
|
||||
<text
|
||||
fg={!isGlobal() ? theme.colors.success : theme.colors.textDim}
|
||||
attributes={!isGlobal() && isCurrentField ? TextAttributes.BOLD : TextAttributes.NONE}
|
||||
>
|
||||
[Local]
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> / </text>
|
||||
<text
|
||||
fg={isGlobal() ? theme.colors.warning : theme.colors.textDim}
|
||||
attributes={isGlobal() && isCurrentField ? TextAttributes.BOLD : TextAttributes.NONE}
|
||||
>
|
||||
[Global]
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text
|
||||
fg={isCurrentField ? theme.colors.primary : theme.colors.text}
|
||||
attributes={isCurrentField ? TextAttributes.BOLD : TextAttributes.NONE}
|
||||
>
|
||||
{isCurrentField ? "> " : " "}
|
||||
{FIELD_LABELS[field]}:{" "}
|
||||
</text>
|
||||
<Show
|
||||
when={value}
|
||||
fallback={
|
||||
<text fg={theme.colors.textDim}>
|
||||
{placeholder}
|
||||
{isCurrentField ? "_" : ""}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text fg={theme.colors.text}>
|
||||
{value}
|
||||
{isCurrentField ? "_" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.primary}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
Add MCP Server
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<Show when={error()}>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.error}>{error()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{renderField("name")}
|
||||
{renderField("command")}
|
||||
{renderField("args")}
|
||||
{renderField("scope")}
|
||||
|
||||
<box marginTop={1} flexDirection="column">
|
||||
<text fg={theme.colors.textDim}>
|
||||
Tab/Enter next | Shift+Tab prev | ↑↓ navigate | Esc cancel
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
Enter on Scope to submit
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
161
src/tui-solid/components/mcp-select.tsx
Normal file
161
src/tui-solid/components/mcp-select.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
|
||||
interface MCPServer {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "connected" | "disconnected" | "error";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MCPSelectProps {
|
||||
servers: MCPServer[];
|
||||
onSelect: (serverId: string) => void;
|
||||
onAddNew: () => void;
|
||||
onClose: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<MCPServer["status"], string> = {
|
||||
connected: "success",
|
||||
disconnected: "textDim",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
export function MCPSelect(props: MCPSelectProps) {
|
||||
const theme = useTheme();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
const totalItems = createMemo(() => props.servers.length + 1);
|
||||
|
||||
const isAddNewSelected = createMemo(
|
||||
() => selectedIndex() === props.servers.length,
|
||||
);
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
if (isAddNewSelected()) {
|
||||
props.onAddNew();
|
||||
} else {
|
||||
const server = props.servers[selectedIndex()];
|
||||
if (server) {
|
||||
props.onSelect(server.id);
|
||||
props.onClose();
|
||||
}
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems() - 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
setSelectedIndex((prev) => (prev < totalItems() - 1 ? prev + 1 : 0));
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.info}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.info} attributes={TextAttributes.BOLD}>
|
||||
MCP Servers
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<Show when={props.servers.length > 0}>
|
||||
<For each={props.servers}>
|
||||
{(server, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
const statusColorKey = STATUS_COLORS[
|
||||
server.status
|
||||
] as keyof typeof theme.colors;
|
||||
const statusColor = theme.colors[statusColorKey];
|
||||
const statusColorStr =
|
||||
typeof statusColor === "string"
|
||||
? statusColor
|
||||
: theme.colors.textDim;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.info : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text fg={statusColorStr}>● </text>
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.info : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{server.name}
|
||||
</text>
|
||||
<Show when={server.description}>
|
||||
<text fg={theme.colors.textDim}> - {server.description}</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={props.servers.length === 0}>
|
||||
<text fg={theme.colors.textDim}>No MCP servers configured</text>
|
||||
</Show>
|
||||
|
||||
<box flexDirection="row" marginTop={props.servers.length > 0 ? 1 : 0}>
|
||||
<text
|
||||
fg={isAddNewSelected() ? theme.colors.success : undefined}
|
||||
attributes={
|
||||
isAddNewSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isAddNewSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text fg={theme.colors.success}>+ </text>
|
||||
<text
|
||||
fg={isAddNewSelected() ? theme.colors.success : theme.colors.text}
|
||||
attributes={
|
||||
isAddNewSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
Add new server
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter select | Esc close
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
148
src/tui-solid/components/mode-select.tsx
Normal file
148
src/tui-solid/components/mode-select.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { createSignal, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import type { InteractionMode } from "@/types/tui";
|
||||
|
||||
interface ModeOption {
|
||||
id: InteractionMode;
|
||||
name: string;
|
||||
description: string;
|
||||
color: "warning" | "info" | "success";
|
||||
}
|
||||
|
||||
const MODE_OPTIONS: ModeOption[] = [
|
||||
{
|
||||
id: "agent",
|
||||
name: "Agent",
|
||||
description: "Full access - can read, write, and execute commands",
|
||||
color: "warning",
|
||||
},
|
||||
{
|
||||
id: "ask",
|
||||
name: "Ask",
|
||||
description: "Read-only - answers questions without modifying files",
|
||||
color: "info",
|
||||
},
|
||||
{
|
||||
id: "code-review",
|
||||
name: "Code Review",
|
||||
description: "Review PRs, diffs, and provide feedback on code changes",
|
||||
color: "success",
|
||||
},
|
||||
];
|
||||
|
||||
interface ModeSelectProps {
|
||||
onSelect: (mode: InteractionMode) => void;
|
||||
onClose: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function ModeSelect(props: ModeSelectProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const currentModeIndex = () =>
|
||||
MODE_OPTIONS.findIndex((m) => m.id === app.interactionMode());
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(
|
||||
currentModeIndex() >= 0 ? currentModeIndex() : 0,
|
||||
);
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const mode = MODE_OPTIONS[selectedIndex()];
|
||||
if (mode) {
|
||||
props.onSelect(mode.id);
|
||||
props.onClose();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : MODE_OPTIONS.length - 1,
|
||||
);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
setSelectedIndex((prev) =>
|
||||
prev < MODE_OPTIONS.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.primary}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
Select Mode
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<For each={MODE_OPTIONS}>
|
||||
{(mode, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
const isCurrent = () => mode.id === app.interactionMode();
|
||||
const modeColor = theme.colors[mode.color];
|
||||
|
||||
return (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.primary : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSelected() ? modeColor : theme.colors.text}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{mode.name}
|
||||
</text>
|
||||
{isCurrent() && (
|
||||
<text fg={theme.colors.success}> (current)</text>
|
||||
)}
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.colors.textDim}> {mode.description}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter select | Esc close
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
283
src/tui-solid/components/model-select.tsx
Normal file
283
src/tui-solid/components/model-select.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import type { ProviderModel } from "@/types/providers";
|
||||
|
||||
interface ModelSelectProps {
|
||||
onSelect: (model: string) => void;
|
||||
onClose: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 10;
|
||||
|
||||
const AUTO_MODEL: ProviderModel = {
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
supportsTools: true,
|
||||
supportsStreaming: true,
|
||||
costMultiplier: undefined,
|
||||
isUnlimited: true,
|
||||
};
|
||||
|
||||
const formatCostMultiplier = (model: ProviderModel): string => {
|
||||
if (model.id === "auto") return "";
|
||||
|
||||
const multiplier = model.costMultiplier;
|
||||
if (multiplier === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (multiplier === 0 || model.isUnlimited) {
|
||||
return "Unlimited";
|
||||
}
|
||||
return `${multiplier}x`;
|
||||
};
|
||||
|
||||
const getCostColor = (
|
||||
model: ProviderModel,
|
||||
theme: ReturnType<typeof useTheme>,
|
||||
): string => {
|
||||
if (model.id === "auto") return theme.colors.textDim;
|
||||
|
||||
const multiplier = model.costMultiplier;
|
||||
if (multiplier === undefined) {
|
||||
return theme.colors.textDim;
|
||||
}
|
||||
if (multiplier === 0 || model.isUnlimited) {
|
||||
return theme.colors.success;
|
||||
}
|
||||
if (multiplier <= 0.1) {
|
||||
return theme.colors.primary;
|
||||
}
|
||||
if (multiplier <= 1.0) {
|
||||
return theme.colors.warning;
|
||||
}
|
||||
return theme.colors.error;
|
||||
};
|
||||
|
||||
export function ModelSelect(props: ModelSelectProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [scrollOffset, setScrollOffset] = createSignal(0);
|
||||
const [filter, setFilter] = createSignal("");
|
||||
|
||||
const allModels = createMemo((): ProviderModel[] => {
|
||||
return [AUTO_MODEL, ...app.availableModels()];
|
||||
});
|
||||
|
||||
const filteredModels = createMemo((): ProviderModel[] => {
|
||||
if (!filter()) return allModels();
|
||||
const query = filter().toLowerCase();
|
||||
return allModels().filter(
|
||||
(model) =>
|
||||
model.id.toLowerCase().includes(query) ||
|
||||
model.name.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const models = filteredModels();
|
||||
if (models.length > 0) {
|
||||
const selected = models[selectedIndex()];
|
||||
if (selected) {
|
||||
props.onSelect(selected.id);
|
||||
props.onClose();
|
||||
}
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
const models = filteredModels();
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev > 0 ? prev - 1 : models.length - 1;
|
||||
if (newIndex < scrollOffset()) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
if (prev === 0 && newIndex === models.length - 1) {
|
||||
setScrollOffset(Math.max(0, models.length - MAX_VISIBLE));
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
const models = filteredModels();
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev < models.length - 1 ? prev + 1 : 0;
|
||||
if (newIndex >= scrollOffset() + MAX_VISIBLE) {
|
||||
setScrollOffset(newIndex - MAX_VISIBLE + 1);
|
||||
}
|
||||
if (prev === models.length - 1 && newIndex === 0) {
|
||||
setScrollOffset(0);
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "backspace" || evt.name === "delete") {
|
||||
if (filter().length > 0) {
|
||||
setFilter(filter().slice(0, -1));
|
||||
setSelectedIndex(0);
|
||||
setScrollOffset(0);
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFilter(filter() + evt.name);
|
||||
setSelectedIndex(0);
|
||||
setScrollOffset(0);
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
const visibleModels = createMemo(() =>
|
||||
filteredModels().slice(scrollOffset(), scrollOffset() + MAX_VISIBLE),
|
||||
);
|
||||
|
||||
const hasMoreAbove = () => scrollOffset() > 0;
|
||||
const hasMoreBelow = () =>
|
||||
scrollOffset() + MAX_VISIBLE < filteredModels().length;
|
||||
const moreAboveCount = () => scrollOffset();
|
||||
const moreBelowCount = () =>
|
||||
filteredModels().length - scrollOffset() - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.accent}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.accent} attributes={TextAttributes.BOLD}>
|
||||
Select Model
|
||||
</text>
|
||||
<Show when={filter()}>
|
||||
<text fg={theme.colors.textDim}> - filtering: </text>
|
||||
<text fg={theme.colors.warning}>{filter()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Current: </text>
|
||||
<text fg={theme.colors.primary}>{app.model()}</text>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={filteredModels().length > 0}
|
||||
fallback={
|
||||
<text fg={theme.colors.textDim}>No models match "{filter()}"</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="column">
|
||||
<Show when={hasMoreAbove()}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
↑ {moreAboveCount()} more above
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<For each={visibleModels()}>
|
||||
{(model, visibleIndex) => {
|
||||
const actualIndex = () => scrollOffset() + visibleIndex();
|
||||
const isSelected = () => actualIndex() === selectedIndex();
|
||||
const isCurrent = () => model.id === app.model();
|
||||
const isAuto = () => model.id === "auto";
|
||||
const costLabel = () => formatCostMultiplier(model);
|
||||
const costColor = () => getCostColor(model, theme);
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.accent : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
isAuto()
|
||||
? theme.colors.warning
|
||||
: isSelected()
|
||||
? theme.colors.accent
|
||||
: undefined
|
||||
}
|
||||
attributes={
|
||||
isSelected() || isAuto()
|
||||
? TextAttributes.BOLD
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{model.id}
|
||||
</text>
|
||||
<Show when={costLabel() && !isAuto()}>
|
||||
<text fg={costColor()}> [{costLabel()}]</text>
|
||||
</Show>
|
||||
<Show when={isCurrent()}>
|
||||
<text fg={theme.colors.success}> (current)</text>
|
||||
</Show>
|
||||
<Show when={isAuto()}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
- Let Copilot choose the best model
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={hasMoreBelow()}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
↓ {moreBelowCount()} more below
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box marginTop={1} flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Cost: </text>
|
||||
<text fg={theme.colors.success}>Unlimited</text>
|
||||
<text fg={theme.colors.textDim}> | </text>
|
||||
<text fg={theme.colors.primary}>Low</text>
|
||||
<text fg={theme.colors.textDim}> | </text>
|
||||
<text fg={theme.colors.warning}>Standard</text>
|
||||
<text fg={theme.colors.textDim}> | </text>
|
||||
<text fg={theme.colors.error}>Premium</text>
|
||||
</box>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter select | Type to filter | Esc close
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
173
src/tui-solid/components/permission-modal.tsx
Normal file
173
src/tui-solid/components/permission-modal.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { createSignal, Show, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { PermissionRequest, PermissionScope } from "@/types/tui";
|
||||
|
||||
interface PermissionModalProps {
|
||||
request: PermissionRequest;
|
||||
onRespond?: (allowed: boolean, scope?: PermissionScope) => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
scope: PermissionScope;
|
||||
}> = [
|
||||
{ key: "y", label: "Yes, this once", scope: "once" },
|
||||
{ key: "s", label: "Yes, for this session", scope: "session" },
|
||||
{ key: "a", label: "Always allow for this project", scope: "local" },
|
||||
{ key: "g", label: "Always allow globally", scope: "global" },
|
||||
];
|
||||
|
||||
export function PermissionModal(props: PermissionModalProps) {
|
||||
const theme = useTheme();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const handleResponse = (allowed: boolean, scope?: PermissionScope): void => {
|
||||
// Call the resolve function on the request to complete the permission prompt
|
||||
if (props.request.resolve) {
|
||||
props.request.resolve({ allowed, scope });
|
||||
}
|
||||
// Also call the onRespond callback for UI state updates
|
||||
props.onRespond?.(allowed, scope);
|
||||
};
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : SCOPE_OPTIONS.length));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
setSelectedIndex((prev) => (prev < SCOPE_OPTIONS.length ? prev + 1 : 0));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
if (selectedIndex() === SCOPE_OPTIONS.length) {
|
||||
handleResponse(false);
|
||||
} else {
|
||||
const option = SCOPE_OPTIONS[selectedIndex()];
|
||||
handleResponse(true, option.scope);
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "escape" || evt.name === "n") {
|
||||
handleResponse(false);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1) {
|
||||
const charLower = evt.name.toLowerCase();
|
||||
const optionIndex = SCOPE_OPTIONS.findIndex((o) => o.key === charLower);
|
||||
if (optionIndex !== -1) {
|
||||
const option = SCOPE_OPTIONS[optionIndex];
|
||||
handleResponse(true, option.scope);
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.borderWarning}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.warning} attributes={TextAttributes.BOLD}>
|
||||
⚠ Permission Required
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.text}>
|
||||
{props.request.type.toUpperCase()}: {props.request.description}
|
||||
</text>
|
||||
<Show when={props.request.command}>
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>Command: </text>
|
||||
<text fg={theme.colors.primary}>{props.request.command}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={props.request.path}>
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>Path: </text>
|
||||
<text fg={theme.colors.primary}>{props.request.path}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<For each={SCOPE_OPTIONS}>
|
||||
{(option, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.primary : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text fg={theme.colors.success}>[{option.key}] </text>
|
||||
<text fg={isSelected() ? theme.colors.primary : undefined}>
|
||||
{option.label}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={
|
||||
selectedIndex() === SCOPE_OPTIONS.length
|
||||
? theme.colors.primary
|
||||
: undefined
|
||||
}
|
||||
attributes={
|
||||
selectedIndex() === SCOPE_OPTIONS.length
|
||||
? TextAttributes.BOLD
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{selectedIndex() === SCOPE_OPTIONS.length ? "> " : " "}
|
||||
</text>
|
||||
<text fg={theme.colors.error}>[n] </text>
|
||||
<text
|
||||
fg={
|
||||
selectedIndex() === SCOPE_OPTIONS.length
|
||||
? theme.colors.primary
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
No, deny this request
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter select | Press shortcut key
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
235
src/tui-solid/components/provider-select.tsx
Normal file
235
src/tui-solid/components/provider-select.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import type { ProviderStatus } from "@services/cascading-provider";
|
||||
|
||||
interface ProviderOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: ProviderStatus;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface ProviderSelectProps {
|
||||
onSelect: (providerId: string) => void;
|
||||
onClose: () => void;
|
||||
onToggleCascade?: () => void;
|
||||
isActive?: boolean;
|
||||
cascadeEnabled?: boolean;
|
||||
providerStatuses?: Record<string, ProviderStatus>;
|
||||
providerScores?: Record<string, number>;
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDERS: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
description: "Local LLM - fast, private, no API costs",
|
||||
},
|
||||
{
|
||||
id: "copilot",
|
||||
name: "Copilot",
|
||||
description: "GitHub Copilot - cloud-based, high quality",
|
||||
},
|
||||
];
|
||||
|
||||
export function ProviderSelect(props: ProviderSelectProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
const providers = createMemo((): ProviderOption[] => {
|
||||
return DEFAULT_PROVIDERS.map((p) => ({
|
||||
...p,
|
||||
status: props.providerStatuses?.[p.id] ?? {
|
||||
available: true,
|
||||
lastChecked: Date.now(),
|
||||
},
|
||||
score: props.providerScores?.[p.id],
|
||||
}));
|
||||
});
|
||||
|
||||
const getStatusColor = (status: ProviderStatus): string => {
|
||||
if (status.available) {
|
||||
return theme.colors.success;
|
||||
}
|
||||
return theme.colors.error;
|
||||
};
|
||||
|
||||
const getStatusText = (status: ProviderStatus): string => {
|
||||
if (status.available) {
|
||||
return "● Available";
|
||||
}
|
||||
return "○ Unavailable";
|
||||
};
|
||||
|
||||
const getScoreColor = (score?: number): string => {
|
||||
if (score === undefined) return theme.colors.textDim;
|
||||
if (score >= 0.85) return theme.colors.success;
|
||||
if (score >= 0.6) return theme.colors.warning;
|
||||
return theme.colors.error;
|
||||
};
|
||||
|
||||
const formatScore = (score?: number): string => {
|
||||
if (score === undefined) return "N/A";
|
||||
return `${Math.round(score * 100)}%`;
|
||||
};
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const selected = providers()[selectedIndex()];
|
||||
if (selected && selected.status.available) {
|
||||
props.onSelect(selected.id);
|
||||
props.onClose();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) => {
|
||||
return prev > 0 ? prev - 1 : providers().length - 1;
|
||||
});
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
setSelectedIndex((prev) => {
|
||||
return prev < providers().length - 1 ? prev + 1 : 0;
|
||||
});
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
|
||||
props.onToggleCascade?.();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.accent}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.accent} attributes={TextAttributes.BOLD}>
|
||||
Select Provider
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Current: </text>
|
||||
<text fg={theme.colors.primary}>{app.provider()}</text>
|
||||
</box>
|
||||
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Cascade Mode: </text>
|
||||
<text
|
||||
fg={props.cascadeEnabled ? theme.colors.success : theme.colors.error}
|
||||
>
|
||||
{props.cascadeEnabled ? "Enabled" : "Disabled"}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> (press 'c' to toggle)</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="column">
|
||||
<For each={providers()}>
|
||||
{(provider, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
const isCurrent = () => provider.id === app.provider();
|
||||
const isAvailable = () => provider.status.available;
|
||||
|
||||
return (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={
|
||||
isSelected()
|
||||
? theme.colors.accent
|
||||
: isAvailable()
|
||||
? undefined
|
||||
: theme.colors.textDim
|
||||
}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
isSelected()
|
||||
? theme.colors.accent
|
||||
: isAvailable()
|
||||
? undefined
|
||||
: theme.colors.textDim
|
||||
}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{provider.name}
|
||||
</text>
|
||||
<text fg={getStatusColor(provider.status)}>
|
||||
{" "}
|
||||
{getStatusText(provider.status)}
|
||||
</text>
|
||||
<Show when={isCurrent()}>
|
||||
<text fg={theme.colors.success}> (current)</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" marginLeft={4}>
|
||||
<text fg={theme.colors.textDim}>{provider.description}</text>
|
||||
</box>
|
||||
<Show when={provider.id === "ollama" && provider.score !== undefined}>
|
||||
<box flexDirection="row" marginLeft={4}>
|
||||
<text fg={theme.colors.textDim}>Quality Score: </text>
|
||||
<text fg={getScoreColor(provider.score)}>
|
||||
{formatScore(provider.score)}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
|
||||
<box marginTop={1} flexDirection="column">
|
||||
<Show when={props.cascadeEnabled}>
|
||||
<text fg={theme.colors.info}>
|
||||
Cascade: Ollama runs first, Copilot audits for quality
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter select | c toggle cascade | Esc close
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
119
src/tui-solid/components/select-menu.tsx
Normal file
119
src/tui-solid/components/select-menu.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createSignal, For, Show, createMemo } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { SelectOption } from "@/types/tui";
|
||||
|
||||
export type { SelectOption } from "@/types/tui";
|
||||
|
||||
interface SelectMenuProps {
|
||||
options: SelectOption[];
|
||||
onSelect: (option: SelectOption) => void;
|
||||
title?: string;
|
||||
initialIndex?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function SelectMenu(props: SelectMenuProps) {
|
||||
const theme = useTheme();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(
|
||||
props.initialIndex ?? 0,
|
||||
);
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : props.options.length - 1,
|
||||
);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
setSelectedIndex((prev) =>
|
||||
prev < props.options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
props.onSelect(props.options[selectedIndex()]);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1) {
|
||||
const lowerInput = evt.name.toLowerCase();
|
||||
const optionIndex = props.options.findIndex(
|
||||
(o) => o.key?.toLowerCase() === lowerInput,
|
||||
);
|
||||
if (optionIndex !== -1) {
|
||||
props.onSelect(props.options[optionIndex]);
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const hasShortcuts = createMemo(() => props.options.some((o) => o.key));
|
||||
|
||||
return (
|
||||
<box flexDirection="column" backgroundColor={theme.colors.background}>
|
||||
<Show when={props.title}>
|
||||
<box marginBottom={1}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.colors.warning}>
|
||||
{props.title}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<For each={props.options}>
|
||||
{(option, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.primary : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "❯ " : " "}
|
||||
</text>
|
||||
<Show when={option.key}>
|
||||
<text
|
||||
fg={
|
||||
isSelected() ? theme.colors.primary : theme.colors.textDim
|
||||
}
|
||||
>
|
||||
[{option.key}]{" "}
|
||||
</text>
|
||||
</Show>
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.primary : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
<Show when={option.description}>
|
||||
<text fg={theme.colors.textDim}> - {option.description}</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<box marginTop={1} flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>↑↓ to navigate • Enter to select</text>
|
||||
<Show when={hasShortcuts()}>
|
||||
<text fg={theme.colors.textDim}> • or press shortcut key</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
212
src/tui-solid/components/status-bar.tsx
Normal file
212
src/tui-solid/components/status-bar.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { createSignal, createEffect, onCleanup, createMemo } from "solid-js";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import {
|
||||
STATUS_HINTS,
|
||||
STATUS_SEPARATOR,
|
||||
TIME_UNITS,
|
||||
TOKEN_DISPLAY,
|
||||
} from "@constants/ui";
|
||||
import { getThinkingMessage, getToolMessage } from "@constants/status-messages";
|
||||
import {
|
||||
MODE_DISPLAY_CONFIG,
|
||||
DEFAULT_MODE_DISPLAY,
|
||||
type ModeDisplayConfig,
|
||||
} from "@constants/tui-components";
|
||||
import type { AppMode, ToolCall } from "@/types/tui";
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / TIME_UNITS.SECOND);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const formatTokens = (count: number): string => {
|
||||
if (count >= TOKEN_DISPLAY.K_THRESHOLD) {
|
||||
return `${(count / TOKEN_DISPLAY.K_THRESHOLD).toFixed(TOKEN_DISPLAY.DECIMALS)}k`;
|
||||
}
|
||||
return String(count);
|
||||
};
|
||||
|
||||
const getModeDisplay = (
|
||||
mode: AppMode,
|
||||
statusMessage: string,
|
||||
currentToolCall: ToolCall | null,
|
||||
): ModeDisplayConfig => {
|
||||
const baseConfig = MODE_DISPLAY_CONFIG[mode] ?? DEFAULT_MODE_DISPLAY;
|
||||
|
||||
if (mode === "thinking" && statusMessage) {
|
||||
return { ...baseConfig, text: statusMessage };
|
||||
}
|
||||
|
||||
if (mode === "tool_execution") {
|
||||
const toolText =
|
||||
statusMessage || `✻ Running ${currentToolCall?.name ?? "tool"}…`;
|
||||
return { ...baseConfig, text: toolText };
|
||||
}
|
||||
|
||||
return baseConfig;
|
||||
};
|
||||
|
||||
export function StatusBar() {
|
||||
const app = useAppStore();
|
||||
const theme = useTheme();
|
||||
|
||||
const [elapsed, setElapsed] = createSignal(0);
|
||||
const [thinkingTime, setThinkingTime] = createSignal(0);
|
||||
const [statusMessage, setStatusMessage] = createSignal("");
|
||||
|
||||
let prevMode: AppMode | null = null;
|
||||
let prevToolName: string | undefined = undefined;
|
||||
|
||||
// Elapsed time tracking
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null;
|
||||
createEffect(() => {
|
||||
const startTime = app.sessionStats().startTime;
|
||||
if (elapsedTimer) clearInterval(elapsedTimer);
|
||||
elapsedTimer = setInterval(() => {
|
||||
setElapsed(Date.now() - startTime);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (elapsedTimer) clearInterval(elapsedTimer);
|
||||
});
|
||||
|
||||
// Thinking time tracking
|
||||
let thinkingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
createEffect(() => {
|
||||
const thinkingStart = app.sessionStats().thinkingStartTime;
|
||||
if (thinkingTimer) clearInterval(thinkingTimer);
|
||||
|
||||
if (thinkingStart === null) {
|
||||
setThinkingTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setThinkingTime(Math.floor((Date.now() - thinkingStart) / 1000));
|
||||
thinkingTimer = setInterval(() => {
|
||||
setThinkingTime(Math.floor((Date.now() - thinkingStart) / 1000));
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (thinkingTimer) clearInterval(thinkingTimer);
|
||||
});
|
||||
|
||||
// Rotating status message
|
||||
let messageTimer: ReturnType<typeof setInterval> | null = null;
|
||||
createEffect(() => {
|
||||
const mode = app.mode();
|
||||
const toolCall = app.currentToolCall();
|
||||
const isProcessing = mode === "thinking" || mode === "tool_execution";
|
||||
|
||||
if (messageTimer) clearInterval(messageTimer);
|
||||
|
||||
if (!isProcessing) {
|
||||
setStatusMessage("");
|
||||
return;
|
||||
}
|
||||
|
||||
const modeChanged = prevMode !== mode;
|
||||
const toolChanged = prevToolName !== toolCall?.name;
|
||||
|
||||
if (modeChanged || toolChanged) {
|
||||
if (mode === "thinking") {
|
||||
setStatusMessage(getThinkingMessage());
|
||||
} else if (mode === "tool_execution" && toolCall) {
|
||||
setStatusMessage(getToolMessage(toolCall.name, toolCall.description));
|
||||
}
|
||||
}
|
||||
|
||||
prevMode = mode;
|
||||
prevToolName = toolCall?.name;
|
||||
|
||||
messageTimer = setInterval(() => {
|
||||
if (mode === "thinking") {
|
||||
setStatusMessage(getThinkingMessage());
|
||||
} else if (mode === "tool_execution" && toolCall) {
|
||||
setStatusMessage(getToolMessage(toolCall.name, toolCall.description));
|
||||
}
|
||||
}, 2500);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (messageTimer) clearInterval(messageTimer);
|
||||
});
|
||||
|
||||
const isProcessing = createMemo(
|
||||
() => app.mode() === "thinking" || app.mode() === "tool_execution",
|
||||
);
|
||||
|
||||
const totalTokens = createMemo(
|
||||
() => app.sessionStats().inputTokens + app.sessionStats().outputTokens,
|
||||
);
|
||||
|
||||
const hints = createMemo(() => {
|
||||
const result: string[] = [];
|
||||
|
||||
// Show mode toggle hint when idle
|
||||
if (!isProcessing()) {
|
||||
result.push("^Tab toggle mode");
|
||||
}
|
||||
|
||||
if (isProcessing()) {
|
||||
result.push(
|
||||
app.interruptPending()
|
||||
? STATUS_HINTS.INTERRUPT_CONFIRM
|
||||
: STATUS_HINTS.INTERRUPT,
|
||||
);
|
||||
}
|
||||
|
||||
if (app.todosVisible()) {
|
||||
result.push(STATUS_HINTS.TOGGLE_TODOS);
|
||||
}
|
||||
|
||||
result.push(formatDuration(elapsed()));
|
||||
|
||||
if (totalTokens() > 0) {
|
||||
result.push(`↓ ${formatTokens(totalTokens())} tokens`);
|
||||
}
|
||||
|
||||
const stats = app.sessionStats();
|
||||
if (stats.thinkingStartTime !== null) {
|
||||
result.push(`${thinkingTime()}s`);
|
||||
} else if (stats.lastThinkingDuration > 0 && app.mode() === "idle") {
|
||||
result.push(`thought for ${stats.lastThinkingDuration}s`);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const modeDisplay = createMemo(() =>
|
||||
getModeDisplay(app.mode(), statusMessage(), app.currentToolCall()),
|
||||
);
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
justifyContent="space-between"
|
||||
borderColor={theme.colors.border}
|
||||
border={["bottom"]}
|
||||
>
|
||||
<box>
|
||||
<text fg={modeDisplay().color}>{modeDisplay().text}</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.colors.textDim}>{hints().join(STATUS_SEPARATOR)}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
35
src/tui-solid/components/streaming-message.tsx
Normal file
35
src/tui-solid/components/streaming-message.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Show } from "solid-js";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { LogEntry } from "@/types/tui";
|
||||
import { Spinner } from "@tui-solid/ui/spinner";
|
||||
|
||||
interface StreamingMessageProps {
|
||||
entry: LogEntry;
|
||||
}
|
||||
|
||||
export function StreamingMessage(props: StreamingMessageProps) {
|
||||
const theme = useTheme();
|
||||
const isStreaming = () => props.entry.metadata?.isStreaming ?? false;
|
||||
const hasContent = () => Boolean(props.entry.content);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.roleAssistant} attributes={TextAttributes.BOLD}>
|
||||
CodeTyper
|
||||
</text>
|
||||
<Show when={isStreaming()}>
|
||||
<box marginLeft={1}>
|
||||
<Spinner />
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={hasContent()}>
|
||||
<box marginLeft={2}>
|
||||
<text wrapMode="word">{props.entry.content}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
119
src/tui-solid/components/theme-select.tsx
Normal file
119
src/tui-solid/components/theme-select.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createSignal, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { THEME_NAMES } from "@constants/themes";
|
||||
|
||||
interface ThemeSelectProps {
|
||||
onSelect: (theme: string) => void;
|
||||
onClose: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function ThemeSelect(props: ThemeSelectProps) {
|
||||
const theme = useTheme();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
const originalTheme = theme.themeName();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(
|
||||
THEME_NAMES.indexOf(theme.themeName()),
|
||||
);
|
||||
|
||||
const previewTheme = (index: number): void => {
|
||||
const themeName = THEME_NAMES[index];
|
||||
if (themeName) {
|
||||
theme.setTheme(themeName);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
// Restore original theme on cancel
|
||||
theme.setTheme(originalTheme);
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const selected = THEME_NAMES[selectedIndex()];
|
||||
if (selected) {
|
||||
props.onSelect(selected);
|
||||
props.onClose();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
const newIndex =
|
||||
selectedIndex() > 0 ? selectedIndex() - 1 : THEME_NAMES.length - 1;
|
||||
setSelectedIndex(newIndex);
|
||||
previewTheme(newIndex);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
const newIndex =
|
||||
selectedIndex() < THEME_NAMES.length - 1 ? selectedIndex() + 1 : 0;
|
||||
setSelectedIndex(newIndex);
|
||||
previewTheme(newIndex);
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.accent}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.accent} attributes={TextAttributes.BOLD}>
|
||||
Select Theme
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<For each={THEME_NAMES}>
|
||||
{(themeName, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
const isCurrent = () => themeName === theme.themeName();
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.accent : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.accent : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{themeName}
|
||||
</text>
|
||||
{isCurrent() && <text fg={theme.colors.success}> (current)</text>}
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter select | Esc close
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
38
src/tui-solid/components/thinking-indicator.tsx
Normal file
38
src/tui-solid/components/thinking-indicator.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createSignal, onCleanup, onMount } from "solid-js";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import {
|
||||
THINKING_SPINNER_FRAMES,
|
||||
THINKING_SPINNER_INTERVAL,
|
||||
} from "@constants/tui-components";
|
||||
|
||||
interface ThinkingIndicatorProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function ThinkingIndicator(props: ThinkingIndicatorProps) {
|
||||
const theme = useTheme();
|
||||
const [frame, setFrame] = createSignal(0);
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(() => {
|
||||
intervalId = setInterval(() => {
|
||||
setFrame((prev) => (prev + 1) % THINKING_SPINNER_FRAMES.length);
|
||||
}, THINKING_SPINNER_INTERVAL);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.modeThinking}>
|
||||
{THINKING_SPINNER_FRAMES[frame()]}{" "}
|
||||
</text>
|
||||
<text fg={theme.colors.modeThinking}>{props.message}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
120
src/tui-solid/components/todo-panel.tsx
Normal file
120
src/tui-solid/components/todo-panel.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { For, Show, createMemo } from "solid-js";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
priority?: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
title: string;
|
||||
items: TodoItem[];
|
||||
}
|
||||
|
||||
interface TodoPanelProps {
|
||||
plan: Plan | null;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
high: "error",
|
||||
medium: "warning",
|
||||
low: "info",
|
||||
};
|
||||
|
||||
export function TodoPanel(props: TodoPanelProps) {
|
||||
const theme = useTheme();
|
||||
const visible = () => props.visible ?? true;
|
||||
|
||||
const completedCount = createMemo(
|
||||
() => props.plan?.items.filter((i) => i.completed).length ?? 0,
|
||||
);
|
||||
|
||||
const totalCount = createMemo(() => props.plan?.items.length ?? 0);
|
||||
|
||||
const progress = createMemo(() => {
|
||||
if (totalCount() === 0) return 0;
|
||||
return Math.round((completedCount() / totalCount()) * 100);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={visible() && props.plan}>
|
||||
<box
|
||||
flexDirection="column"
|
||||
width={30}
|
||||
borderColor={theme.colors.border}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box
|
||||
marginBottom={1}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
Plan
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{completedCount()}/{totalCount()} ({progress()}%)
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<scrollbox stickyScroll={false} flexGrow={1}>
|
||||
<box flexDirection="column">
|
||||
<For each={props.plan!.items}>
|
||||
{(item) => {
|
||||
const priorityColorKey = item.priority
|
||||
? (PRIORITY_COLORS[
|
||||
item.priority
|
||||
] as keyof typeof theme.colors)
|
||||
: undefined;
|
||||
const priorityColor = priorityColorKey
|
||||
? theme.colors[priorityColorKey]
|
||||
: undefined;
|
||||
const priorityColorStr =
|
||||
typeof priorityColor === "string" ? priorityColor : undefined;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text
|
||||
fg={
|
||||
item.completed
|
||||
? theme.colors.success
|
||||
: theme.colors.textDim
|
||||
}
|
||||
>
|
||||
{item.completed ? "✓" : "○"}{" "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
item.completed
|
||||
? theme.colors.textDim
|
||||
: theme.colors.text
|
||||
}
|
||||
attributes={
|
||||
item.completed
|
||||
? TextAttributes.STRIKETHROUGH
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
wrapMode="word"
|
||||
>
|
||||
{item.text}
|
||||
</text>
|
||||
<Show when={priorityColorStr && !item.completed}>
|
||||
<text fg={priorityColorStr}> ●</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
7
src/tui-solid/constants/text-attributes.ts
Normal file
7
src/tui-solid/constants/text-attributes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Re-export TextAttributes from @opentui/core for convenient access
|
||||
export { TextAttributes } from "@opentui/core";
|
||||
|
||||
// Helper to combine multiple attributes
|
||||
export function combineAttributes(...attrs: number[]): number {
|
||||
return attrs.reduce((acc, attr) => acc | attr, 0);
|
||||
}
|
||||
979
src/tui-solid/context/app.tsx
Normal file
979
src/tui-solid/context/app.tsx
Normal file
@@ -0,0 +1,979 @@
|
||||
import { batch, type Accessor } from "solid-js";
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createSimpleContext } from "./helper";
|
||||
import type {
|
||||
AppMode,
|
||||
ScreenMode,
|
||||
InteractionMode,
|
||||
LogEntry,
|
||||
ToolCall,
|
||||
PermissionRequest,
|
||||
LearningPrompt,
|
||||
SessionStats,
|
||||
SuggestionPrompt,
|
||||
CommandMenuState,
|
||||
StreamingLogState,
|
||||
SuggestionState,
|
||||
} from "@/types/tui";
|
||||
import type { ProviderModel } from "@/types/providers";
|
||||
|
||||
interface AppStore {
|
||||
mode: AppMode;
|
||||
screenMode: ScreenMode;
|
||||
interactionMode: InteractionMode;
|
||||
currentAgent: string;
|
||||
inputBuffer: string;
|
||||
inputCursorPosition: number;
|
||||
logs: LogEntry[];
|
||||
currentToolCall: ToolCall | null;
|
||||
permissionRequest: PermissionRequest | null;
|
||||
learningPrompt: LearningPrompt | null;
|
||||
thinkingMessage: string | null;
|
||||
sessionId: string | null;
|
||||
provider: string;
|
||||
model: string;
|
||||
version: string;
|
||||
commandMenu: CommandMenuState;
|
||||
availableModels: ProviderModel[];
|
||||
sessionStats: SessionStats;
|
||||
todosVisible: boolean;
|
||||
interruptPending: boolean;
|
||||
exitPending: boolean;
|
||||
isCompacting: boolean;
|
||||
streamingLog: StreamingLogState;
|
||||
suggestions: SuggestionState;
|
||||
cascadeEnabled: boolean;
|
||||
}
|
||||
|
||||
interface AppContextValue {
|
||||
// State accessors
|
||||
mode: Accessor<AppMode>;
|
||||
screenMode: Accessor<ScreenMode>;
|
||||
interactionMode: Accessor<InteractionMode>;
|
||||
currentAgent: Accessor<string>;
|
||||
inputBuffer: Accessor<string>;
|
||||
inputCursorPosition: Accessor<number>;
|
||||
|
||||
// Input ref for text insertion
|
||||
setInputInsertFn: (fn: ((text: string) => void) | null) => void;
|
||||
insertText: (text: string) => void;
|
||||
logs: Accessor<LogEntry[]>;
|
||||
currentToolCall: Accessor<ToolCall | null>;
|
||||
permissionRequest: Accessor<PermissionRequest | null>;
|
||||
learningPrompt: Accessor<LearningPrompt | null>;
|
||||
thinkingMessage: Accessor<string | null>;
|
||||
sessionId: Accessor<string | null>;
|
||||
provider: Accessor<string>;
|
||||
model: Accessor<string>;
|
||||
version: Accessor<string>;
|
||||
commandMenu: Accessor<CommandMenuState>;
|
||||
availableModels: Accessor<ProviderModel[]>;
|
||||
sessionStats: Accessor<SessionStats>;
|
||||
todosVisible: Accessor<boolean>;
|
||||
interruptPending: Accessor<boolean>;
|
||||
exitPending: Accessor<boolean>;
|
||||
isCompacting: Accessor<boolean>;
|
||||
streamingLog: Accessor<StreamingLogState>;
|
||||
suggestions: Accessor<SuggestionState>;
|
||||
cascadeEnabled: Accessor<boolean>;
|
||||
|
||||
// Mode actions
|
||||
setMode: (mode: AppMode) => void;
|
||||
setScreenMode: (screenMode: ScreenMode) => void;
|
||||
setInteractionMode: (mode: InteractionMode) => void;
|
||||
toggleInteractionMode: () => void;
|
||||
setCurrentAgent: (agent: string) => void;
|
||||
|
||||
// Input actions
|
||||
setInputBuffer: (buffer: string) => void;
|
||||
setInputCursorPosition: (position: number) => void;
|
||||
appendToInput: (text: string) => void;
|
||||
clearInput: () => void;
|
||||
|
||||
// Log actions
|
||||
addLog: (entry: Omit<LogEntry, "id" | "timestamp">) => string;
|
||||
updateLog: (id: string, updates: Partial<LogEntry>) => void;
|
||||
clearLogs: () => void;
|
||||
|
||||
// Tool call actions
|
||||
setCurrentToolCall: (toolCall: ToolCall | null) => void;
|
||||
updateToolCall: (updates: Partial<ToolCall>) => void;
|
||||
|
||||
// Permission actions
|
||||
setPermissionRequest: (request: PermissionRequest | null) => void;
|
||||
|
||||
// Learning prompt actions
|
||||
setLearningPrompt: (prompt: LearningPrompt | null) => void;
|
||||
|
||||
// Thinking message
|
||||
setThinkingMessage: (message: string | null) => void;
|
||||
|
||||
// Session info
|
||||
setSessionInfo: (sessionId: string, provider: string, model: string) => void;
|
||||
setVersion: (version: string) => void;
|
||||
|
||||
// Command menu actions
|
||||
openCommandMenu: () => void;
|
||||
closeCommandMenu: () => void;
|
||||
transitionFromCommandMenu: (newMode: AppMode) => void;
|
||||
setCommandFilter: (filter: string) => void;
|
||||
setCommandSelectedIndex: (index: number) => void;
|
||||
|
||||
// Model actions
|
||||
setAvailableModels: (models: ProviderModel[]) => void;
|
||||
setModel: (model: string) => void;
|
||||
|
||||
// Provider actions
|
||||
setProvider: (provider: string) => void;
|
||||
setCascadeEnabled: (enabled: boolean) => void;
|
||||
toggleCascadeEnabled: () => void;
|
||||
|
||||
// Session stats actions
|
||||
startThinking: () => void;
|
||||
stopThinking: () => void;
|
||||
addTokens: (input: number, output: number) => void;
|
||||
resetSessionStats: () => void;
|
||||
|
||||
// UI state actions
|
||||
toggleTodos: () => void;
|
||||
setInterruptPending: (pending: boolean) => void;
|
||||
setExitPending: (pending: boolean) => void;
|
||||
setIsCompacting: (compacting: boolean) => void;
|
||||
|
||||
// Streaming actions
|
||||
startStreaming: () => string;
|
||||
appendStreamContent: (content: string) => void;
|
||||
completeStreaming: () => void;
|
||||
cancelStreaming: () => void;
|
||||
|
||||
// Suggestion actions
|
||||
setSuggestions: (suggestions: SuggestionPrompt[]) => void;
|
||||
clearSuggestions: () => void;
|
||||
selectSuggestion: (index: number) => void;
|
||||
nextSuggestion: () => void;
|
||||
prevSuggestion: () => void;
|
||||
hideSuggestions: () => void;
|
||||
showSuggestions: () => void;
|
||||
|
||||
// Computed
|
||||
isInputLocked: () => boolean;
|
||||
}
|
||||
|
||||
let logIdCounter = 0;
|
||||
const generateLogId = (): string => `log-${++logIdCounter}-${Date.now()}`;
|
||||
|
||||
const createInitialSessionStats = (): SessionStats => ({
|
||||
startTime: Date.now(),
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
thinkingStartTime: null,
|
||||
lastThinkingDuration: 0,
|
||||
});
|
||||
|
||||
const createInitialStreamingState = (): StreamingLogState => ({
|
||||
logId: null,
|
||||
content: "",
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
const createInitialSuggestionState = (): SuggestionState => ({
|
||||
suggestions: [],
|
||||
selectedIndex: 0,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const createInitialCommandMenu = (): CommandMenuState => ({
|
||||
isOpen: false,
|
||||
filter: "",
|
||||
selectedIndex: 0,
|
||||
});
|
||||
|
||||
export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
createSimpleContext<AppContextValue>({
|
||||
name: "AppStore",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<AppStore>({
|
||||
mode: "idle",
|
||||
screenMode: "home",
|
||||
interactionMode: "agent",
|
||||
currentAgent: "default",
|
||||
inputBuffer: "",
|
||||
inputCursorPosition: 0,
|
||||
logs: [],
|
||||
currentToolCall: null,
|
||||
permissionRequest: null,
|
||||
learningPrompt: null,
|
||||
thinkingMessage: null,
|
||||
sessionId: null,
|
||||
provider: "copilot",
|
||||
model: "",
|
||||
version: "0.1.0",
|
||||
commandMenu: createInitialCommandMenu(),
|
||||
availableModels: [],
|
||||
sessionStats: createInitialSessionStats(),
|
||||
todosVisible: true,
|
||||
interruptPending: false,
|
||||
exitPending: false,
|
||||
isCompacting: false,
|
||||
streamingLog: createInitialStreamingState(),
|
||||
suggestions: createInitialSuggestionState(),
|
||||
cascadeEnabled: true,
|
||||
});
|
||||
|
||||
// Input insert function (set by InputArea)
|
||||
let inputInsertFn: ((text: string) => void) | null = null;
|
||||
|
||||
const setInputInsertFn = (fn: ((text: string) => void) | null): void => {
|
||||
inputInsertFn = fn;
|
||||
};
|
||||
|
||||
const insertText = (text: string): void => {
|
||||
if (inputInsertFn) {
|
||||
inputInsertFn(text);
|
||||
}
|
||||
};
|
||||
|
||||
// State accessors
|
||||
const mode = (): AppMode => store.mode;
|
||||
const screenMode = (): ScreenMode => store.screenMode;
|
||||
const interactionMode = (): InteractionMode => store.interactionMode;
|
||||
const currentAgent = (): string => store.currentAgent;
|
||||
const inputBuffer = (): string => store.inputBuffer;
|
||||
const inputCursorPosition = (): number => store.inputCursorPosition;
|
||||
const logs = (): LogEntry[] => store.logs;
|
||||
const currentToolCall = (): ToolCall | null => store.currentToolCall;
|
||||
const permissionRequest = (): PermissionRequest | null =>
|
||||
store.permissionRequest;
|
||||
const learningPrompt = (): LearningPrompt | null => store.learningPrompt;
|
||||
const thinkingMessage = (): string | null => store.thinkingMessage;
|
||||
const sessionId = (): string | null => store.sessionId;
|
||||
const provider = (): string => store.provider;
|
||||
const model = (): string => store.model;
|
||||
const version = (): string => store.version;
|
||||
const commandMenu = (): CommandMenuState => store.commandMenu;
|
||||
const availableModels = (): ProviderModel[] => store.availableModels;
|
||||
const sessionStats = (): SessionStats => store.sessionStats;
|
||||
const todosVisible = (): boolean => store.todosVisible;
|
||||
const interruptPending = (): boolean => store.interruptPending;
|
||||
const exitPending = (): boolean => store.exitPending;
|
||||
const isCompacting = (): boolean => store.isCompacting;
|
||||
const streamingLog = (): StreamingLogState => store.streamingLog;
|
||||
const suggestions = (): SuggestionState => store.suggestions;
|
||||
const cascadeEnabled = (): boolean => store.cascadeEnabled;
|
||||
|
||||
// Mode actions
|
||||
const setMode = (newMode: AppMode): void => {
|
||||
setStore("mode", newMode);
|
||||
};
|
||||
|
||||
const setScreenMode = (newScreenMode: ScreenMode): void => {
|
||||
setStore("screenMode", newScreenMode);
|
||||
};
|
||||
|
||||
const setInteractionMode = (newMode: InteractionMode): void => {
|
||||
setStore("interactionMode", newMode);
|
||||
};
|
||||
|
||||
const toggleInteractionMode = (): void => {
|
||||
const modeOrder: InteractionMode[] = ["agent", "ask", "code-review"];
|
||||
const currentIndex = modeOrder.indexOf(store.interactionMode);
|
||||
const nextIndex = (currentIndex + 1) % modeOrder.length;
|
||||
setStore("interactionMode", modeOrder[nextIndex]);
|
||||
};
|
||||
|
||||
const setCurrentAgent = (agent: string): void => {
|
||||
setStore("currentAgent", agent);
|
||||
};
|
||||
|
||||
// Input actions
|
||||
const setInputBuffer = (buffer: string): void => {
|
||||
setStore("inputBuffer", buffer);
|
||||
};
|
||||
|
||||
const setInputCursorPosition = (position: number): void => {
|
||||
setStore("inputCursorPosition", position);
|
||||
};
|
||||
|
||||
const appendToInput = (text: string): void => {
|
||||
const before = store.inputBuffer.slice(0, store.inputCursorPosition);
|
||||
const after = store.inputBuffer.slice(store.inputCursorPosition);
|
||||
batch(() => {
|
||||
setStore("inputBuffer", before + text + after);
|
||||
setStore(
|
||||
"inputCursorPosition",
|
||||
store.inputCursorPosition + text.length,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const clearInput = (): void => {
|
||||
batch(() => {
|
||||
setStore("inputBuffer", "");
|
||||
setStore("inputCursorPosition", 0);
|
||||
});
|
||||
};
|
||||
|
||||
// Log actions
|
||||
const addLog = (entry: Omit<LogEntry, "id" | "timestamp">): string => {
|
||||
const newEntry: LogEntry = {
|
||||
...entry,
|
||||
id: generateLogId(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.logs.push(newEntry);
|
||||
}),
|
||||
);
|
||||
return newEntry.id;
|
||||
};
|
||||
|
||||
const updateLog = (id: string, updates: Partial<LogEntry>): void => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
const index = s.logs.findIndex((log) => log.id === id);
|
||||
if (index !== -1) {
|
||||
Object.assign(s.logs[index], updates);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const clearLogs = (): void => {
|
||||
setStore("logs", []);
|
||||
};
|
||||
|
||||
// Tool call actions
|
||||
const setCurrentToolCall = (toolCall: ToolCall | null): void => {
|
||||
setStore("currentToolCall", toolCall);
|
||||
};
|
||||
|
||||
const updateToolCall = (updates: Partial<ToolCall>): void => {
|
||||
if (store.currentToolCall) {
|
||||
setStore("currentToolCall", { ...store.currentToolCall, ...updates });
|
||||
}
|
||||
};
|
||||
|
||||
// Permission actions
|
||||
const setPermissionRequest = (
|
||||
request: PermissionRequest | null,
|
||||
): void => {
|
||||
setStore("permissionRequest", request);
|
||||
};
|
||||
|
||||
// Learning prompt actions
|
||||
const setLearningPrompt = (prompt: LearningPrompt | null): void => {
|
||||
setStore("learningPrompt", prompt);
|
||||
};
|
||||
|
||||
// Thinking message
|
||||
const setThinkingMessage = (message: string | null): void => {
|
||||
setStore("thinkingMessage", message);
|
||||
};
|
||||
|
||||
// Session info
|
||||
const setSessionInfo = (
|
||||
newSessionId: string,
|
||||
newProvider: string,
|
||||
newModel: string,
|
||||
): void => {
|
||||
batch(() => {
|
||||
setStore("sessionId", newSessionId);
|
||||
setStore("provider", newProvider);
|
||||
setStore("model", newModel);
|
||||
});
|
||||
};
|
||||
|
||||
const setVersion = (newVersion: string): void => {
|
||||
setStore("version", newVersion);
|
||||
};
|
||||
|
||||
// Command menu actions
|
||||
const openCommandMenu = (): void => {
|
||||
batch(() => {
|
||||
setStore("mode", "command_menu");
|
||||
setStore("commandMenu", {
|
||||
isOpen: true,
|
||||
filter: "",
|
||||
selectedIndex: 0,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const closeCommandMenu = (): void => {
|
||||
batch(() => {
|
||||
setStore("mode", "idle");
|
||||
setStore("commandMenu", {
|
||||
isOpen: false,
|
||||
filter: "",
|
||||
selectedIndex: 0,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Close command menu and transition to a different mode (for sub-menus)
|
||||
const transitionFromCommandMenu = (newMode: AppMode): void => {
|
||||
batch(() => {
|
||||
setStore("mode", newMode);
|
||||
setStore("commandMenu", {
|
||||
isOpen: false,
|
||||
filter: "",
|
||||
selectedIndex: 0,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const setCommandFilter = (filter: string): void => {
|
||||
setStore("commandMenu", {
|
||||
...store.commandMenu,
|
||||
filter,
|
||||
selectedIndex: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const setCommandSelectedIndex = (index: number): void => {
|
||||
setStore("commandMenu", { ...store.commandMenu, selectedIndex: index });
|
||||
};
|
||||
|
||||
// Model actions
|
||||
const setAvailableModels = (models: ProviderModel[]): void => {
|
||||
setStore("availableModels", models);
|
||||
};
|
||||
|
||||
const setModel = (newModel: string): void => {
|
||||
setStore("model", newModel);
|
||||
};
|
||||
|
||||
// Provider actions
|
||||
const setProvider = (newProvider: string): void => {
|
||||
setStore("provider", newProvider);
|
||||
};
|
||||
|
||||
const setCascadeEnabled = (enabled: boolean): void => {
|
||||
setStore("cascadeEnabled", enabled);
|
||||
};
|
||||
|
||||
const toggleCascadeEnabled = (): void => {
|
||||
setStore("cascadeEnabled", !store.cascadeEnabled);
|
||||
};
|
||||
|
||||
// Session stats actions
|
||||
const startThinking = (): void => {
|
||||
setStore("sessionStats", {
|
||||
...store.sessionStats,
|
||||
thinkingStartTime: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const stopThinking = (): void => {
|
||||
const duration = store.sessionStats.thinkingStartTime
|
||||
? Math.floor(
|
||||
(Date.now() - store.sessionStats.thinkingStartTime) / 1000,
|
||||
)
|
||||
: 0;
|
||||
setStore("sessionStats", {
|
||||
...store.sessionStats,
|
||||
thinkingStartTime: null,
|
||||
lastThinkingDuration: duration,
|
||||
});
|
||||
};
|
||||
|
||||
const addTokens = (input: number, output: number): void => {
|
||||
setStore("sessionStats", {
|
||||
...store.sessionStats,
|
||||
inputTokens: store.sessionStats.inputTokens + input,
|
||||
outputTokens: store.sessionStats.outputTokens + output,
|
||||
});
|
||||
};
|
||||
|
||||
const resetSessionStats = (): void => {
|
||||
setStore("sessionStats", createInitialSessionStats());
|
||||
};
|
||||
|
||||
// UI state actions
|
||||
const toggleTodos = (): void => {
|
||||
setStore("todosVisible", !store.todosVisible);
|
||||
};
|
||||
|
||||
const setInterruptPending = (pending: boolean): void => {
|
||||
setStore("interruptPending", pending);
|
||||
};
|
||||
|
||||
const setExitPending = (pending: boolean): void => {
|
||||
setStore("exitPending", pending);
|
||||
};
|
||||
|
||||
const setIsCompacting = (compacting: boolean): void => {
|
||||
setStore("isCompacting", compacting);
|
||||
};
|
||||
|
||||
// Streaming actions
|
||||
const startStreaming = (): string => {
|
||||
const logId = generateLogId();
|
||||
const entry: LogEntry = {
|
||||
id: logId,
|
||||
type: "assistant_streaming",
|
||||
content: "",
|
||||
timestamp: Date.now(),
|
||||
metadata: { isStreaming: true, streamComplete: false },
|
||||
};
|
||||
batch(() => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.logs.push(entry);
|
||||
}),
|
||||
);
|
||||
setStore("streamingLog", {
|
||||
logId,
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
});
|
||||
});
|
||||
return logId;
|
||||
};
|
||||
|
||||
const appendStreamContent = (content: string): void => {
|
||||
if (!store.streamingLog.logId || !store.streamingLog.isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = store.streamingLog.content + content;
|
||||
batch(() => {
|
||||
setStore("streamingLog", {
|
||||
...store.streamingLog,
|
||||
content: newContent,
|
||||
});
|
||||
setStore(
|
||||
produce((s) => {
|
||||
const log = s.logs.find((l) => l.id === store.streamingLog.logId);
|
||||
if (log) {
|
||||
log.content = newContent;
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const completeStreaming = (): void => {
|
||||
if (!store.streamingLog.logId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logId = store.streamingLog.logId;
|
||||
batch(() => {
|
||||
setStore("streamingLog", createInitialStreamingState());
|
||||
setStore(
|
||||
produce((s) => {
|
||||
const log = s.logs.find((l) => l.id === logId);
|
||||
if (log) {
|
||||
log.type = "assistant";
|
||||
log.metadata = {
|
||||
...log.metadata,
|
||||
isStreaming: false,
|
||||
streamComplete: true,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const cancelStreaming = (): void => {
|
||||
if (!store.streamingLog.logId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logId = store.streamingLog.logId;
|
||||
batch(() => {
|
||||
setStore("streamingLog", createInitialStreamingState());
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.logs = s.logs.filter((l) => l.id !== logId);
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Suggestion actions
|
||||
const setSuggestions = (newSuggestions: SuggestionPrompt[]): void => {
|
||||
setStore("suggestions", {
|
||||
suggestions: newSuggestions,
|
||||
selectedIndex: 0,
|
||||
visible: newSuggestions.length > 0,
|
||||
});
|
||||
};
|
||||
|
||||
const clearSuggestions = (): void => {
|
||||
setStore("suggestions", createInitialSuggestionState());
|
||||
};
|
||||
|
||||
const selectSuggestion = (index: number): void => {
|
||||
setStore("suggestions", {
|
||||
...store.suggestions,
|
||||
selectedIndex: Math.max(
|
||||
0,
|
||||
Math.min(index, store.suggestions.suggestions.length - 1),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const nextSuggestion = (): void => {
|
||||
setStore("suggestions", {
|
||||
...store.suggestions,
|
||||
selectedIndex:
|
||||
(store.suggestions.selectedIndex + 1) %
|
||||
Math.max(1, store.suggestions.suggestions.length),
|
||||
});
|
||||
};
|
||||
|
||||
const prevSuggestion = (): void => {
|
||||
const newIndex =
|
||||
store.suggestions.selectedIndex === 0
|
||||
? Math.max(0, store.suggestions.suggestions.length - 1)
|
||||
: store.suggestions.selectedIndex - 1;
|
||||
setStore("suggestions", {
|
||||
...store.suggestions,
|
||||
selectedIndex: newIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const hideSuggestions = (): void => {
|
||||
setStore("suggestions", { ...store.suggestions, visible: false });
|
||||
};
|
||||
|
||||
const showSuggestions = (): void => {
|
||||
setStore("suggestions", {
|
||||
...store.suggestions,
|
||||
visible: store.suggestions.suggestions.length > 0,
|
||||
});
|
||||
};
|
||||
|
||||
// Computed
|
||||
const isInputLocked = (): boolean => {
|
||||
return (
|
||||
store.mode === "thinking" ||
|
||||
store.mode === "tool_execution" ||
|
||||
store.mode === "permission_prompt"
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
// State accessors
|
||||
mode,
|
||||
screenMode,
|
||||
interactionMode,
|
||||
currentAgent,
|
||||
inputBuffer,
|
||||
inputCursorPosition,
|
||||
logs,
|
||||
currentToolCall,
|
||||
permissionRequest,
|
||||
learningPrompt,
|
||||
thinkingMessage,
|
||||
sessionId,
|
||||
provider,
|
||||
model,
|
||||
version,
|
||||
commandMenu,
|
||||
availableModels,
|
||||
sessionStats,
|
||||
todosVisible,
|
||||
interruptPending,
|
||||
exitPending,
|
||||
isCompacting,
|
||||
streamingLog,
|
||||
suggestions,
|
||||
cascadeEnabled,
|
||||
|
||||
// Mode actions
|
||||
setMode,
|
||||
setScreenMode,
|
||||
setInteractionMode,
|
||||
toggleInteractionMode,
|
||||
setCurrentAgent,
|
||||
|
||||
// Input actions
|
||||
setInputBuffer,
|
||||
setInputCursorPosition,
|
||||
appendToInput,
|
||||
clearInput,
|
||||
setInputInsertFn,
|
||||
insertText,
|
||||
|
||||
// Log actions
|
||||
addLog,
|
||||
updateLog,
|
||||
clearLogs,
|
||||
|
||||
// Tool call actions
|
||||
setCurrentToolCall,
|
||||
updateToolCall,
|
||||
|
||||
// Permission actions
|
||||
setPermissionRequest,
|
||||
|
||||
// Learning prompt actions
|
||||
setLearningPrompt,
|
||||
|
||||
// Thinking message
|
||||
setThinkingMessage,
|
||||
|
||||
// Session info
|
||||
setSessionInfo,
|
||||
setVersion,
|
||||
|
||||
// Command menu actions
|
||||
openCommandMenu,
|
||||
closeCommandMenu,
|
||||
transitionFromCommandMenu,
|
||||
setCommandFilter,
|
||||
setCommandSelectedIndex,
|
||||
|
||||
// Model actions
|
||||
setAvailableModels,
|
||||
setModel,
|
||||
|
||||
// Provider actions
|
||||
setProvider,
|
||||
setCascadeEnabled,
|
||||
toggleCascadeEnabled,
|
||||
|
||||
// Session stats actions
|
||||
startThinking,
|
||||
stopThinking,
|
||||
addTokens,
|
||||
resetSessionStats,
|
||||
|
||||
// UI state actions
|
||||
toggleTodos,
|
||||
setInterruptPending,
|
||||
setExitPending,
|
||||
setIsCompacting,
|
||||
|
||||
// Streaming actions
|
||||
startStreaming,
|
||||
appendStreamContent,
|
||||
completeStreaming,
|
||||
cancelStreaming,
|
||||
|
||||
// Suggestion actions
|
||||
setSuggestions,
|
||||
clearSuggestions,
|
||||
selectSuggestion,
|
||||
nextSuggestion,
|
||||
prevSuggestion,
|
||||
hideSuggestions,
|
||||
showSuggestions,
|
||||
|
||||
// Computed
|
||||
isInputLocked,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Non-reactive store access for use outside components
|
||||
let storeRef: AppContextValue | null = null;
|
||||
|
||||
export const setAppStoreRef = (store: AppContextValue): void => {
|
||||
storeRef = store;
|
||||
};
|
||||
|
||||
export const appStore = {
|
||||
getState: () => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
return {
|
||||
mode: storeRef.mode(),
|
||||
screenMode: storeRef.screenMode(),
|
||||
interactionMode: storeRef.interactionMode(),
|
||||
currentAgent: storeRef.currentAgent(),
|
||||
inputBuffer: storeRef.inputBuffer(),
|
||||
logs: storeRef.logs(),
|
||||
currentToolCall: storeRef.currentToolCall(),
|
||||
permissionRequest: storeRef.permissionRequest(),
|
||||
learningPrompt: storeRef.learningPrompt(),
|
||||
thinkingMessage: storeRef.thinkingMessage(),
|
||||
sessionId: storeRef.sessionId(),
|
||||
provider: storeRef.provider(),
|
||||
model: storeRef.model(),
|
||||
version: storeRef.version(),
|
||||
sessionStats: storeRef.sessionStats(),
|
||||
cascadeEnabled: storeRef.cascadeEnabled(),
|
||||
todosVisible: storeRef.todosVisible(),
|
||||
interruptPending: storeRef.interruptPending(),
|
||||
exitPending: storeRef.exitPending(),
|
||||
isCompacting: storeRef.isCompacting(),
|
||||
streamingLog: storeRef.streamingLog(),
|
||||
suggestions: storeRef.suggestions(),
|
||||
};
|
||||
},
|
||||
|
||||
addLog: (entry: Omit<LogEntry, "id" | "timestamp">): string => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
return storeRef.addLog(entry);
|
||||
},
|
||||
|
||||
updateLog: (id: string, updates: Partial<LogEntry>): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.updateLog(id, updates);
|
||||
},
|
||||
|
||||
setMode: (mode: AppMode): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setMode(mode);
|
||||
},
|
||||
|
||||
toggleInteractionMode: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.toggleInteractionMode();
|
||||
},
|
||||
|
||||
setCurrentAgent: (agent: string): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setCurrentAgent(agent);
|
||||
},
|
||||
|
||||
setCurrentToolCall: (toolCall: ToolCall | null): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setCurrentToolCall(toolCall);
|
||||
},
|
||||
|
||||
updateToolCall: (updates: Partial<ToolCall>): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.updateToolCall(updates);
|
||||
},
|
||||
|
||||
setThinkingMessage: (message: string | null): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setThinkingMessage(message);
|
||||
},
|
||||
|
||||
setPermissionRequest: (request: PermissionRequest | null): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setPermissionRequest(request);
|
||||
},
|
||||
|
||||
setLearningPrompt: (prompt: LearningPrompt | null): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setLearningPrompt(prompt);
|
||||
},
|
||||
|
||||
clearInput: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.clearInput();
|
||||
},
|
||||
|
||||
clearLogs: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.clearLogs();
|
||||
},
|
||||
|
||||
openCommandMenu: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.openCommandMenu();
|
||||
},
|
||||
|
||||
closeCommandMenu: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.closeCommandMenu();
|
||||
},
|
||||
|
||||
transitionFromCommandMenu: (newMode: AppMode): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.transitionFromCommandMenu(newMode);
|
||||
},
|
||||
|
||||
setAvailableModels: (models: ProviderModel[]): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setAvailableModels(models);
|
||||
},
|
||||
|
||||
setModel: (model: string): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setModel(model);
|
||||
},
|
||||
|
||||
startThinking: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.startThinking();
|
||||
},
|
||||
|
||||
stopThinking: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.stopThinking();
|
||||
},
|
||||
|
||||
addTokens: (input: number, output: number): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.addTokens(input, output);
|
||||
},
|
||||
|
||||
resetSessionStats: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.resetSessionStats();
|
||||
},
|
||||
|
||||
toggleTodos: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.toggleTodos();
|
||||
},
|
||||
|
||||
setInterruptPending: (pending: boolean): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setInterruptPending(pending);
|
||||
},
|
||||
|
||||
setIsCompacting: (compacting: boolean): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setIsCompacting(compacting);
|
||||
},
|
||||
|
||||
startStreaming: (): string => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
return storeRef.startStreaming();
|
||||
},
|
||||
|
||||
appendStreamContent: (content: string): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.appendStreamContent(content);
|
||||
},
|
||||
|
||||
completeStreaming: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.completeStreaming();
|
||||
},
|
||||
|
||||
cancelStreaming: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.cancelStreaming();
|
||||
},
|
||||
|
||||
setSuggestions: (suggestions: SuggestionPrompt[]): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setSuggestions(suggestions);
|
||||
},
|
||||
|
||||
clearSuggestions: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.clearSuggestions();
|
||||
},
|
||||
|
||||
hideSuggestions: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.hideSuggestions();
|
||||
},
|
||||
|
||||
setProvider: (provider: string): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setProvider(provider);
|
||||
},
|
||||
|
||||
setCascadeEnabled: (enabled: boolean): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setCascadeEnabled(enabled);
|
||||
},
|
||||
|
||||
toggleCascadeEnabled: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.toggleCascadeEnabled();
|
||||
},
|
||||
};
|
||||
130
src/tui-solid/context/dialog.tsx
Normal file
130
src/tui-solid/context/dialog.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { type Accessor, type JSX } from "solid-js";
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createSimpleContext } from "./helper";
|
||||
|
||||
export type DialogType = "confirm" | "alert" | "custom";
|
||||
|
||||
export interface DialogConfig {
|
||||
id: string;
|
||||
type: DialogType;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
content?: () => JSX.Element;
|
||||
}
|
||||
|
||||
interface DialogStore {
|
||||
dialogs: DialogConfig[];
|
||||
activeDialogId: string | null;
|
||||
}
|
||||
|
||||
interface DialogContextValue {
|
||||
dialogs: Accessor<DialogConfig[]>;
|
||||
activeDialog: Accessor<DialogConfig | null>;
|
||||
isOpen: Accessor<boolean>;
|
||||
open: (config: Omit<DialogConfig, "id">) => string;
|
||||
close: (id?: string) => void;
|
||||
closeAll: () => void;
|
||||
confirm: (config: Omit<DialogConfig, "id" | "type">) => Promise<boolean>;
|
||||
alert: (title: string, message?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
let dialogIdCounter = 0;
|
||||
const generateDialogId = (): string => `dialog-${++dialogIdCounter}`;
|
||||
|
||||
export const { provider: DialogProvider, use: useDialog } =
|
||||
createSimpleContext<DialogContextValue>({
|
||||
name: "Dialog",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<DialogStore>({
|
||||
dialogs: [],
|
||||
activeDialogId: null,
|
||||
});
|
||||
|
||||
const dialogs = (): DialogConfig[] => store.dialogs;
|
||||
|
||||
const activeDialog = (): DialogConfig | null => {
|
||||
if (!store.activeDialogId) return null;
|
||||
return store.dialogs.find((d) => d.id === store.activeDialogId) ?? null;
|
||||
};
|
||||
|
||||
const isOpen = (): boolean => store.activeDialogId !== null;
|
||||
|
||||
const open = (config: Omit<DialogConfig, "id">): string => {
|
||||
const id = generateDialogId();
|
||||
const dialog: DialogConfig = { ...config, id };
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.dialogs.push(dialog);
|
||||
s.activeDialogId = id;
|
||||
}),
|
||||
);
|
||||
return id;
|
||||
};
|
||||
|
||||
const close = (id?: string): void => {
|
||||
const targetId = id ?? store.activeDialogId;
|
||||
if (!targetId) return;
|
||||
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.dialogs = s.dialogs.filter((d) => d.id !== targetId);
|
||||
if (s.activeDialogId === targetId) {
|
||||
s.activeDialogId =
|
||||
s.dialogs.length > 0
|
||||
? s.dialogs[s.dialogs.length - 1].id
|
||||
: null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const closeAll = (): void => {
|
||||
setStore({ dialogs: [], activeDialogId: null });
|
||||
};
|
||||
|
||||
const confirm = (
|
||||
config: Omit<DialogConfig, "id" | "type">,
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
open({
|
||||
...config,
|
||||
type: "confirm",
|
||||
onConfirm: () => {
|
||||
config.onConfirm?.();
|
||||
resolve(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
config.onCancel?.();
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const alert = (title: string, message?: string): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
open({
|
||||
type: "alert",
|
||||
title,
|
||||
message,
|
||||
onConfirm: () => resolve(),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
dialogs,
|
||||
activeDialog,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
closeAll,
|
||||
confirm,
|
||||
alert,
|
||||
};
|
||||
},
|
||||
});
|
||||
61
src/tui-solid/context/exit.tsx
Normal file
61
src/tui-solid/context/exit.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createSignal, onCleanup } from "solid-js";
|
||||
import { createSimpleContext } from "./helper";
|
||||
|
||||
interface ExitContextInput extends Record<string, unknown> {
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
interface ExitContextValue {
|
||||
exit: (code?: number) => void;
|
||||
exitCode: () => number;
|
||||
isExiting: () => boolean;
|
||||
requestExit: () => void;
|
||||
cancelExit: () => void;
|
||||
confirmExit: () => void;
|
||||
}
|
||||
|
||||
export const { provider: ExitProvider, use: useExit } = createSimpleContext<
|
||||
ExitContextValue,
|
||||
ExitContextInput
|
||||
>({
|
||||
name: "Exit",
|
||||
init: (props) => {
|
||||
const [exitCode, setExitCode] = createSignal(0);
|
||||
const [isExiting, setIsExiting] = createSignal(false);
|
||||
const [exitRequested, setExitRequested] = createSignal(false);
|
||||
|
||||
const exit = (code = 0): void => {
|
||||
setExitCode(code);
|
||||
setIsExiting(true);
|
||||
props.onExit?.();
|
||||
};
|
||||
|
||||
const requestExit = (): void => {
|
||||
setExitRequested(true);
|
||||
};
|
||||
|
||||
const cancelExit = (): void => {
|
||||
setExitRequested(false);
|
||||
};
|
||||
|
||||
const confirmExit = (): void => {
|
||||
if (exitRequested()) {
|
||||
exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
setIsExiting(false);
|
||||
setExitRequested(false);
|
||||
});
|
||||
|
||||
return {
|
||||
exit,
|
||||
exitCode,
|
||||
isExiting,
|
||||
requestExit,
|
||||
cancelExit,
|
||||
confirmExit,
|
||||
};
|
||||
},
|
||||
});
|
||||
43
src/tui-solid/context/helper.tsx
Normal file
43
src/tui-solid/context/helper.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js";
|
||||
|
||||
interface ContextInput<T, Props extends Record<string, unknown>> {
|
||||
name: string;
|
||||
init: ((input: Props) => T) | (() => T);
|
||||
}
|
||||
|
||||
interface ContextOutput<T, Props extends Record<string, unknown>> {
|
||||
provider: (props: ParentProps<Props>) => ReturnType<typeof Show>;
|
||||
use: () => T;
|
||||
}
|
||||
|
||||
export function createSimpleContext<
|
||||
T,
|
||||
Props extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(input: ContextInput<T, Props>): ContextOutput<T, Props> {
|
||||
const ctx = createContext<T>();
|
||||
|
||||
return {
|
||||
provider: (props: ParentProps<Props>) => {
|
||||
const init = input.init(props as Props);
|
||||
const initWithReady = init as T & { ready?: boolean };
|
||||
return (
|
||||
<Show
|
||||
when={
|
||||
initWithReady.ready === undefined || initWithReady.ready === true
|
||||
}
|
||||
>
|
||||
<ctx.Provider value={init}>{props.children}</ctx.Provider>
|
||||
</Show>
|
||||
);
|
||||
},
|
||||
use() {
|
||||
const value = useContext(ctx);
|
||||
if (value === undefined) {
|
||||
throw new Error(
|
||||
`${input.name} context must be used within a ${input.name}Provider`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
7
src/tui-solid/context/index.ts
Normal file
7
src/tui-solid/context/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { createSimpleContext } from "./helper";
|
||||
export { AppStoreProvider, useAppStore, appStore, setAppStoreRef } from "./app";
|
||||
export { ThemeProvider, useTheme } from "./theme";
|
||||
export { RouteProvider, useRoute } from "./route";
|
||||
export { KeybindProvider, useKeybind } from "./keybind";
|
||||
export { DialogProvider, useDialog } from "./dialog";
|
||||
export { ExitProvider, useExit } from "./exit";
|
||||
179
src/tui-solid/context/keybind.tsx
Normal file
179
src/tui-solid/context/keybind.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createSimpleContext } from "./helper";
|
||||
|
||||
export type KeyModifier = "ctrl" | "alt" | "shift" | "meta";
|
||||
|
||||
export interface KeyBinding {
|
||||
id: string;
|
||||
key: string;
|
||||
modifiers?: KeyModifier[];
|
||||
description: string;
|
||||
action: () => void;
|
||||
context?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface KeybindStore {
|
||||
bindings: Record<string, KeyBinding>;
|
||||
contextStack: string[];
|
||||
}
|
||||
|
||||
interface KeybindContextValue {
|
||||
bindings: () => KeyBinding[];
|
||||
currentContext: () => string | null;
|
||||
pushContext: (context: string) => void;
|
||||
popContext: () => void;
|
||||
clearContexts: () => void;
|
||||
register: (binding: Omit<KeyBinding, "id">) => string;
|
||||
unregister: (id: string) => void;
|
||||
enable: (id: string) => void;
|
||||
disable: (id: string) => void;
|
||||
getBindingForKey: (
|
||||
key: string,
|
||||
modifiers: KeyModifier[],
|
||||
) => KeyBinding | null;
|
||||
formatKeybind: (binding: KeyBinding) => string;
|
||||
}
|
||||
|
||||
let keybindIdCounter = 0;
|
||||
const generateKeybindId = (): string => `keybind-${++keybindIdCounter}`;
|
||||
|
||||
const normalizeKey = (key: string): string => key.toLowerCase();
|
||||
|
||||
const sortModifiers = (modifiers: KeyModifier[]): KeyModifier[] => {
|
||||
const order: KeyModifier[] = ["ctrl", "alt", "shift", "meta"];
|
||||
return [...modifiers].sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
};
|
||||
|
||||
const createKeySignature = (
|
||||
key: string,
|
||||
modifiers: KeyModifier[] = [],
|
||||
): string => {
|
||||
const sorted = sortModifiers(modifiers);
|
||||
return [...sorted, normalizeKey(key)].join("+");
|
||||
};
|
||||
|
||||
export const { provider: KeybindProvider, use: useKeybind } =
|
||||
createSimpleContext<KeybindContextValue>({
|
||||
name: "Keybind",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<KeybindStore>({
|
||||
bindings: {},
|
||||
contextStack: [],
|
||||
});
|
||||
|
||||
const bindings = (): KeyBinding[] => Object.values(store.bindings);
|
||||
|
||||
const currentContext = (): string | null => {
|
||||
const stack = store.contextStack;
|
||||
return stack.length > 0 ? stack[stack.length - 1] : null;
|
||||
};
|
||||
|
||||
const pushContext = (context: string): void => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.contextStack.push(context);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const popContext = (): void => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.contextStack.pop();
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const clearContexts = (): void => {
|
||||
setStore("contextStack", []);
|
||||
};
|
||||
|
||||
const register = (binding: Omit<KeyBinding, "id">): string => {
|
||||
const id = generateKeybindId();
|
||||
const fullBinding: KeyBinding = {
|
||||
...binding,
|
||||
id,
|
||||
enabled: binding.enabled ?? true,
|
||||
};
|
||||
setStore("bindings", id, fullBinding);
|
||||
return id;
|
||||
};
|
||||
|
||||
const unregister = (id: string): void => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
delete s.bindings[id];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const enable = (id: string): void => {
|
||||
const binding = store.bindings[id];
|
||||
if (binding) {
|
||||
setStore("bindings", id, "enabled", true);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = (id: string): void => {
|
||||
const binding = store.bindings[id];
|
||||
if (binding) {
|
||||
setStore("bindings", id, "enabled", false);
|
||||
}
|
||||
};
|
||||
|
||||
const getBindingForKey = (
|
||||
key: string,
|
||||
modifiers: KeyModifier[],
|
||||
): KeyBinding | null => {
|
||||
const signature = createKeySignature(key, modifiers);
|
||||
const ctx = currentContext();
|
||||
|
||||
for (const binding of Object.values(store.bindings)) {
|
||||
if (!binding.enabled) continue;
|
||||
|
||||
const bindingSignature = createKeySignature(
|
||||
binding.key,
|
||||
binding.modifiers,
|
||||
);
|
||||
if (bindingSignature !== signature) continue;
|
||||
|
||||
if (binding.context && binding.context !== ctx) continue;
|
||||
|
||||
return binding;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatKeybind = (binding: KeyBinding): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (binding.modifiers?.includes("ctrl")) parts.push("Ctrl");
|
||||
if (binding.modifiers?.includes("alt")) parts.push("Alt");
|
||||
if (binding.modifiers?.includes("shift")) parts.push("Shift");
|
||||
if (binding.modifiers?.includes("meta")) parts.push("Cmd");
|
||||
|
||||
const keyDisplay =
|
||||
binding.key.length === 1 ? binding.key.toUpperCase() : binding.key;
|
||||
|
||||
parts.push(keyDisplay);
|
||||
|
||||
return parts.join("+");
|
||||
};
|
||||
|
||||
return {
|
||||
bindings,
|
||||
currentContext,
|
||||
pushContext,
|
||||
popContext,
|
||||
clearContexts,
|
||||
register,
|
||||
unregister,
|
||||
enable,
|
||||
disable,
|
||||
getBindingForKey,
|
||||
formatKeybind,
|
||||
};
|
||||
},
|
||||
});
|
||||
60
src/tui-solid/context/route.tsx
Normal file
60
src/tui-solid/context/route.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createStore } from "solid-js/store";
|
||||
import { createSimpleContext } from "./helper";
|
||||
import type { Route } from "@tui-solid/types";
|
||||
|
||||
interface RouteStore {
|
||||
route: Route;
|
||||
}
|
||||
|
||||
interface RouteContextValue {
|
||||
get data(): Route;
|
||||
navigate: (route: Route) => void;
|
||||
goHome: () => void;
|
||||
goToSession: (sessionId: string) => void;
|
||||
isHome: () => boolean;
|
||||
isSession: () => boolean;
|
||||
currentSessionId: () => string | null;
|
||||
}
|
||||
|
||||
export const { provider: RouteProvider, use: useRoute } =
|
||||
createSimpleContext<RouteContextValue>({
|
||||
name: "Route",
|
||||
init: () => {
|
||||
const initialRoute: Route = { type: "home" };
|
||||
const [store, setStore] = createStore<RouteStore>({
|
||||
route: initialRoute,
|
||||
});
|
||||
|
||||
const navigate = (route: Route): void => {
|
||||
setStore("route", route);
|
||||
};
|
||||
|
||||
const goHome = (): void => {
|
||||
setStore("route", { type: "home" });
|
||||
};
|
||||
|
||||
const goToSession = (sessionId: string): void => {
|
||||
setStore("route", { type: "session", sessionId });
|
||||
};
|
||||
|
||||
const isHome = (): boolean => store.route.type === "home";
|
||||
|
||||
const isSession = (): boolean => store.route.type === "session";
|
||||
|
||||
const currentSessionId = (): string | null => {
|
||||
return store.route.type === "session" ? store.route.sessionId : null;
|
||||
};
|
||||
|
||||
return {
|
||||
get data() {
|
||||
return store.route;
|
||||
},
|
||||
navigate,
|
||||
goHome,
|
||||
goToSession,
|
||||
isHome,
|
||||
isSession,
|
||||
currentSessionId,
|
||||
};
|
||||
},
|
||||
});
|
||||
89
src/tui-solid/context/theme.tsx
Normal file
89
src/tui-solid/context/theme.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { type Accessor } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { createSimpleContext } from "./helper";
|
||||
import { THEMES, DEFAULT_THEME, getTheme } from "@constants/themes";
|
||||
import type { Theme, ThemeColors } from "@/types/theme";
|
||||
|
||||
interface ThemeStore {
|
||||
currentTheme: Theme;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Accessor<Theme>;
|
||||
themeName: Accessor<string>;
|
||||
colors: ThemeColors;
|
||||
availableThemes: () => string[];
|
||||
setTheme: (name: string) => void;
|
||||
cycleTheme: () => void;
|
||||
getColor: (key: keyof ThemeColors) => string;
|
||||
}
|
||||
|
||||
export const { provider: ThemeProvider, use: useTheme } =
|
||||
createSimpleContext<ThemeContextValue>({
|
||||
name: "Theme",
|
||||
init: () => {
|
||||
const savedTheme = loadSavedTheme();
|
||||
const initialTheme = getTheme(savedTheme);
|
||||
|
||||
const [store, setStore] = createStore<ThemeStore>({
|
||||
currentTheme: initialTheme,
|
||||
themeName: savedTheme,
|
||||
});
|
||||
|
||||
const theme = (): Theme => store.currentTheme;
|
||||
const themeName = (): string => store.themeName;
|
||||
|
||||
const availableThemes = (): string[] => Object.keys(THEMES);
|
||||
|
||||
const setTheme = (name: string): void => {
|
||||
const newTheme = getTheme(name);
|
||||
setStore({
|
||||
currentTheme: newTheme,
|
||||
themeName: name,
|
||||
});
|
||||
saveTheme(name);
|
||||
};
|
||||
|
||||
const cycleTheme = (): void => {
|
||||
const themes = availableThemes();
|
||||
const currentIndex = themes.indexOf(store.themeName);
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
setTheme(themes[nextIndex]);
|
||||
};
|
||||
|
||||
const getColor = (key: keyof ThemeColors): string => {
|
||||
const color = store.currentTheme.colors[key];
|
||||
return typeof color === "string" ? color : "";
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
themeName,
|
||||
get colors() {
|
||||
return store.currentTheme.colors;
|
||||
},
|
||||
availableThemes,
|
||||
setTheme,
|
||||
cycleTheme,
|
||||
getColor,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const loadSavedTheme = (): string => {
|
||||
try {
|
||||
const saved = process.env["CODETYPER_THEME"];
|
||||
if (saved && saved in THEMES) {
|
||||
return saved;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
};
|
||||
|
||||
const saveTheme = (_name: string): void => {
|
||||
// Theme persistence could be implemented here
|
||||
// For now, themes are session-only
|
||||
};
|
||||
3
src/tui-solid/index.ts
Normal file
3
src/tui-solid/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { tui, appStore } from "./app";
|
||||
export type { TuiRenderOptions } from "./app";
|
||||
export type { TuiInput, TuiOutput } from "./types";
|
||||
134
src/tui-solid/routes/home.tsx
Normal file
134
src/tui-solid/routes/home.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Match, Switch } from "solid-js";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import { Logo } from "@tui-solid/components/logo";
|
||||
import { InputArea } from "@tui-solid/components/input-area";
|
||||
import { CommandMenu } from "@tui-solid/components/command-menu";
|
||||
import { ModelSelect } from "@tui-solid/components/model-select";
|
||||
import { ThemeSelect } from "@tui-solid/components/theme-select";
|
||||
import { FilePicker } from "@tui-solid/components/file-picker";
|
||||
import { HOME_VARS } from "@constants/home";
|
||||
|
||||
interface HomeProps {
|
||||
onSubmit: (input: string) => void;
|
||||
onCommand?: (command: string) => void;
|
||||
onModelSelect?: (model: string) => void;
|
||||
onThemeSelect?: (theme: string) => void;
|
||||
onFileSelect?: (file: string) => void;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export function Home(props: HomeProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
|
||||
const handleSubmit = (input: string): void => {
|
||||
props.onSubmit(input);
|
||||
};
|
||||
|
||||
const handleModelClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleThemeClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleFilePickerClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={1}
|
||||
backgroundColor={theme.colors.background}
|
||||
>
|
||||
<box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Logo />
|
||||
|
||||
<box marginTop={2} flexDirection="column" alignItems="center">
|
||||
<text fg={theme.colors.textDim}>{HOME_VARS.title}</text>
|
||||
<text fg={theme.colors.textDim}>{HOME_VARS.subTitle}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box marginTop={2}>
|
||||
<InputArea
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="What would you like to build today?"
|
||||
/>
|
||||
</box>
|
||||
|
||||
<Switch>
|
||||
<Match when={app.mode() === "command_menu"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CommandMenu
|
||||
onSelect={(command) => {
|
||||
const lowerCommand = command.toLowerCase();
|
||||
// Handle menu-opening commands directly to avoid async timing issues
|
||||
if (lowerCommand === "model" || lowerCommand === "models") {
|
||||
app.transitionFromCommandMenu("model_select");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "theme") {
|
||||
app.transitionFromCommandMenu("theme_select");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "agent" || lowerCommand === "a") {
|
||||
app.transitionFromCommandMenu("agent_select");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "mcp") {
|
||||
app.transitionFromCommandMenu("mcp_select");
|
||||
return;
|
||||
}
|
||||
// For other commands, close menu and process through handler
|
||||
app.closeCommandMenu();
|
||||
props.onCommand?.(command);
|
||||
}}
|
||||
onCancel={() => app.closeCommandMenu()}
|
||||
isActive={app.mode() === "command_menu"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "model_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<ModelSelect
|
||||
onSelect={(model) => props.onModelSelect?.(model)}
|
||||
onClose={handleModelClose}
|
||||
isActive={app.mode() === "model_select"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "theme_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<ThemeSelect
|
||||
onSelect={(themeName) => props.onThemeSelect?.(themeName)}
|
||||
onClose={handleThemeClose}
|
||||
isActive={app.mode() === "theme_select"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "file_picker"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<FilePicker
|
||||
files={props.files ?? []}
|
||||
onSelect={(file) => props.onFileSelect?.(file)}
|
||||
onClose={handleFilePickerClose}
|
||||
isActive={app.mode() === "file_picker"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
2
src/tui-solid/routes/index.ts
Normal file
2
src/tui-solid/routes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Home } from "./home";
|
||||
export { Session } from "./session";
|
||||
310
src/tui-solid/routes/session.tsx
Normal file
310
src/tui-solid/routes/session.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { Show, Switch, Match } from "solid-js";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import { Header } from "@tui-solid/components/header";
|
||||
import { LogPanel } from "@tui-solid/components/log-panel";
|
||||
import { InputArea } from "@tui-solid/components/input-area";
|
||||
import { StatusBar } from "@tui-solid/components/status-bar";
|
||||
import { CommandMenu } from "@tui-solid/components/command-menu";
|
||||
import { ModelSelect } from "@tui-solid/components/model-select";
|
||||
import { ThemeSelect } from "@tui-solid/components/theme-select";
|
||||
import { AgentSelect } from "@tui-solid/components/agent-select";
|
||||
import { MCPSelect } from "@tui-solid/components/mcp-select";
|
||||
import { MCPAddForm } from "@tui-solid/components/mcp-add-form";
|
||||
import { ModeSelect } from "@tui-solid/components/mode-select";
|
||||
import { ProviderSelect } from "@tui-solid/components/provider-select";
|
||||
import { FilePicker } from "@tui-solid/components/file-picker";
|
||||
import { PermissionModal } from "@tui-solid/components/permission-modal";
|
||||
import { LearningModal } from "@tui-solid/components/learning-modal";
|
||||
import { TodoPanel } from "@tui-solid/components/todo-panel";
|
||||
import type { PermissionScope, LearningScope, InteractionMode } from "@/types/tui";
|
||||
import type { MCPAddFormData } from "@/types/mcp";
|
||||
|
||||
interface AgentOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MCPServer {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "connected" | "disconnected" | "error";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ProviderStatus {
|
||||
available: boolean;
|
||||
error?: string;
|
||||
lastChecked: number;
|
||||
}
|
||||
|
||||
interface SessionProps {
|
||||
onSubmit: (input: string) => void;
|
||||
onCommand: (command: string) => void;
|
||||
onModelSelect: (model: string) => void;
|
||||
onThemeSelect: (theme: string) => void;
|
||||
onAgentSelect: (agentId: string) => void;
|
||||
onMCPSelect: (serverId: string) => void;
|
||||
onMCPAdd: (data: MCPAddFormData) => void;
|
||||
onFileSelect: (file: string) => void;
|
||||
onProviderSelect?: (providerId: string) => void;
|
||||
onCascadeToggle?: () => void;
|
||||
onPermissionResponse: (allowed: boolean, scope?: PermissionScope) => void;
|
||||
onLearningResponse: (
|
||||
save: boolean,
|
||||
scope?: LearningScope,
|
||||
editedContent?: string,
|
||||
) => void;
|
||||
plan?: {
|
||||
id: string;
|
||||
title: string;
|
||||
items: Array<{ id: string; text: string; completed: boolean }>;
|
||||
} | null;
|
||||
agents?: AgentOption[];
|
||||
currentAgent?: string;
|
||||
mcpServers?: MCPServer[];
|
||||
files?: string[];
|
||||
providerStatuses?: Record<string, ProviderStatus>;
|
||||
providerScores?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function Session(props: SessionProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
|
||||
const handleCommandSelect = (command: string): void => {
|
||||
const lowerCommand = command.toLowerCase();
|
||||
// Handle menu-opening commands directly to avoid async timing issues
|
||||
if (lowerCommand === "model" || lowerCommand === "models") {
|
||||
app.transitionFromCommandMenu("model_select");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "theme") {
|
||||
app.transitionFromCommandMenu("theme_select");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "agent" || lowerCommand === "a") {
|
||||
app.transitionFromCommandMenu("agent_select");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "mcp") {
|
||||
app.transitionFromCommandMenu("mcp_select");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "mode") {
|
||||
app.transitionFromCommandMenu("mode_select");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "provider" || lowerCommand === "p") {
|
||||
app.transitionFromCommandMenu("provider_select");
|
||||
return;
|
||||
}
|
||||
// For other commands, close menu and process through handler
|
||||
app.closeCommandMenu();
|
||||
props.onCommand(command);
|
||||
};
|
||||
|
||||
const handleCommandCancel = (): void => {
|
||||
app.closeCommandMenu();
|
||||
};
|
||||
|
||||
const handleModelClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleThemeClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleAgentClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleMCPClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleMCPAddNew = (): void => {
|
||||
app.setMode("mcp_add");
|
||||
};
|
||||
|
||||
const handleMCPAddClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleModeSelect = (mode: InteractionMode): void => {
|
||||
app.setInteractionMode(mode);
|
||||
};
|
||||
|
||||
const handleModeClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleProviderSelect = (providerId: string): void => {
|
||||
app.setProvider(providerId);
|
||||
props.onProviderSelect?.(providerId);
|
||||
};
|
||||
|
||||
const handleProviderClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleToggleCascade = (): void => {
|
||||
app.toggleCascadeEnabled();
|
||||
props.onCascadeToggle?.();
|
||||
};
|
||||
|
||||
const handleFilePickerClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={1}
|
||||
backgroundColor={theme.colors.background}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<box flexDirection="row" flexGrow={1}>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<LogPanel />
|
||||
</box>
|
||||
|
||||
<Show when={app.todosVisible() && props.plan}>
|
||||
<TodoPanel plan={props.plan ?? null} visible={app.todosVisible()} />
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<StatusBar />
|
||||
<InputArea onSubmit={props.onSubmit} />
|
||||
|
||||
<Switch>
|
||||
<Match when={app.mode() === "command_menu"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CommandMenu
|
||||
onSelect={handleCommandSelect}
|
||||
onCancel={handleCommandCancel}
|
||||
isActive={app.mode() === "command_menu"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "model_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<ModelSelect
|
||||
onSelect={props.onModelSelect}
|
||||
onClose={handleModelClose}
|
||||
isActive={app.mode() === "model_select"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "theme_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<ThemeSelect
|
||||
onSelect={props.onThemeSelect}
|
||||
onClose={handleThemeClose}
|
||||
isActive={app.mode() === "theme_select"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "agent_select" && props.agents}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<AgentSelect
|
||||
agents={props.agents ?? []}
|
||||
currentAgent={app.currentAgent()}
|
||||
onSelect={(agentId) => {
|
||||
app.setCurrentAgent(agentId);
|
||||
props.onAgentSelect(agentId);
|
||||
}}
|
||||
onClose={handleAgentClose}
|
||||
isActive={app.mode() === "agent_select"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "mcp_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<MCPSelect
|
||||
servers={props.mcpServers ?? []}
|
||||
onSelect={props.onMCPSelect}
|
||||
onAddNew={handleMCPAddNew}
|
||||
onClose={handleMCPClose}
|
||||
isActive={app.mode() === "mcp_select"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "mcp_add"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<MCPAddForm
|
||||
onSubmit={props.onMCPAdd}
|
||||
onClose={handleMCPAddClose}
|
||||
isActive={app.mode() === "mcp_add"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "mode_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<ModeSelect
|
||||
onSelect={handleModeSelect}
|
||||
onClose={handleModeClose}
|
||||
isActive={app.mode() === "mode_select"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "provider_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<ProviderSelect
|
||||
onSelect={handleProviderSelect}
|
||||
onClose={handleProviderClose}
|
||||
onToggleCascade={handleToggleCascade}
|
||||
isActive={app.mode() === "provider_select"}
|
||||
cascadeEnabled={app.cascadeEnabled()}
|
||||
providerStatuses={props.providerStatuses}
|
||||
providerScores={props.providerScores}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "file_picker"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<FilePicker
|
||||
files={props.files ?? []}
|
||||
onSelect={props.onFileSelect}
|
||||
onClose={handleFilePickerClose}
|
||||
isActive={app.mode() === "file_picker"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match
|
||||
when={app.mode() === "permission_prompt" && app.permissionRequest()}
|
||||
>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<PermissionModal
|
||||
request={app.permissionRequest()!}
|
||||
onRespond={props.onPermissionResponse}
|
||||
isActive={app.mode() === "permission_prompt"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "learning_prompt" && app.learningPrompt()}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<LearningModal
|
||||
prompt={app.learningPrompt()!}
|
||||
onRespond={props.onLearningResponse}
|
||||
isActive={app.mode() === "learning_prompt"}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
142
src/tui-solid/types/index.ts
Normal file
142
src/tui-solid/types/index.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
import type { ProviderModel } from "@/types/providers";
|
||||
|
||||
export type { ProviderModel };
|
||||
|
||||
export interface TuiInput {
|
||||
sessionId?: string;
|
||||
initialPrompt?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
theme?: string;
|
||||
workingDirectory?: string;
|
||||
availableModels?: ProviderModel[];
|
||||
cascadeEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface TuiOutput {
|
||||
exitCode: number;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export type Route = { type: "home" } | { type: "session"; sessionId: string };
|
||||
|
||||
export type AppMode =
|
||||
| "idle"
|
||||
| "editing"
|
||||
| "thinking"
|
||||
| "tool_execution"
|
||||
| "permission_prompt"
|
||||
| "learning_prompt"
|
||||
| "command_menu"
|
||||
| "model_select"
|
||||
| "agent_select"
|
||||
| "theme_select"
|
||||
| "mcp_select"
|
||||
| "file_picker"
|
||||
| "error";
|
||||
|
||||
export type ScreenMode = "home" | "session";
|
||||
|
||||
export type LogEntryType =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "assistant_streaming"
|
||||
| "tool"
|
||||
| "error"
|
||||
| "system"
|
||||
| "thinking";
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
type: LogEntryType;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
metadata?: LogEntryMetadata;
|
||||
}
|
||||
|
||||
export interface LogEntryMetadata {
|
||||
toolName?: string;
|
||||
toolStatus?: "pending" | "running" | "success" | "error";
|
||||
isStreaming?: boolean;
|
||||
thinkingDuration?: number;
|
||||
diffData?: DiffData;
|
||||
tokenCount?: number;
|
||||
}
|
||||
|
||||
export interface DiffData {
|
||||
filePath: string;
|
||||
hunks: DiffHunk[];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: "add" | "remove" | "context";
|
||||
content: string;
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "pending" | "running" | "success" | "error";
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
id: string;
|
||||
toolName: string;
|
||||
description: string;
|
||||
riskLevel: "low" | "medium" | "high";
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LearningPrompt {
|
||||
id: string;
|
||||
question: string;
|
||||
options: LearningOption[];
|
||||
context?: string;
|
||||
}
|
||||
|
||||
export interface LearningOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
startTime: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
thinkingStartTime?: number;
|
||||
lastThinkingDuration?: number;
|
||||
}
|
||||
|
||||
export interface SuggestionPrompt {
|
||||
id: string;
|
||||
text: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface CommandMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export type Children = JSX.Element | JSX.Element[] | null | undefined;
|
||||
164
src/tui-solid/ui/dialog.tsx
Normal file
164
src/tui-solid/ui/dialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Show, type ParentProps } from "solid-js";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useTerminalDimensions, useKeyboard } from "@opentui/solid";
|
||||
import { RGBA, TextAttributes } from "@opentui/core";
|
||||
|
||||
export type DialogSize = "small" | "medium" | "large";
|
||||
|
||||
interface DialogProps {
|
||||
size?: DialogSize;
|
||||
title?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const SIZE_WIDTHS: Record<DialogSize, number> = {
|
||||
small: 40,
|
||||
medium: 60,
|
||||
large: 80,
|
||||
};
|
||||
|
||||
export function Dialog(props: ParentProps<DialogProps>) {
|
||||
const theme = useTheme();
|
||||
const dimensions = useTerminalDimensions();
|
||||
const size = () => props.size ?? "medium";
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "escape" && props.onClose) {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseUp={() => props.onClose?.()}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
paddingTop={Math.floor(dimensions().height / 4)}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
>
|
||||
<box
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
width={SIZE_WIDTHS[size()]}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={theme.colors.backgroundPanel ?? "#1e1e1e"}
|
||||
borderColor={theme.colors.borderModal}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Show when={props.title}>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={theme.colors.text}
|
||||
marginBottom={1}
|
||||
>
|
||||
{props.title}
|
||||
</text>
|
||||
</Show>
|
||||
{props.children}
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogActionsProps {
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmColor?: string;
|
||||
showCancel?: boolean;
|
||||
}
|
||||
|
||||
export function DialogActions(props: DialogActionsProps) {
|
||||
const theme = useTheme();
|
||||
const showCancel = () => props.showCancel ?? true;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="flex-end" gap={2} marginTop={2}>
|
||||
<Show when={showCancel()}>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
borderColor={theme.colors.border}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
onMouseUp={() => props.onCancel?.()}
|
||||
>
|
||||
<text fg={theme.colors.textDim}>{props.cancelLabel ?? "Cancel"}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={props.confirmColor ?? theme.colors.primary}
|
||||
onMouseUp={() => props.onConfirm?.()}
|
||||
>
|
||||
<text fg={theme.colors.text}>{props.confirmLabel ?? "Confirm"}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "default" | "danger";
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const theme = useTheme();
|
||||
const confirmColor = () =>
|
||||
props.variant === "danger" ? theme.colors.error : theme.colors.primary;
|
||||
|
||||
return (
|
||||
<Dialog title={props.title} onClose={props.onCancel}>
|
||||
<text fg={theme.colors.text} wrapMode="word">
|
||||
{props.message}
|
||||
</text>
|
||||
<DialogActions
|
||||
onConfirm={props.onConfirm}
|
||||
onCancel={props.onCancel}
|
||||
confirmLabel={props.confirmLabel}
|
||||
cancelLabel={props.cancelLabel}
|
||||
confirmColor={confirmColor()}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
closeLabel?: string;
|
||||
}
|
||||
|
||||
export function AlertDialog(props: AlertDialogProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Dialog title={props.title} onClose={props.onClose}>
|
||||
<text fg={theme.colors.text} wrapMode="word">
|
||||
{props.message}
|
||||
</text>
|
||||
<DialogActions
|
||||
onConfirm={props.onClose}
|
||||
confirmLabel={props.closeLabel ?? "OK"}
|
||||
showCancel={false}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
4
src/tui-solid/ui/index.ts
Normal file
4
src/tui-solid/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Dialog, DialogActions, ConfirmDialog, AlertDialog } from "./dialog";
|
||||
export { Toast, ToastContainer, ToastProvider, useToast } from "./toast";
|
||||
export type { ToastOptions, ToastVariant } from "./toast";
|
||||
export { Spinner } from "./spinner";
|
||||
35
src/tui-solid/ui/spinner.tsx
Normal file
35
src/tui-solid/ui/spinner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createSignal, onCleanup, onMount } from "solid-js";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
const SPINNER_INTERVAL = 80;
|
||||
|
||||
interface SpinnerProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Spinner(props: SpinnerProps) {
|
||||
const theme = useTheme();
|
||||
const [frame, setFrame] = createSignal(0);
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(() => {
|
||||
intervalId = setInterval(() => {
|
||||
setFrame((f) => (f + 1) % SPINNER_FRAMES.length);
|
||||
}, SPINNER_INTERVAL);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.colors.primary}>{SPINNER_FRAMES[frame()]}</text>
|
||||
{props.label && <text fg={theme.colors.textDim}>{props.label}</text>}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
179
src/tui-solid/ui/toast.tsx
Normal file
179
src/tui-solid/ui/toast.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
Show,
|
||||
type ParentProps,
|
||||
type Accessor,
|
||||
} from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { ThemeColors } from "@/types/theme";
|
||||
|
||||
export type ToastVariant = "info" | "success" | "warning" | "error";
|
||||
|
||||
export interface ToastOptions {
|
||||
message: string;
|
||||
title?: string;
|
||||
variant?: ToastVariant;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
currentToast: ToastOptions | null;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
show: (options: ToastOptions) => void;
|
||||
error: (err: unknown) => void;
|
||||
success: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
hide: () => void;
|
||||
currentToast: ToastOptions | null;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 3000;
|
||||
|
||||
const createToastContext = (): ToastContextValue => {
|
||||
const [store, setStore] = createStore<ToastStore>({
|
||||
currentToast: null,
|
||||
});
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const show = (options: ToastOptions): void => {
|
||||
const duration = options.duration ?? DEFAULT_DURATION;
|
||||
setStore("currentToast", {
|
||||
...options,
|
||||
variant: options.variant ?? "info",
|
||||
});
|
||||
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
timeoutHandle = setTimeout(() => {
|
||||
setStore("currentToast", null);
|
||||
}, duration);
|
||||
};
|
||||
|
||||
const error = (err: unknown): void => {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "An unknown error occurred";
|
||||
show({ message, variant: "error" });
|
||||
};
|
||||
|
||||
const success = (message: string): void => {
|
||||
show({ message, variant: "success" });
|
||||
};
|
||||
|
||||
const info = (message: string): void => {
|
||||
show({ message, variant: "info" });
|
||||
};
|
||||
|
||||
const warning = (message: string): void => {
|
||||
show({ message, variant: "warning" });
|
||||
};
|
||||
|
||||
const hide = (): void => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
setStore("currentToast", null);
|
||||
};
|
||||
|
||||
return {
|
||||
show,
|
||||
error,
|
||||
success,
|
||||
info,
|
||||
warning,
|
||||
hide,
|
||||
get currentToast() {
|
||||
return store.currentToast;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>();
|
||||
|
||||
export function ToastProvider(props: ParentProps) {
|
||||
const value = createToastContext();
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{props.children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const value = useContext(ToastContext);
|
||||
if (!value) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const VARIANT_COLORS: Record<ToastVariant, keyof ThemeColors> = {
|
||||
info: "info",
|
||||
success: "success",
|
||||
warning: "warning",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
export function Toast() {
|
||||
const toast = useToast();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Show when={toast.currentToast}>
|
||||
{(current: Accessor<ToastOptions>) => {
|
||||
const variant = current().variant ?? "info";
|
||||
const colorKey = VARIANT_COLORS[variant];
|
||||
const borderColorValue = theme.colors[colorKey];
|
||||
const borderColor =
|
||||
typeof borderColorValue === "string"
|
||||
? borderColorValue
|
||||
: theme.colors.border;
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
maxWidth={60}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.colors.backgroundPanel ?? "#1e1e1e"}
|
||||
borderColor={borderColor}
|
||||
border={["left", "right"]}
|
||||
>
|
||||
<Show when={current().title}>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
marginBottom={1}
|
||||
fg={theme.colors.text}
|
||||
>
|
||||
{current().title}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.colors.text} wrapMode="word" width="100%">
|
||||
{current().message}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
return (
|
||||
<box position="absolute">
|
||||
<Toast />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user