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:
2026-01-27 23:33:06 -05:00
commit 0062e5d9d9
521 changed files with 66418 additions and 0 deletions

439
src/tui-solid/app.tsx Normal file
View 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";

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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";

View 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>
);
}

View 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>
);
}

View 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";

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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();
},
};

View 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,
};
},
});

View 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,
};
},
});

View 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;
},
};
}

View 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";

View 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,
};
},
});

View 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,
};
},
});

View 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
View File

@@ -0,0 +1,3 @@
export { tui, appStore } from "./app";
export type { TuiRenderOptions } from "./app";
export type { TuiInput, TuiOutput } from "./types";

View 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>
);
}

View File

@@ -0,0 +1,2 @@
export { Home } from "./home";
export { Session } from "./session";

View 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>
);
}

View 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
View 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>
);
}

View 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";

View 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
View 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>
);
}