Remove legacy React/Ink TUI and fix TypeScript errors

- Delete old tui/ React/Ink implementation (App.tsx, components/, hooks/, store.ts)
  - Migrate to tui-solid/ as the sole TUI implementation
  - Update tui/index.ts to re-export from tui-solid and @/types/tui

  TypeScript fixes:
  - Add missing PreCompact to hook event constants
  - Fix path aliases (@src/ -> @/, @constants/)
  - Remove unused imports across service files
  - Add explicit type annotations to callback parameters
  - Replace Bun.file/write with Node.js fs/promises in mcp/registry
  - Fix Map.some() -> Array.from().some() in registry
  - Fix addServer() call signature
  - Add missing description to brain-mcp schema items
  - Fix typo in progress-bar import (@interfactes -> @interfaces)
This commit is contained in:
2026-02-04 01:21:43 -05:00
parent 5c2d79c802
commit c1b4384890
61 changed files with 80 additions and 8123 deletions

View File

@@ -1,6 +1,6 @@
import { v4 as uuidv4 } from "uuid";
import { appStore } from "@tui/index.ts";
import type { LearningResponse } from "@tui/types.ts";
import { appStore } from "@tui/index";
import type { LearningResponse } from "@/types/tui";
import type { LearningCandidate } from "@services/learning-service.ts";
export const onLearningDetected = async (

View File

@@ -42,6 +42,7 @@ export const HOOK_EVENT_LABELS: Record<HookEventType, string> = {
SessionStart: "Session Start",
SessionEnd: "Session End",
UserPromptSubmit: "User Prompt Submit",
PreCompact: "Pre-Compact",
Stop: "Stop",
};
@@ -55,6 +56,7 @@ export const HOOK_EVENT_DESCRIPTIONS: Record<HookEventType, string> = {
SessionStart: "Runs when a new session begins.",
SessionEnd: "Runs when a session ends.",
UserPromptSubmit: "Runs when user submits a prompt. Can modify or block.",
PreCompact: "Runs before context compaction. For custom compaction logic.",
Stop: "Runs when execution is stopped (interrupt, complete, or error).",
};
@@ -67,6 +69,7 @@ export const HOOK_EVENT_TYPES: readonly HookEventType[] = [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreCompact",
"Stop",
] as const;

View File

@@ -4,21 +4,20 @@
*/
import { readFile, readdir } from "node:fs/promises";
import { join, basename, extname } from "node:path";
import { join, extname } from "node:path";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import type {
AgentDefinition,
AgentFrontmatter,
AgentDefinitionFile,
AgentRegistry,
AgentLoadResult,
AgentTier,
AgentColor,
} from "@src/types/agent-definition";
import { DEFAULT_AGENT_DEFINITION, AGENT_DEFINITION_SCHEMA } from "@src/types/agent-definition";
import { AGENT_DEFINITION, AGENT_DEFINITION_PATHS, AGENT_MESSAGES } from "@src/constants/agent-definition";
} from "@/types/agent-definition";
import { DEFAULT_AGENT_DEFINITION, AGENT_DEFINITION_SCHEMA } from "@/types/agent-definition";
import { AGENT_DEFINITION, AGENT_DEFINITION_PATHS, AGENT_MESSAGES } from "@constants/agent-definition";
const parseFrontmatter = (content: string): { frontmatter: Record<string, unknown>; body: string } | null => {
const delimiter = AGENT_DEFINITION.FRONTMATTER_DELIMITER;
@@ -207,12 +206,12 @@ export const loadAllAgentDefinitions = async (
agents.set(agent.name, agent);
// Index by trigger phrases
agent.triggerPhrases?.forEach((phrase) => {
agent.triggerPhrases?.forEach((phrase: string) => {
byTrigger.set(phrase.toLowerCase(), agent.name);
});
// Index by capabilities
agent.capabilities?.forEach((capability) => {
agent.capabilities?.forEach((capability: string) => {
const existing = byCapability.get(capability) || [];
byCapability.set(capability, [...existing, agent.name]);
});
@@ -245,8 +244,8 @@ export const findAgentsByCapability = (
): ReadonlyArray<AgentDefinition> => {
const agentNames = registry.byCapability.get(capability) || [];
return agentNames
.map((name) => registry.agents.get(name))
.filter((a): a is AgentDefinition => a !== undefined);
.map((name: string) => registry.agents.get(name))
.filter((a: AgentDefinition | undefined): a is AgentDefinition => a !== undefined);
};
export const getAgentByName = (
@@ -273,12 +272,12 @@ export const createAgentDefinitionContent = (agent: AgentDefinition): string =>
if (agent.triggerPhrases && agent.triggerPhrases.length > 0) {
frontmatter.push("triggerPhrases:");
agent.triggerPhrases.forEach((phrase) => frontmatter.push(` - ${phrase}`));
agent.triggerPhrases.forEach((phrase: string) => frontmatter.push(` - ${phrase}`));
}
if (agent.capabilities && agent.capabilities.length > 0) {
frontmatter.push("capabilities:");
agent.capabilities.forEach((cap) => frontmatter.push(` - ${cap}`));
agent.capabilities.forEach((cap: string) => frontmatter.push(` - ${cap}`));
}
frontmatter.push("---");

View File

@@ -19,16 +19,13 @@ import type {
TaskError,
TaskMetadata,
TaskNotification,
TaskStep,
TaskArtifact,
} from "@src/types/background-task";
import { DEFAULT_BACKGROUND_TASK_CONFIG, BACKGROUND_TASK_PRIORITIES } from "@src/types/background-task";
} from "@/types/background-task";
import { DEFAULT_BACKGROUND_TASK_CONFIG, BACKGROUND_TASK_PRIORITIES } from "@/types/background-task";
import {
BACKGROUND_TASK,
BACKGROUND_TASK_STORAGE,
BACKGROUND_TASK_MESSAGES,
BACKGROUND_TASK_STATUS_ICONS,
} from "@src/constants/background-task";
} from "@constants/background-task";
type TaskHandler = (task: BackgroundTask, updateProgress: (progress: Partial<TaskProgress>) => void) => Promise<TaskResult>;
type NotificationHandler = (notification: TaskNotification) => void;

View File

@@ -4,7 +4,6 @@
*/
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import type {
BrainMcpServerConfig,
@@ -14,18 +13,17 @@ import type {
BrainMcpToolName,
McpContent,
McpError,
} from "@src/types/brain-mcp";
BrainMcpTool,
} from "@/types/brain-mcp";
import {
DEFAULT_BRAIN_MCP_SERVER_CONFIG,
BRAIN_MCP_TOOLS,
MCP_ERROR_CODES,
} from "@src/types/brain-mcp";
} from "@/types/brain-mcp";
import {
BRAIN_MCP_SERVER,
BRAIN_MCP_MESSAGES,
BRAIN_MCP_ERRORS,
BRAIN_MCP_AUTH,
} from "@src/constants/brain-mcp";
} from "@constants/brain-mcp";
type BrainService = {
recall: (query: string, limit?: number) => Promise<unknown>;
@@ -135,7 +133,7 @@ const handleToolCall = async (
throw createMcpError(MCP_ERROR_CODES.BRAIN_UNAVAILABLE, "Brain service not connected");
}
const tool = BRAIN_MCP_TOOLS.find((t) => t.name === toolName);
const tool = BRAIN_MCP_TOOLS.find((t: BrainMcpTool) => t.name === toolName);
if (!tool) {
throw createMcpError(MCP_ERROR_CODES.TOOL_NOT_FOUND, `Tool not found: ${toolName}`);
}
@@ -167,7 +165,7 @@ const handleToolCall = async (
brain_stats: () => state.brainService!.getStats(),
brain_projects: async () => {
// Import dynamically to avoid circular dependency
const { listProjects } = await import("@src/services/brain/project-service");
const { listProjects } = await import("@services/brain/project-service");
return listProjects();
},
};
@@ -254,7 +252,7 @@ const handleRequest = async (
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(createMcpResponse(mcpRequest.id, content)));
} else if (mcpRequest.method === "tools/list") {
const tools = BRAIN_MCP_TOOLS.map((tool) => ({
const tools = BRAIN_MCP_TOOLS.map((tool: BrainMcpTool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
@@ -351,4 +349,4 @@ export const updateConfig = (config: Partial<BrainMcpServerConfig>): void => {
};
export const getAvailableTools = (): ReadonlyArray<{ name: string; description: string }> =>
BRAIN_MCP_TOOLS.map((t) => ({ name: t.name, description: t.description }));
BRAIN_MCP_TOOLS.map((t: BrainMcpTool) => ({ name: t.name, description: t.description }));

View File

@@ -18,21 +18,16 @@ import type {
BrainProjectListResult,
BrainProjectExport,
BrainProjectImportResult,
ExportedConcept,
ExportedMemory,
ExportedRelationship,
} from "@src/types/brain-project";
} from "@/types/brain-project";
import {
DEFAULT_BRAIN_PROJECT_SETTINGS,
BRAIN_PROJECT_EXPORT_VERSION,
} from "@src/types/brain-project";
} from "@/types/brain-project";
import {
BRAIN_PROJECT,
BRAIN_PROJECT_STORAGE,
BRAIN_PROJECT_PATHS,
BRAIN_PROJECT_MESSAGES,
BRAIN_PROJECT_API,
} from "@src/constants/brain-project";
} from "@constants/brain-project";
interface ProjectServiceState {
projects: Map<number, BrainProject>;

View File

@@ -11,12 +11,12 @@ import type {
FilteredResult,
ValidationResult,
ConfidenceFilterStats,
} from "@src/types/confidence-filter";
} from "@/types/confidence-filter";
import {
CONFIDENCE_LEVELS,
DEFAULT_CONFIDENCE_FILTER_CONFIG,
} from "@src/types/confidence-filter";
import { CONFIDENCE_FILTER, CONFIDENCE_WEIGHTS } from "@src/constants/confidence-filter";
} from "@/types/confidence-filter";
import { CONFIDENCE_FILTER, CONFIDENCE_WEIGHTS } from "@constants/confidence-filter";
export const calculateConfidenceLevel = (score: number): ConfidenceLevel => {
const levels = Object.entries(CONFIDENCE_LEVELS) as Array<[ConfidenceLevel, { min: number; max: number }]>;
@@ -158,7 +158,7 @@ export const formatConfidenceScore = (confidence: ConfidenceScore, showFactors:
if (showFactors && confidence.factors.length > 0) {
const factorLines = confidence.factors
.map((f) => ` - ${f.name}: ${f.score}% (weight: ${f.weight})`)
.map((f: ConfidenceFactor) => ` - ${f.name}: ${f.score}% (weight: ${f.weight})`)
.join("\n");
result += `\n${factorLines}`;
}

View File

@@ -19,7 +19,6 @@ import {
MCP_REGISTRY_CACHE,
MCP_REGISTRY_SOURCES,
MCP_REGISTRY_ERRORS,
MCP_REGISTRY_SUCCESS,
MCP_SEARCH_DEFAULTS,
} from "@constants/mcp-registry";
import { addServer, connectServer, getServerInstances } from "./manager";
@@ -42,11 +41,9 @@ const getCacheFilePath = (): string => {
const loadCache = async (): Promise<MCPRegistryCache | null> => {
try {
const cachePath = getCacheFilePath();
const file = Bun.file(cachePath);
if (await file.exists()) {
const data = await file.json();
return data as MCPRegistryCache;
}
const fs = await import("fs/promises");
const content = await fs.readFile(cachePath, "utf-8");
return JSON.parse(content) as MCPRegistryCache;
} catch {
// Cache doesn't exist or is invalid
}
@@ -59,7 +56,10 @@ const loadCache = async (): Promise<MCPRegistryCache | null> => {
const saveCache = async (cache: MCPRegistryCache): Promise<void> => {
try {
const cachePath = getCacheFilePath();
await Bun.write(cachePath, JSON.stringify(cache, null, 2));
const fs = await import("fs/promises");
const path = await import("path");
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await fs.writeFile(cachePath, JSON.stringify(cache, null, 2));
} catch {
// Ignore cache write errors
}
@@ -300,7 +300,7 @@ export const getServersByCategory = async (
*/
export const isServerInstalled = (serverId: string): boolean => {
const instances = getServerInstances();
return instances.some((instance) =>
return Array.from(instances.values()).some((instance) =>
instance.config.name === serverId ||
instance.config.name.toLowerCase() === serverId.toLowerCase()
);
@@ -332,8 +332,8 @@ export const installServer = async (
try {
// Add server to configuration
await addServer(
server.id,
{
name: server.id,
command: server.command,
args: customArgs || server.args,
transport: server.transport,
@@ -447,10 +447,8 @@ export const clearRegistryCache = async (): Promise<void> => {
registryCache = null;
try {
const cachePath = getCacheFilePath();
const file = Bun.file(cachePath);
if (await file.exists()) {
await Bun.write(cachePath, "");
}
const fs = await import("fs/promises");
await fs.unlink(cachePath);
} catch {
// Ignore
}

View File

@@ -21,7 +21,6 @@ import {
compactConversation,
checkCompactionNeeded,
getModelCompactionConfig,
createCompactionSummary,
} from "@services/auto-compaction";
import { appStore } from "@tui-solid/context/app";

View File

@@ -1,857 +0,0 @@
/**
* Main TUI Application Component
*/
import React, {
useEffect,
useCallback,
useState,
useRef,
useMemo,
} from "react";
import { Box, useApp, useInput, Text } from "ink";
import { useAppStore } from "@tui/store";
import {
LogPanel,
PermissionModal,
LearningModal,
StatusBar,
CommandMenu,
ModelSelect,
AgentSelect,
ThemeSelect,
MCPSelect,
MCPBrowser,
TodoPanel,
FilePicker,
SessionHeader,
} from "@tui/components/index";
import { InputLine, calculateLineStartPos } from "@tui/components/input-line";
import { useThemeStore, useThemeColors } from "@tui/hooks/useThemeStore";
import type { AgentConfig } from "@/types/agent-config";
import { createFilePickerState } from "@/services/file-picker-service";
import { INTERRUPT_TIMEOUT } from "@constants/ui";
import type { AppProps } from "@interfaces/AppProps";
import {
isMouseEscapeSequence,
cleanInput,
insertAtCursor,
deleteBeforeCursor,
calculateCursorPosition,
} from "@utils/tui-app/input-utils";
import {
isInputLocked,
isModalCommand,
isMainInputActive as checkMainInputActive,
isProcessing,
} from "@utils/tui-app/mode-utils";
import {
shouldSummarizePaste,
addPastedBlock,
updatePastedBlocksAfterDelete,
expandPastedContent,
normalizeLineEndings,
clearPastedBlocks,
} from "@utils/tui-app/paste-utils";
import { PAGE_SCROLL_LINES, MOUSE_SCROLL_LINES } from "@constants/auto-scroll";
import { useMouseScroll } from "@tui/hooks";
import type { PasteState } from "@interfaces/PastedContent";
import { createInitialPasteState } from "@interfaces/PastedContent";
import { readClipboardImage } from "@services/clipboard-service";
import { ImageAttachment } from "@tui/components/ImageAttachment";
// Re-export for backwards compatibility
export type { AppProps } from "@interfaces/AppProps";
export function App({
sessionId,
provider,
model,
agent = "coder",
version,
onSubmit,
onExit,
onCommand,
onModelSelect,
onAgentSelect,
onThemeSelect,
}: AppProps): React.ReactElement {
const { exit } = useApp();
const setSessionInfo = useAppStore((state) => state.setSessionInfo);
const setMode = useAppStore((state) => state.setMode);
const addLog = useAppStore((state) => state.addLog);
const mode = useAppStore((state) => state.mode);
const permissionRequest = useAppStore((state) => state.permissionRequest);
const learningPrompt = useAppStore((state) => state.learningPrompt);
const openCommandMenu = useAppStore((state) => state.openCommandMenu);
const closeCommandMenu = useAppStore((state) => state.closeCommandMenu);
const interruptPending = useAppStore((state) => state.interruptPending);
const setInterruptPending = useAppStore((state) => state.setInterruptPending);
const exitPending = useAppStore((state) => state.exitPending);
const setExitPending = useAppStore((state) => state.setExitPending);
const toggleTodos = useAppStore((state) => state.toggleTodos);
const toggleInteractionMode = useAppStore(
(state) => state.toggleInteractionMode,
);
const interactionMode = useAppStore((state) => state.interactionMode);
const startThinking = useAppStore((state) => state.startThinking);
const stopThinking = useAppStore((state) => state.stopThinking);
const scrollUp = useAppStore((state) => state.scrollUp);
const scrollDown = useAppStore((state) => state.scrollDown);
const scrollToTop = useAppStore((state) => state.scrollToTop);
const scrollToBottom = useAppStore((state) => state.scrollToBottom);
const screenMode = useAppStore((state) => state.screenMode);
const setScreenMode = useAppStore((state) => state.setScreenMode);
const sessionStats = useAppStore((state) => state.sessionStats);
const brain = useAppStore((state) => state.brain);
const dismissBrainBanner = useAppStore((state) => state.dismissBrainBanner);
// Local input state
const [inputBuffer, setInputBuffer] = useState("");
const [cursorPos, setCursorPos] = useState(0);
// Paste state for virtual text
const [pasteState, setPasteState] = useState<PasteState>(
createInitialPasteState,
);
// File picker state
const [filePickerOpen, setFilePickerOpen] = useState(false);
const [filePickerQuery, setFilePickerQuery] = useState("");
const [filePickerTrigger, setFilePickerTrigger] = useState<"@" | "/">("@");
const filePickerState = useMemo(
() => createFilePickerState(process.cwd()),
[],
);
// Interrupt timeout ref
const interruptTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// Exit timeout ref
const exitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isLocked = isInputLocked(mode);
const isCommandMenuOpen = mode === "command_menu";
const isModelSelectOpen = mode === "model_select";
const isAgentSelectOpen = mode === "agent_select";
const isThemeSelectOpen = mode === "theme_select";
const isMCPSelectOpen = mode === "mcp_select";
const isMCPBrowserOpen = mode === "mcp_browse";
const isLearningPromptOpen = mode === "learning_prompt";
// Theme colors
const colors = useThemeColors();
const setTheme = useThemeStore((state) => state.setTheme);
// Mouse scroll handling - scroll up pauses auto-scroll, scroll down toward bottom resumes
useMouseScroll({
onScrollUp: () => scrollUp(MOUSE_SCROLL_LINES),
onScrollDown: () => scrollDown(MOUSE_SCROLL_LINES),
enabled: true,
});
// Main input should only be active when not in special modes
const mainInputActive =
checkMainInputActive(mode, isLocked) && !filePickerOpen;
// Initialize session info
useEffect(() => {
setSessionInfo(sessionId, provider, model);
}, [sessionId, provider, model, setSessionInfo]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (interruptTimeoutRef.current) {
clearTimeout(interruptTimeoutRef.current);
}
if (exitTimeoutRef.current) {
clearTimeout(exitTimeoutRef.current);
}
};
}, []);
// Handle message submission
const handleSubmit = useCallback(
async (message: string) => {
// Expand pasted content before submitting
const expandedMessage = expandPastedContent(
message,
pasteState.pastedBlocks,
);
// Capture images before clearing
const images =
pasteState.pastedImages.length > 0
? [...pasteState.pastedImages]
: undefined;
// Clear paste state after expanding
setPasteState(clearPastedBlocks());
// Transition to session view when first message is sent
if (screenMode === "home") {
setScreenMode("session");
}
// Build log content with image indicator
const logContent = images
? `${expandedMessage}\n[${images.length} image${images.length > 1 ? "s" : ""} attached]`
: expandedMessage;
addLog({ type: "user", content: logContent });
setMode("thinking");
startThinking();
try {
await onSubmit(expandedMessage, { images });
} finally {
stopThinking();
setMode("idle");
}
},
[
onSubmit,
addLog,
setMode,
startThinking,
stopThinking,
screenMode,
setScreenMode,
pasteState.pastedBlocks,
pasteState.pastedImages,
],
);
// Handle command selection from menu
const handleCommandSelect = useCallback(
async (command: string) => {
closeCommandMenu();
setInputBuffer("");
setCursorPos(0);
// Commands that open their own modal will set their own mode
if (!isModalCommand(command)) {
setMode("idle");
}
if (onCommand) {
await onCommand(command);
}
},
[onCommand, closeCommandMenu, setMode],
);
// Handle model selection
const handleModelSelect = useCallback(
(selectedModel: string) => {
setMode("idle");
if (onModelSelect) {
onModelSelect(selectedModel);
}
},
[onModelSelect, setMode],
);
// Handle model select close
const handleModelSelectClose = useCallback(() => {
setMode("idle");
}, [setMode]);
// Handle agent selection
const handleAgentSelect = useCallback(
(agentId: string, agentConfig: AgentConfig) => {
setMode("idle");
if (onAgentSelect) {
onAgentSelect(agentId, agentConfig);
}
addLog({
type: "system",
content: `Switched to agent: ${agentConfig.name}${agentConfig.description ? `\n${agentConfig.description}` : ""}`,
});
},
[onAgentSelect, setMode, addLog],
);
// Handle agent select close
const handleAgentSelectClose = useCallback(() => {
setMode("idle");
}, [setMode]);
// Handle theme selection
const handleThemeSelect = useCallback(
(themeName: string) => {
setTheme(themeName);
setMode("idle");
if (onThemeSelect) {
onThemeSelect(themeName);
}
addLog({
type: "system",
content: `Theme changed to: ${themeName}`,
});
},
[setTheme, setMode, onThemeSelect, addLog],
);
// Handle theme select close
const handleThemeSelectClose = useCallback(() => {
setMode("idle");
}, [setMode]);
// Handle MCP select close
const handleMCPSelectClose = useCallback(() => {
setMode("idle");
}, [setMode]);
// Handle MCP browser close
const handleMCPBrowserClose = useCallback(() => {
setMode("idle");
}, [setMode]);
// Handle file selection from picker
const handleFileSelect = useCallback(
(path: string): void => {
const fileRef = `${filePickerTrigger}${path}`;
const { newBuffer, newCursorPos } = insertAtCursor(
inputBuffer,
cursorPos,
fileRef + " ",
);
setInputBuffer(newBuffer);
setCursorPos(newCursorPos);
setFilePickerOpen(false);
setFilePickerQuery("");
},
[filePickerTrigger, inputBuffer, cursorPos],
);
// Handle file picker cancel
const handleFilePickerCancel = useCallback((): void => {
setFilePickerOpen(false);
setFilePickerQuery("");
}, []);
// Handle command menu cancel (Escape or backspace with no filter)
const handleCommandMenuCancel = useCallback((): void => {
setInputBuffer("");
setCursorPos(0);
}, []);
// Handle interrupt logic
const handleInterrupt = useCallback(() => {
if (interruptPending) {
// Second press - actually interrupt
if (interruptTimeoutRef.current) {
clearTimeout(interruptTimeoutRef.current);
interruptTimeoutRef.current = null;
}
setInterruptPending(false);
stopThinking();
addLog({ type: "system", content: "Operation interrupted" });
setMode("idle");
} else {
// First press - set pending and start timeout
setInterruptPending(true);
interruptTimeoutRef.current = setTimeout(() => {
setInterruptPending(false);
interruptTimeoutRef.current = null;
}, INTERRUPT_TIMEOUT);
}
}, [interruptPending, setInterruptPending, stopThinking, addLog, setMode]);
// Global input handler for Ctrl+C, Ctrl+D, Ctrl+T, scroll (always active)
useInput((input, key) => {
// Handle Ctrl+M to toggle interaction mode (Ctrl+Tab doesn't work in most terminals)
if (key.ctrl && input === "m") {
toggleInteractionMode();
// Note: The log will show the new mode after toggle
const newMode = useAppStore.getState().interactionMode;
addLog({
type: "system",
content: `Switched to ${newMode} mode (Ctrl+M)`,
});
return;
}
// Handle Ctrl+T to toggle todos visibility (only in agent/code-review modes)
if (key.ctrl && input === "t") {
const currentMode = useAppStore.getState().interactionMode;
if (currentMode === "agent" || currentMode === "code-review") {
toggleTodos();
}
return;
}
// Handle Page Up/Down for scrolling (works even when locked)
if (key.pageUp) {
scrollUp(PAGE_SCROLL_LINES);
return;
}
if (key.pageDown) {
scrollDown(PAGE_SCROLL_LINES);
return;
}
// Handle Ctrl+Home/End for scroll to top/bottom
if (key.ctrl && key.home) {
scrollToTop();
return;
}
if (key.ctrl && key.end) {
scrollToBottom();
return;
}
// Handle Shift+Up/Down for scrolling
if (key.shift && key.upArrow) {
scrollUp(3);
return;
}
if (key.shift && key.downArrow) {
scrollDown(3);
return;
}
// Handle Ctrl+C
if (key.ctrl && input === "c") {
if (isProcessing(mode)) {
handleInterrupt();
} else if (filePickerOpen) {
handleFilePickerCancel();
} else if (
isCommandMenuOpen ||
isModelSelectOpen ||
isAgentSelectOpen ||
isThemeSelectOpen ||
isMCPSelectOpen ||
isMCPBrowserOpen ||
isLearningPromptOpen
) {
closeCommandMenu();
setMode("idle");
} else if (!permissionRequest && !learningPrompt) {
if (exitPending) {
// Second press - actually exit
if (exitTimeoutRef.current) {
clearTimeout(exitTimeoutRef.current);
exitTimeoutRef.current = null;
}
setExitPending(false);
onExit();
exit();
} else {
// First press - set pending and show message
setExitPending(true);
addLog({ type: "system", content: "Press Ctrl+C again to quit" });
exitTimeoutRef.current = setTimeout(() => {
setExitPending(false);
exitTimeoutRef.current = null;
}, INTERRUPT_TIMEOUT);
}
}
return;
}
// Handle Ctrl+D to exit
if (key.ctrl && input === "d") {
if ((mode === "idle" || mode === "editing") && inputBuffer.length === 0) {
onExit();
exit();
}
return;
}
});
// Main input handler - only active when in normal input mode
useInput(
(input, key) => {
// Skip if this is a control sequence handled by the global handler
if (key.ctrl && (input === "c" || input === "d" || input === "t")) {
return;
}
// Handle Enter
if (key.return) {
if (key.meta) {
// Alt+Enter: insert newline
const { newBuffer, newCursorPos } = insertAtCursor(
inputBuffer,
cursorPos,
"\n",
);
setInputBuffer(newBuffer);
setCursorPos(newCursorPos);
} else {
// Plain Enter: submit
const message = inputBuffer.trim();
if (message) {
handleSubmit(message);
setInputBuffer("");
setCursorPos(0);
// Note: paste state is cleared in handleSubmit after expansion
}
}
return;
}
// Handle Escape
if (key.escape) {
return;
}
// Handle backspace
if (key.backspace || key.delete) {
const { newBuffer, newCursorPos } = deleteBeforeCursor(
inputBuffer,
cursorPos,
);
setInputBuffer(newBuffer);
setCursorPos(newCursorPos);
// Update pasted block positions after deletion
if (pasteState.pastedBlocks.size > 0) {
const deleteLength = inputBuffer.length - newBuffer.length;
const deletePos = newCursorPos;
const updatedBlocks = updatePastedBlocksAfterDelete(
pasteState.pastedBlocks,
deletePos,
deleteLength,
);
setPasteState((prev) => ({
...prev,
pastedBlocks: updatedBlocks,
}));
}
return;
}
// Handle arrow keys
if (key.leftArrow) {
if (cursorPos > 0) setCursorPos(cursorPos - 1);
return;
}
if (key.rightArrow) {
if (cursorPos < inputBuffer.length) setCursorPos(cursorPos + 1);
return;
}
if (key.upArrow || key.downArrow) {
return;
}
// Handle Ctrl shortcuts
if (key.ctrl) {
if (input === "u") {
setInputBuffer("");
setCursorPos(0);
setPasteState(clearPastedBlocks());
} else if (input === "a") {
setCursorPos(0);
} else if (input === "e") {
setCursorPos(inputBuffer.length);
} else if (input === "k") {
setInputBuffer(inputBuffer.slice(0, cursorPos));
// Update paste state for kill to end of line
if (pasteState.pastedBlocks.size > 0) {
const deleteLength = inputBuffer.length - cursorPos;
const updatedBlocks = updatePastedBlocksAfterDelete(
pasteState.pastedBlocks,
cursorPos,
deleteLength,
);
setPasteState((prev) => ({
...prev,
pastedBlocks: updatedBlocks,
}));
}
} else if (input === "v" || input === "\x16") {
// Handle Ctrl+V for image paste (v or raw control character)
readClipboardImage().then((image) => {
if (image) {
setPasteState((prev) => ({
...prev,
pastedImages: [...prev.pastedImages, image],
}));
addLog({
type: "system",
content: `Image attached (${image.mediaType})`,
});
}
});
} else if (input === "i") {
// Handle Ctrl+I as alternative for image paste
readClipboardImage().then((image) => {
if (image) {
setPasteState((prev) => ({
...prev,
pastedImages: [...prev.pastedImages, image],
}));
addLog({
type: "system",
content: `Image attached (${image.mediaType})`,
});
} else {
addLog({
type: "system",
content: "No image found in clipboard",
});
}
});
}
return;
}
// Handle Cmd+V (macOS) for image paste
if (key.meta && (input === "v" || input === "\x16")) {
readClipboardImage().then((image) => {
if (image) {
setPasteState((prev) => ({
...prev,
pastedImages: [...prev.pastedImages, image],
}));
addLog({
type: "system",
content: `Image attached (${image.mediaType})`,
});
}
});
return;
}
// Skip meta key combinations
if (key.meta) {
return;
}
// Check for '/' at start of input to open command menu
if (input === "/" && inputBuffer.length === 0) {
openCommandMenu();
setInputBuffer("/");
setCursorPos(1);
return;
}
// Check for '@' to open file picker
if (input === "@") {
setFilePickerTrigger("@");
setFilePickerQuery("");
setFilePickerOpen(true);
return;
}
// Filter out mouse escape sequences
if (isMouseEscapeSequence(input)) {
return;
}
// Regular character input - only accept printable characters
if (input && input.length > 0) {
const cleaned = cleanInput(input);
if (cleaned.length > 0) {
// Normalize line endings for pasted content
const normalizedContent = normalizeLineEndings(cleaned);
// Check if this is a paste that should be summarized
if (shouldSummarizePaste(normalizedContent)) {
// Create virtual text placeholder for large paste
const { newState, pastedContent } = addPastedBlock(
pasteState,
normalizedContent,
cursorPos,
);
// Insert placeholder + space into buffer
const placeholderWithSpace = pastedContent.placeholder + " ";
const { newBuffer, newCursorPos } = insertAtCursor(
inputBuffer,
cursorPos,
placeholderWithSpace,
);
setInputBuffer(newBuffer);
setCursorPos(newCursorPos);
setPasteState(newState);
} else {
// Normal input - insert as-is
const { newBuffer, newCursorPos } = insertAtCursor(
inputBuffer,
cursorPos,
normalizedContent,
);
setInputBuffer(newBuffer);
setCursorPos(newCursorPos);
}
}
}
},
{ isActive: mainInputActive },
);
// Render input area inline
const lines = inputBuffer.split("\n");
const isEmpty = inputBuffer.length === 0;
const { line: cursorLine, col: cursorCol } = calculateCursorPosition(
inputBuffer,
cursorPos,
);
// Calculate token count for session header
const totalTokens = sessionStats.inputTokens + sessionStats.outputTokens;
return (
<Box flexDirection="column" height="100%">
{/* Always show session header and status bar */}
<SessionHeader
title={sessionId ?? "New session"}
tokenCount={totalTokens}
contextPercentage={15}
cost={0}
version={version}
interactionMode={interactionMode}
brain={brain}
onDismissBrainBanner={dismissBrainBanner}
/>
<StatusBar />
<Box flexDirection="column" flexGrow={1}>
{/* Main content area with all panes */}
<Box flexDirection="row" flexGrow={1}>
<Box flexDirection="column" flexGrow={1}>
{/* LogPanel shows logo when empty, logs otherwise */}
<LogPanel />
</Box>
<TodoPanel />
</Box>
<PermissionModal />
<LearningModal />
{/* Command Menu Overlay */}
{isCommandMenuOpen && (
<CommandMenu
onSelect={handleCommandSelect}
onCancel={handleCommandMenuCancel}
isActive={isCommandMenuOpen}
/>
)}
{/* Model Select Overlay */}
{isModelSelectOpen && (
<ModelSelect
onSelect={handleModelSelect}
onClose={handleModelSelectClose}
isActive={isModelSelectOpen}
/>
)}
{/* Agent Select Overlay */}
{isAgentSelectOpen && (
<AgentSelect
onSelect={handleAgentSelect}
onClose={handleAgentSelectClose}
currentAgent={agent}
isActive={isAgentSelectOpen}
/>
)}
{/* Theme Select Overlay */}
{isThemeSelectOpen && (
<ThemeSelect
onSelect={handleThemeSelect}
onClose={handleThemeSelectClose}
isActive={isThemeSelectOpen}
/>
)}
{/* MCP Select Overlay */}
{isMCPSelectOpen && (
<MCPSelect
onClose={handleMCPSelectClose}
onBrowse={() => setMode("mcp_browse")}
isActive={isMCPSelectOpen}
/>
)}
{/* MCP Browser Overlay */}
{isMCPBrowserOpen && (
<MCPBrowser
onClose={handleMCPBrowserClose}
isActive={isMCPBrowserOpen}
/>
)}
{/* File Picker Overlay */}
{filePickerOpen && (
<FilePicker
query={filePickerQuery}
cwd={process.cwd()}
filePickerState={filePickerState}
onSelect={handleFileSelect}
onCancel={handleFilePickerCancel}
onQueryChange={setFilePickerQuery}
isActive={filePickerOpen}
/>
)}
{/* Inline Input Area */}
<Box
flexDirection="column"
borderStyle="single"
borderColor={
isLocked
? colors.border
: filePickerOpen
? colors.borderWarning
: colors.borderFocus
}
paddingX={1}
>
{/* Show attached images */}
{pasteState.pastedImages.length > 0 && (
<ImageAttachment images={pasteState.pastedImages} />
)}
{isLocked ? (
<Text dimColor>Input locked during execution...</Text>
) : isEmpty ? (
<Box>
<Text color={colors.primary}>&gt; </Text>
<Text backgroundColor={colors.bgCursor} color={colors.textDim}>
T
</Text>
<Text dimColor>ype your message...</Text>
</Box>
) : (
lines.map((line, i) => (
<Box key={i}>
<Text color={colors.primary}>{i === 0 ? "> " : "│ "}</Text>
<InputLine
line={line}
lineIndex={i}
lineStartPos={calculateLineStartPos(inputBuffer, i)}
cursorLine={cursorLine}
cursorCol={cursorCol}
pastedBlocks={pasteState.pastedBlocks}
colors={colors}
/>
</Box>
))
)}
<Box marginTop={1}>
<Text dimColor>Enter @ files Ctrl+M mode Ctrl+I image</Text>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,251 +0,0 @@
/**
* AgentSelect Component - Agent selection menu
*
* Shows available agents loaded from .codetyper/agent/*.agent.md files
*/
import React, { useState, useMemo, useEffect } from "react";
import { Box, Text, useInput } from "ink";
import { agentLoader } from "@services/agent-loader";
import type { AgentConfig } from "@/types/agent-config";
interface AgentSelectProps {
onSelect: (agentId: string, agent: AgentConfig) => void;
onClose: () => void;
currentAgent?: string;
isActive?: boolean;
}
const MAX_VISIBLE = 10;
export function AgentSelect({
onSelect,
onClose,
currentAgent = "coder",
isActive = true,
}: AgentSelectProps): React.ReactElement {
const [agents, setAgents] = useState<AgentConfig[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
// Load agents on mount
useEffect(() => {
const loadAgentsAsync = async () => {
setLoading(true);
const availableAgents = await agentLoader.getAvailableAgents(
process.cwd(),
);
setAgents(availableAgents);
setLoading(false);
// Set initial selection to current agent
const currentIdx = availableAgents.findIndex(
(a) => a.id === currentAgent,
);
if (currentIdx >= 0) {
setSelectedIndex(currentIdx);
if (currentIdx >= MAX_VISIBLE) {
setScrollOffset(currentIdx - MAX_VISIBLE + 1);
}
}
};
loadAgentsAsync();
}, [currentAgent]);
// Filter agents based on input
const filteredAgents = useMemo(() => {
if (!filter) return agents;
const query = filter.toLowerCase();
return agents.filter(
(agent) =>
agent.id.toLowerCase().includes(query) ||
agent.name.toLowerCase().includes(query) ||
agent.description.toLowerCase().includes(query),
);
}, [agents, filter]);
useInput(
(input, key) => {
if (!isActive) return;
// Escape to close
if (key.escape) {
onClose();
return;
}
// Enter to select
if (key.return) {
if (filteredAgents.length > 0) {
const selected = filteredAgents[selectedIndex];
if (selected) {
onSelect(selected.id, selected);
onClose();
}
}
return;
}
// Navigate up
if (key.upArrow) {
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredAgents.length - 1;
if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
if (prev === 0 && newIndex === filteredAgents.length - 1) {
setScrollOffset(Math.max(0, filteredAgents.length - MAX_VISIBLE));
}
return newIndex;
});
return;
}
// Navigate down
if (key.downArrow) {
setSelectedIndex((prev) => {
const newIndex = prev < filteredAgents.length - 1 ? prev + 1 : 0;
if (newIndex >= scrollOffset + MAX_VISIBLE) {
setScrollOffset(newIndex - MAX_VISIBLE + 1);
}
if (prev === filteredAgents.length - 1 && newIndex === 0) {
setScrollOffset(0);
}
return newIndex;
});
return;
}
// Backspace
if (key.backspace || key.delete) {
if (filter.length > 0) {
setFilter(filter.slice(0, -1));
setSelectedIndex(0);
setScrollOffset(0);
}
return;
}
// Regular character input for filtering
if (input && !key.ctrl && !key.meta) {
setFilter(filter + input);
setSelectedIndex(0);
setScrollOffset(0);
}
},
{ isActive },
);
if (loading) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={1}
paddingY={0}
>
<Text color="magenta" bold>
Loading agents...
</Text>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={1}
paddingY={0}
>
<Box marginBottom={1}>
<Text color="magenta" bold>
Select Agent
</Text>
{filter && (
<>
<Text dimColor> - filtering: </Text>
<Text color="yellow">{filter}</Text>
</>
)}
</Box>
<Box marginBottom={1}>
<Text dimColor>Current: </Text>
<Text color="cyan">
{agents.find((a) => a.id === currentAgent)?.name ?? currentAgent}
</Text>
</Box>
{filteredAgents.length === 0 ? (
<Text dimColor>No agents match "{filter}"</Text>
) : (
<Box flexDirection="column">
{/* Scroll up indicator */}
{scrollOffset > 0 && (
<Text dimColor> {scrollOffset} more above</Text>
)}
{/* Visible agents */}
{filteredAgents
.slice(scrollOffset, scrollOffset + MAX_VISIBLE)
.map((agent, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const isCurrent = agent.id === currentAgent;
const isDefault = agent.id === "coder";
return (
<Box key={agent.id} flexDirection="column">
<Box>
<Text
color={isSelected ? "magenta" : undefined}
bold={isSelected}
>
{isSelected ? "> " : " "}
</Text>
<Text
color={
isDefault
? "yellow"
: isSelected
? "magenta"
: undefined
}
bold={isSelected || isDefault}
>
{agent.name}
</Text>
{isCurrent && <Text color="green"> (current)</Text>}
</Box>
{agent.description && (
<Box marginLeft={4}>
<Text dimColor>{agent.description}</Text>
</Box>
)}
</Box>
);
})}
{/* Scroll down indicator */}
{scrollOffset + MAX_VISIBLE < filteredAgents.length && (
<Text dimColor>
{" "}
{filteredAgents.length - scrollOffset - MAX_VISIBLE} more below
</Text>
)}
</Box>
)}
<Box marginTop={1}>
<Text dimColor>
navigate | Enter select | Type to filter | Esc close
</Text>
</Box>
</Box>
);
}

View File

@@ -1,81 +0,0 @@
/**
* Bouncing Loader Component
*
* A horizontal bouncing loader with gradient colors.
* The active dot bounces back and forth with a colorful trail.
*/
import React, { useEffect, useState } from "react";
import { Text } from "ink";
const LOADER_COLORS = [
"#ff00ff", // magenta
"#ff33ff", // light magenta
"#cc66ff", // purple
"#9966ff", // violet
"#6699ff", // blue-violet
"#33ccff", // cyan
"#00ffff", // cyan bright
"#33ffcc", // teal
] as const;
const LOADER_CONFIG = {
dotCount: 8,
frameInterval: 100,
dotChar: "●",
emptyChar: "○",
} as const;
export function BouncingLoader(): React.ReactElement {
const [position, setPosition] = useState(0);
const [direction, setDirection] = useState(1);
useEffect(() => {
const timer = setInterval(() => {
setPosition((prev) => {
const next = prev + direction;
if (next >= LOADER_CONFIG.dotCount - 1) {
setDirection(-1);
return LOADER_CONFIG.dotCount - 1;
}
if (next <= 0) {
setDirection(1);
return 0;
}
return next;
});
}, LOADER_CONFIG.frameInterval);
return () => clearInterval(timer);
}, [direction]);
const dots = Array.from({ length: LOADER_CONFIG.dotCount }, (_, i) => {
const distance = Math.abs(i - position);
const isActive = distance === 0;
const isTrail = distance <= 2;
// Get color based on position in the gradient
const colorIndex = i % LOADER_COLORS.length;
const color = LOADER_COLORS[colorIndex];
return {
char:
isActive || isTrail ? LOADER_CONFIG.dotChar : LOADER_CONFIG.emptyChar,
color,
dimColor: !isActive && !isTrail,
};
});
return (
<Text>
{dots.map((dot, i) => (
<Text key={i} color={dot.color} dimColor={dot.dimColor}>
{dot.char}
</Text>
))}
</Text>
);
}

View File

@@ -1,269 +0,0 @@
/**
* CommandMenu Component - Slash command selection menu
*
* Shows when user types '/' and provides filterable command list
* Supports scrolling for small terminal windows
*/
import React, { useMemo, useState, useEffect } from "react";
import { Box, Text, useInput } from "ink";
import { useAppStore } from "@tui/store";
import type {
SlashCommand,
CommandMenuProps,
CommandCategory,
} from "@/types/tui";
import { SLASH_COMMANDS, COMMAND_CATEGORIES } from "@constants/tui-components";
// Re-export for backwards compatibility
export { SLASH_COMMANDS } from "@constants/tui-components";
// Maximum visible items before scrolling
const MAX_VISIBLE = 12;
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({
onSelect,
onCancel,
isActive = true,
}: CommandMenuProps): React.ReactElement | null {
const commandMenu = useAppStore((state) => state.commandMenu);
const closeCommandMenu = useAppStore((state) => state.closeCommandMenu);
const setCommandFilter = useAppStore((state) => state.setCommandFilter);
const setCommandSelectedIndex = useAppStore(
(state) => state.setCommandSelectedIndex,
);
// Scroll offset for viewport
const [scrollOffset, setScrollOffset] = useState(0);
// Filter commands based on input
const filteredCommands = useMemo(
() => filterCommands(SLASH_COMMANDS, commandMenu.filter),
[commandMenu.filter],
);
// Reset scroll when filter changes
useEffect(() => {
setScrollOffset(0);
}, [commandMenu.filter]);
// Ensure selected index is visible
useEffect(() => {
if (commandMenu.selectedIndex < scrollOffset) {
setScrollOffset(commandMenu.selectedIndex);
} else if (commandMenu.selectedIndex >= scrollOffset + MAX_VISIBLE) {
setScrollOffset(commandMenu.selectedIndex - MAX_VISIBLE + 1);
}
}, [commandMenu.selectedIndex, scrollOffset]);
// Handle keyboard input
useInput(
(input, key) => {
if (!isActive || !commandMenu.isOpen) return;
// Escape to close
if (key.escape) {
closeCommandMenu();
onCancel?.();
return;
}
// Enter to select
if (key.return) {
if (filteredCommands.length > 0) {
const selected = filteredCommands[commandMenu.selectedIndex];
if (selected) {
onSelect(selected.name);
}
}
return;
}
// Navigate up
if (key.upArrow) {
const newIndex =
commandMenu.selectedIndex > 0
? commandMenu.selectedIndex - 1
: filteredCommands.length - 1;
setCommandSelectedIndex(newIndex);
return;
}
// Navigate down
if (key.downArrow) {
const newIndex =
commandMenu.selectedIndex < filteredCommands.length - 1
? commandMenu.selectedIndex + 1
: 0;
setCommandSelectedIndex(newIndex);
return;
}
// Tab to complete/select
if (key.tab) {
if (filteredCommands.length > 0) {
const selected = filteredCommands[commandMenu.selectedIndex];
if (selected) {
onSelect(selected.name);
}
}
return;
}
// Backspace
if (key.backspace || key.delete) {
if (commandMenu.filter.length > 0) {
setCommandFilter(commandMenu.filter.slice(0, -1));
} else {
closeCommandMenu();
onCancel?.();
}
return;
}
// Regular character input for filtering
if (input && !key.ctrl && !key.meta) {
setCommandFilter(commandMenu.filter + input);
}
},
{ isActive: isActive && commandMenu.isOpen },
);
if (!commandMenu.isOpen) return null;
// Group commands by category
const groupedCommands = groupCommandsByCategory(filteredCommands);
// Calculate flat index for selection
let flatIndex = 0;
const commandsWithIndex: CommandWithIndex[] = groupedCommands.flatMap(
(group) =>
group.commands.map((cmd) => ({
...cmd,
flatIndex: flatIndex++,
})),
);
// Calculate visible items with scroll
const totalItems = filteredCommands.length;
const hasScrollUp = scrollOffset > 0;
const hasScrollDown = scrollOffset + MAX_VISIBLE < totalItems;
// Get visible commands
const visibleCommands = commandsWithIndex.slice(
scrollOffset,
scrollOffset + MAX_VISIBLE,
);
// Group visible commands by category for display
const visibleGrouped: Array<{
category: CommandCategory;
commands: CommandWithIndex[];
}> = [];
for (const cmd of visibleCommands) {
const existingGroup = visibleGrouped.find((g) => g.category === cmd.category);
if (existingGroup) {
existingGroup.commands.push(cmd);
} else {
visibleGrouped.push({ category: cmd.category, commands: [cmd] });
}
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
paddingY={0}
>
<Box marginBottom={1}>
<Text color="cyan" bold>
Commands
</Text>
{commandMenu.filter && <Text dimColor> - filtering: </Text>}
{commandMenu.filter && <Text color="yellow">{commandMenu.filter}</Text>}
<Text dimColor> ({totalItems})</Text>
</Box>
{hasScrollUp && (
<Box justifyContent="center">
<Text color="gray"> more ({scrollOffset} above)</Text>
</Box>
)}
{filteredCommands.length === 0 ? (
<Text dimColor>No commands match "{commandMenu.filter}"</Text>
) : (
<Box flexDirection="column">
{visibleGrouped.map((group) => (
<Box key={group.category} flexDirection="column" marginBottom={1}>
<Text dimColor bold>
{capitalizeCategory(group.category)}
</Text>
{group.commands.map((cmd) => {
const isSelected = cmd.flatIndex === commandMenu.selectedIndex;
return (
<Box key={cmd.name}>
<Text
color={isSelected ? "cyan" : undefined}
bold={isSelected}
>
{isSelected ? "> " : " "}
</Text>
<Text color={isSelected ? "cyan" : "green"}>
/{cmd.name}
</Text>
<Text dimColor> - {cmd.description}</Text>
</Box>
);
})}
</Box>
))}
</Box>
)}
{hasScrollDown && (
<Box justifyContent="center">
<Text color="gray"> more ({totalItems - scrollOffset - MAX_VISIBLE} below)</Text>
</Box>
)}
<Box marginTop={1}>
<Text dimColor>
Esc to close | Enter/Tab to select | Type to filter
</Text>
</Box>
</Box>
);
}

View File

@@ -1,19 +0,0 @@
/**
* DiffView Component - Re-exports from modular implementation
*/
export { DiffView } from "@tui/components/diff-view/index";
export { DiffLine } from "@tui/components/diff-view/line-renderers";
export {
parseDiffOutput,
isDiffContent,
stripAnsi,
} from "@tui/components/diff-view/utils";
// Re-export types for convenience
export type {
DiffLineData,
DiffViewProps,
DiffLineProps,
DiffLineType,
} from "@/types/tui";

View File

@@ -1,168 +0,0 @@
/**
* FilePicker Component - Interactive file/folder selector (Presentation Only)
*
* This component is purely presentational. All filesystem operations
* are handled by the parent via the filePickerState prop.
*
* - Triggered by @ or / in input
* - Filters as you type
* - Up/Down to navigate, Enter to select
* - Tab to enter directories
* - Escape to cancel
*/
import React, { useState, useEffect, useMemo } from "react";
import { Box, Text, useInput } from "ink";
import { dirname, relative } from "path";
import type {
FileEntry,
FilePickerState,
} from "@/services/file-picker-service";
interface FilePickerProps {
query: string;
cwd: string;
filePickerState: FilePickerState;
onSelect: (path: string) => void;
onCancel: () => void;
onQueryChange: (query: string) => void;
isActive?: boolean;
}
export function FilePicker({
query,
cwd,
filePickerState,
onSelect,
onCancel,
onQueryChange,
isActive = true,
}: FilePickerProps): React.ReactElement {
const [selectedIndex, setSelectedIndex] = useState(0);
// Get filtered files from state manager
const filteredFiles = useMemo(
() => filePickerState.filterFiles(query, 15),
[filePickerState, query],
);
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
}, [query]);
useInput(
(input, key) => {
if (!isActive) return;
// Cancel
if (key.escape) {
onCancel();
return;
}
// Navigate
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow) {
setSelectedIndex((prev) =>
Math.min(filteredFiles.length - 1, prev + 1),
);
return;
}
// Select
if (key.return && filteredFiles.length > 0) {
const selected = filteredFiles[selectedIndex];
if (selected) {
onSelect(selected.relativePath);
}
return;
}
// Tab to enter directory
if (key.tab && filteredFiles.length > 0) {
const selected = filteredFiles[selectedIndex];
if (selected?.isDirectory) {
filePickerState.setCurrentDir(selected.path);
onQueryChange("");
}
return;
}
// Backspace
if (key.backspace || key.delete) {
if (query.length > 0) {
onQueryChange(query.slice(0, -1));
} else {
// Go up a directory
const currentDir = filePickerState.getCurrentDir();
const parent = dirname(currentDir);
if (parent !== currentDir) {
filePickerState.setCurrentDir(parent);
}
}
return;
}
// Type filter
if (input && !key.ctrl && !key.meta) {
onQueryChange(query + input);
}
},
{ isActive },
);
const relativeDir = relative(cwd, filePickerState.getCurrentDir()) || ".";
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="yellow"
paddingX={1}
marginBottom={1}
>
<Box marginBottom={1}>
<Text color="yellow" bold>
Select file or folder
</Text>
<Text dimColor> ({relativeDir})</Text>
</Box>
<Box marginBottom={1}>
<Text color="cyan">Filter: </Text>
<Text>{query || <Text dimColor>type to filter...</Text>}</Text>
</Box>
<Box flexDirection="column" marginBottom={1}>
{filteredFiles.length === 0 ? (
<Text dimColor>No matching files</Text>
) : (
filteredFiles.map((file: FileEntry, index: number) => {
const isSelected = index === selectedIndex;
const icon = file.isDirectory ? "📁" : "📄";
return (
<Box key={file.path}>
<Text color={isSelected ? "cyan" : undefined} bold={isSelected}>
{isSelected ? " " : " "}
{icon} {file.relativePath}
</Text>
</Box>
);
})
)}
</Box>
<Box>
<Text dimColor>
navigate Enter select Tab enter dir Esc cancel
</Text>
</Box>
</Box>
);
}

View File

@@ -1,54 +0,0 @@
/**
* Header Component - Shows the CodeTyper banner
*/
import React from "react";
import { Box, Text } from "ink";
import type { HeaderProps } from "@/types/tui";
import { TUI_BANNER, HEADER_GRADIENT_COLORS } from "@constants/tui-components";
export function Header({
version = "0.1.0",
provider,
model,
showBanner = true,
}: HeaderProps): React.ReactElement {
if (!showBanner) {
return (
<Box paddingX={1} marginBottom={1}>
<Text color="cyan" bold>
codetyper
</Text>
<Text dimColor> - AI Coding Assistant</Text>
</Box>
);
}
const info: string[] = [];
if (version) info.push(`v${version}`);
if (provider) info.push(provider);
if (model) info.push(model);
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
<Text> </Text>
{TUI_BANNER.map((line, i) => (
<Text
key={i}
color={
HEADER_GRADIENT_COLORS[
Math.min(i, HEADER_GRADIENT_COLORS.length - 1)
]
}
>
{line}
</Text>
))}
<Text> </Text>
<Text dimColor> AI Coding Assistant</Text>
<Text> </Text>
{info.length > 0 && <Text dimColor> {info.join(" | ")}</Text>}
<Text> </Text>
</Box>
);
}

View File

@@ -1,74 +0,0 @@
/**
* ImageAttachment Component - Displays pasted image indicators
*/
import React from "react";
import { Box, Text } from "ink";
import type { PastedImage } from "@/types/image";
import { formatImageSize, getImageSizeFromBase64 } from "@services/clipboard-service";
interface ImageAttachmentProps {
images: PastedImage[];
onRemove?: (id: string) => void;
}
const IMAGE_ICON = "📷";
export function ImageAttachment({
images,
onRemove,
}: ImageAttachmentProps): React.ReactElement | null {
if (images.length === 0) {
return null;
}
return (
<Box flexDirection="row" gap={1} marginBottom={1}>
{images.map((image, index) => {
const size = getImageSizeFromBase64(image.data);
const formattedSize = formatImageSize(size);
return (
<Box
key={image.id}
borderStyle="round"
borderColor="cyan"
paddingX={1}
>
<Text color="cyan">{IMAGE_ICON} </Text>
<Text>Image {index + 1}</Text>
<Text dimColor> ({formattedSize})</Text>
{onRemove && (
<Text dimColor> [x]</Text>
)}
</Box>
);
})}
</Box>
);
}
export function ImageAttachmentCompact({
images,
}: {
images: PastedImage[];
}): React.ReactElement | null {
if (images.length === 0) {
return null;
}
const totalSize = images.reduce(
(acc, img) => acc + getImageSizeFromBase64(img.data),
0,
);
return (
<Box>
<Text color="cyan">{IMAGE_ICON} </Text>
<Text>
{images.length} image{images.length > 1 ? "s" : ""} attached
</Text>
<Text dimColor> ({formatImageSize(totalSize)})</Text>
</Box>
);
}

View File

@@ -1,277 +0,0 @@
/**
* Input Area Component - Multi-line input with Enter/Alt+Enter handling
*
* - Enter: Submit message
* - Alt+Enter (Option+Enter): Insert newline
* - @ or /: Open file picker to add files as context
*/
import React, { useState, useEffect, useMemo } from "react";
import { Box, Text, useInput } from "ink";
import { useAppStore } from "@tui/store";
import { FilePicker } from "@tui/components/FilePicker";
import { BouncingLoader } from "@tui/components/BouncingLoader";
import { createFilePickerState } from "@/services/file-picker-service";
interface InputAreaProps {
onSubmit: (message: string) => void;
placeholder?: string;
}
export function InputArea({
onSubmit,
placeholder = "Type your message...",
}: InputAreaProps): React.ReactElement {
// Use local state for responsive input handling
const [buffer, setBuffer] = useState("");
const [cursorPos, setCursorPos] = useState(0);
const [cursorVisible, setCursorVisible] = useState(true);
// File picker state
const [filePickerOpen, setFilePickerOpen] = useState(false);
const [filePickerQuery, setFilePickerQuery] = useState("");
const [filePickerTrigger, setFilePickerTrigger] = useState<"@" | "/">("@");
const filePickerState = useMemo(
() => createFilePickerState(process.cwd()),
[],
);
const mode = useAppStore((state) => state.mode);
const isLocked =
mode === "thinking" ||
mode === "tool_execution" ||
mode === "permission_prompt";
// Cursor blink effect
useEffect(() => {
if (isLocked) return;
const timer = setInterval(() => {
setCursorVisible((prev) => !prev);
}, 530);
return () => clearInterval(timer);
}, [isLocked]);
// Handle input with useInput hook
useInput(
(input, key) => {
if (isLocked) return;
// Handle Enter
if (key.return) {
if (key.meta) {
// Alt+Enter or Option+Enter: insert newline
const before = buffer.slice(0, cursorPos);
const after = buffer.slice(cursorPos);
setBuffer(before + "\n" + after);
setCursorPos(cursorPos + 1);
} else {
// Plain Enter: submit
const message = buffer.trim();
if (message) {
onSubmit(message);
setBuffer("");
setCursorPos(0);
}
}
return;
}
// Handle Escape - check for Alt+Enter sequence (ESC followed by Enter)
if (key.escape) {
return;
}
// Handle backspace
if (key.backspace || key.delete) {
if (cursorPos > 0) {
const before = buffer.slice(0, cursorPos - 1);
const after = buffer.slice(cursorPos);
setBuffer(before + after);
setCursorPos(cursorPos - 1);
}
return;
}
// Handle arrow keys
if (key.leftArrow) {
if (cursorPos > 0) {
setCursorPos(cursorPos - 1);
}
return;
}
if (key.rightArrow) {
if (cursorPos < buffer.length) {
setCursorPos(cursorPos + 1);
}
return;
}
if (key.upArrow || key.downArrow) {
// For now, just ignore up/down in single-line scenarios
// Multi-line navigation can be added later
return;
}
// Handle Ctrl+U (clear all)
if (key.ctrl && input === "u") {
setBuffer("");
setCursorPos(0);
return;
}
// Handle Ctrl+A (beginning of line)
if (key.ctrl && input === "a") {
setCursorPos(0);
return;
}
// Handle Ctrl+E (end of line)
if (key.ctrl && input === "e") {
setCursorPos(buffer.length);
return;
}
// Handle Ctrl+K (kill to end)
if (key.ctrl && input === "k") {
setBuffer(buffer.slice(0, cursorPos));
return;
}
// Skip if it's a control sequence
if (key.ctrl || key.meta) {
return;
}
// Check for file picker triggers (@ or /)
if (input === "@" || input === "/") {
setFilePickerTrigger(input as "@" | "/");
setFilePickerQuery("");
setFilePickerOpen(true);
return;
}
// Regular character input - this is the key fix!
// The input parameter contains the typed character
if (input) {
const before = buffer.slice(0, cursorPos);
const after = buffer.slice(cursorPos);
setBuffer(before + input + after);
setCursorPos(cursorPos + input.length);
}
},
{ isActive: !isLocked && !filePickerOpen },
);
// File picker handlers
const handleFileSelect = (path: string): void => {
// Insert the file reference at cursor position
const fileRef = `${filePickerTrigger}${path}`;
const before = buffer.slice(0, cursorPos);
const after = buffer.slice(cursorPos);
const newBuffer = before + fileRef + " " + after;
setBuffer(newBuffer);
setCursorPos(cursorPos + fileRef.length + 1);
setFilePickerOpen(false);
setFilePickerQuery("");
};
const handleFilePickerCancel = (): void => {
setFilePickerOpen(false);
setFilePickerQuery("");
};
// Render
const lines = buffer.split("\n");
const isEmpty = buffer.length === 0;
// Calculate cursor position
let cursorLine = 0;
let cursorCol = 0;
let charCount = 0;
for (let i = 0; i < lines.length; i++) {
if (charCount + lines[i].length >= cursorPos || i === lines.length - 1) {
cursorLine = i;
cursorCol = cursorPos - charCount;
if (cursorCol > lines[i].length) cursorCol = lines[i].length;
break;
}
charCount += lines[i].length + 1;
}
return (
<Box flexDirection="column">
{/* File Picker Overlay */}
{filePickerOpen && (
<FilePicker
query={filePickerQuery}
cwd={process.cwd()}
filePickerState={filePickerState}
onSelect={handleFileSelect}
onCancel={handleFilePickerCancel}
onQueryChange={setFilePickerQuery}
isActive={filePickerOpen}
/>
)}
<Box
flexDirection="column"
borderStyle="single"
borderColor={isLocked ? "gray" : filePickerOpen ? "yellow" : "cyan"}
paddingX={1}
>
{isLocked ? (
<Box gap={1}>
<BouncingLoader />
<Text dimColor>esc</Text>
<Text color="magenta">interrupt</Text>
</Box>
) : isEmpty ? (
<Box>
<Text color="cyan">&gt; </Text>
{cursorVisible ? (
<Text backgroundColor="cyan" color="gray">
{placeholder[0]}
</Text>
) : (
<Text dimColor>{placeholder[0]}</Text>
)}
<Text dimColor>{placeholder.slice(1)}</Text>
</Box>
) : (
lines.map((line, i) => (
<Box key={i}>
<Text color="cyan">{i === 0 ? "> " : "│ "}</Text>
{i === cursorLine ? (
<Text>
{line.slice(0, cursorCol)}
{cursorVisible ? (
<Text backgroundColor="cyan" color="black">
{cursorCol < line.length ? line[cursorCol] : " "}
</Text>
) : (
<Text>
{cursorCol < line.length ? line[cursorCol] : ""}
</Text>
)}
{line.slice(cursorCol + 1)}
</Text>
) : (
<Text>{line}</Text>
)}
</Box>
))
)}
<Box marginTop={1}>
<Text dimColor>
Enter to send Alt+Enter for newline @ or / to add files
</Text>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,75 +0,0 @@
/**
* Learning Modal Component - Prompts user to save a learning
*/
import React from "react";
import { Box, Text } from "ink";
import { useAppStore } from "@tui/store";
import { SelectMenu } from "@tui/components/SelectMenu";
import type { SelectOption, LearningScope } from "@/types/tui";
import {
LEARNING_OPTIONS,
LEARNING_CONTENT_MAX_LENGTH,
LEARNING_TRUNCATION_SUFFIX,
} from "@constants/tui-components";
const truncateContent = (content: string): string => {
if (content.length <= LEARNING_CONTENT_MAX_LENGTH) {
return content;
}
const truncateAt =
LEARNING_CONTENT_MAX_LENGTH - LEARNING_TRUNCATION_SUFFIX.length;
return content.slice(0, truncateAt) + LEARNING_TRUNCATION_SUFFIX;
};
export function LearningModal(): React.ReactElement | null {
const learningPrompt = useAppStore((state) => state.learningPrompt);
if (!learningPrompt) return null;
const handleSelect = (option: SelectOption): void => {
learningPrompt.resolve({
save: option.value !== "skip",
scope:
option.value === "skip" ? undefined : (option.value as LearningScope),
});
};
const displayContent = truncateContent(learningPrompt.content);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={2}
paddingY={1}
marginY={1}
>
<Box marginBottom={1}>
<Text color="magenta" bold>
Remember this?
</Text>
</Box>
<Box marginBottom={1} flexDirection="column">
<Text color="cyan">{displayContent}</Text>
{learningPrompt.context && (
<Text dimColor>({learningPrompt.context})</Text>
)}
</Box>
<SelectMenu
options={LEARNING_OPTIONS}
onSelect={handleSelect}
isActive={!!learningPrompt}
/>
<Box marginTop={1}>
<Text dimColor>
Learnings help codetyper understand your project preferences.
</Text>
</Box>
</Box>
);
}

View File

@@ -1,8 +0,0 @@
/**
* Log Panel Component - Re-exports from modular implementation
*/
export { LogPanel } from "@tui/components/log-panel/index";
export { LogEntryDisplay } from "@tui/components/log-panel/entry-renderers";
export { ThinkingIndicator } from "@tui/components/log-panel/thinking-indicator";
export { estimateEntryLines } from "@tui/components/log-panel/utils";

View File

@@ -1,600 +0,0 @@
/**
* MCPBrowser Component - Browse and search MCP servers from registry
*
* Allows users to discover, search, and install MCP servers
*/
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Box, Text, useInput } from "ink";
import {
searchServers,
getCuratedServers,
installServer,
isServerInstalled,
getCategoriesWithCounts,
} from "@services/mcp/index";
import type {
MCPRegistryServer,
MCPServerCategory,
} from "@/types/mcp-registry";
import {
MCP_CATEGORY_LABELS,
MCP_CATEGORY_ICONS,
} from "@constants/mcp-registry";
interface MCPBrowserProps {
onClose: () => void;
onInstalled?: (serverName: string) => void;
isActive?: boolean;
}
type BrowserMode = "browse" | "search" | "category" | "detail" | "installing";
interface CategoryCount {
category: MCPServerCategory;
count: number;
}
const MAX_VISIBLE = 8;
const STATUS_COLORS = {
verified: "green",
installed: "cyan",
popular: "yellow",
default: "white",
} as const;
export function MCPBrowser({
onClose,
onInstalled,
isActive = true,
}: MCPBrowserProps): React.ReactElement {
const [mode, setMode] = useState<BrowserMode>("browse");
const [servers, setServers] = useState<MCPRegistryServer[]>([]);
const [categories, setCategories] = useState<CategoryCount[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<MCPServerCategory | null>(null);
const [selectedServer, setSelectedServer] = useState<MCPRegistryServer | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [messageType, setMessageType] = useState<"success" | "error" | "info">("info");
// Load initial data
const loadData = useCallback(async () => {
setLoading(true);
try {
const curatedServers = getCuratedServers();
setServers(curatedServers);
const cats = await getCategoriesWithCounts();
setCategories(cats);
} catch {
setServers(getCuratedServers());
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// Search handler
const handleSearch = useCallback(async (query: string) => {
setLoading(true);
try {
const result = await searchServers({
query,
category: selectedCategory || undefined,
});
setServers(result.servers);
} catch {
setMessage("Search failed");
setMessageType("error");
}
setLoading(false);
}, [selectedCategory]);
// Category filter handler
const handleCategorySelect = useCallback(async (category: MCPServerCategory | null) => {
setSelectedCategory(category);
setLoading(true);
try {
const result = await searchServers({
query: searchQuery,
category: category || undefined,
});
setServers(result.servers);
} catch {
setMessage("Failed to filter");
setMessageType("error");
}
setLoading(false);
setMode("browse");
setSelectedIndex(0);
setScrollOffset(0);
}, [searchQuery]);
// Install handler
const handleInstall = useCallback(async (server: MCPRegistryServer) => {
setMode("installing");
setMessage(`Installing ${server.name}...`);
setMessageType("info");
try {
const result = await installServer(server, { connect: true });
if (result.success) {
setMessage(`Installed ${server.name}${result.connected ? " and connected" : ""}`);
setMessageType("success");
onInstalled?.(server.id);
} else {
setMessage(result.error || "Installation failed");
setMessageType("error");
}
} catch (error) {
setMessage(error instanceof Error ? error.message : "Installation failed");
setMessageType("error");
}
setMode("browse");
setTimeout(() => setMessage(null), 3000);
}, [onInstalled]);
// Filtered and displayed items
const displayItems = useMemo(() => {
if (mode === "category") {
return categories.map((cat) => ({
id: cat.category,
label: `${MCP_CATEGORY_ICONS[cat.category]} ${MCP_CATEGORY_LABELS[cat.category]}`,
count: cat.count,
}));
}
return servers;
}, [mode, servers, categories]);
// Visible window
const visibleItems = useMemo(() => {
if (mode === "category") {
return displayItems.slice(scrollOffset, scrollOffset + MAX_VISIBLE);
}
return servers.slice(scrollOffset, scrollOffset + MAX_VISIBLE);
}, [displayItems, servers, scrollOffset, mode]);
// Input handling
useInput(
(input, key) => {
if (!isActive) return;
// Clear message on any key
if (message && mode !== "installing") {
setMessage(null);
}
// Escape handling
if (key.escape) {
if (mode === "detail") {
setMode("browse");
setSelectedServer(null);
} else if (mode === "search") {
setMode("browse");
setSearchQuery("");
} else if (mode === "category") {
setMode("browse");
} else {
onClose();
}
return;
}
// Installing mode - ignore input
if (mode === "installing") return;
// Search mode - typing
if (mode === "search") {
if (key.return) {
handleSearch(searchQuery);
setMode("browse");
} else if (key.backspace || key.delete) {
setSearchQuery((prev) => prev.slice(0, -1));
} else if (input && !key.ctrl && !key.meta) {
setSearchQuery((prev) => prev + input);
}
return;
}
// Detail mode
if (mode === "detail" && selectedServer) {
if (key.return || input === "i") {
if (!isServerInstalled(selectedServer.id)) {
handleInstall(selectedServer);
} else {
setMessage("Already installed");
setMessageType("info");
setTimeout(() => setMessage(null), 2000);
}
}
return;
}
// Navigation
if (key.upArrow || input === "k") {
setSelectedIndex((prev) => {
const newIndex = Math.max(0, prev - 1);
if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
return newIndex;
});
} else if (key.downArrow || input === "j") {
const maxIndex = (mode === "category" ? categories.length : servers.length) - 1;
setSelectedIndex((prev) => {
const newIndex = Math.min(maxIndex, prev + 1);
if (newIndex >= scrollOffset + MAX_VISIBLE) {
setScrollOffset(newIndex - MAX_VISIBLE + 1);
}
return newIndex;
});
} else if (key.return) {
// Select item
if (mode === "category") {
const cat = categories[selectedIndex];
if (cat) {
handleCategorySelect(cat.category);
}
} else {
const server = servers[selectedIndex];
if (server) {
setSelectedServer(server);
setMode("detail");
}
}
} else if (input === "/") {
setMode("search");
setSearchQuery("");
} else if (input === "c") {
setMode("category");
setSelectedIndex(0);
setScrollOffset(0);
} else if (input === "i" && mode === "browse") {
const server = servers[selectedIndex];
if (server && !isServerInstalled(server.id)) {
handleInstall(server);
}
} else if (input === "r") {
loadData();
}
},
{ isActive }
);
// Render loading
if (loading && servers.length === 0) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="center" marginY={1}>
<Text color="cyan" bold>
MCP Server Browser
</Text>
</Box>
<Box justifyContent="center" marginY={1}>
<Text color="gray">Loading servers...</Text>
</Box>
</Box>
);
}
// Render detail view
if (mode === "detail" && selectedServer) {
const installed = isServerInstalled(selectedServer.id);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="center" marginBottom={1}>
<Text color="cyan" bold>
{selectedServer.name}
</Text>
{selectedServer.verified && (
<Text color="green"> </Text>
)}
</Box>
<Box flexDirection="column" paddingX={1}>
<Text color="gray" wrap="wrap">
{selectedServer.description}
</Text>
<Box marginTop={1}>
<Text color="gray">Author: </Text>
<Text>{selectedServer.author}</Text>
</Box>
<Box>
<Text color="gray">Category: </Text>
<Text>
{MCP_CATEGORY_ICONS[selectedServer.category]}{" "}
{MCP_CATEGORY_LABELS[selectedServer.category]}
</Text>
</Box>
<Box>
<Text color="gray">Package: </Text>
<Text color="yellow">{selectedServer.package}</Text>
</Box>
{selectedServer.envVars && selectedServer.envVars.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Required env vars:</Text>
{selectedServer.envVars.map((envVar) => (
<Text key={envVar} color="magenta">
{" "}${envVar}
</Text>
))}
</Box>
)}
{selectedServer.installHint && (
<Box marginTop={1}>
<Text color="gray" wrap="wrap">
Note: {selectedServer.installHint}
</Text>
</Box>
)}
</Box>
{message && (
<Box justifyContent="center" marginTop={1}>
<Text
color={
messageType === "success"
? "green"
: messageType === "error"
? "red"
: "yellow"
}
>
{message}
</Text>
</Box>
)}
<Box justifyContent="center" marginTop={1} paddingTop={1} borderStyle="single" borderTop borderBottom={false} borderLeft={false} borderRight={false}>
{installed ? (
<Text color="cyan">Already installed</Text>
) : (
<Text>
<Text color="green">[Enter/i]</Text> Install{" "}
<Text color="gray">[Esc]</Text> Back
</Text>
)}
</Box>
</Box>
);
}
// Render category view
if (mode === "category") {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="center" marginBottom={1}>
<Text color="cyan" bold>
Select Category
</Text>
</Box>
{/* All categories option */}
<Box
paddingX={1}
backgroundColor={selectedIndex === -1 ? "cyan" : undefined}
>
<Text
color={selectedIndex === -1 ? "black" : "white"}
bold={selectedIndex === -1}
>
📋 All Categories
</Text>
</Box>
{visibleItems.map((item, index) => {
const actualIndex = index + scrollOffset;
const isSelected = actualIndex === selectedIndex;
const catItem = item as { id: string; label: string; count: number };
return (
<Box
key={catItem.id}
paddingX={1}
backgroundColor={isSelected ? "cyan" : undefined}
>
<Text
color={isSelected ? "black" : "white"}
bold={isSelected}
>
{catItem.label} ({catItem.count})
</Text>
</Box>
);
})}
<Box
justifyContent="center"
marginTop={1}
paddingTop={1}
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
>
<Text color="gray">
<Text color="cyan">[]</Text> Navigate{" "}
<Text color="cyan">[Enter]</Text> Select{" "}
<Text color="cyan">[Esc]</Text> Back
</Text>
</Box>
</Box>
);
}
// Render search mode
if (mode === "search") {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="center" marginBottom={1}>
<Text color="cyan" bold>
Search MCP Servers
</Text>
</Box>
<Box paddingX={1}>
<Text color="gray">/ </Text>
<Text>{searchQuery}</Text>
<Text color="cyan"></Text>
</Box>
<Box
justifyContent="center"
marginTop={1}
paddingTop={1}
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
>
<Text color="gray">
<Text color="cyan">[Enter]</Text> Search{" "}
<Text color="cyan">[Esc]</Text> Cancel
</Text>
</Box>
</Box>
);
}
// Render browse mode
const hasMore = servers.length > scrollOffset + MAX_VISIBLE;
const hasLess = scrollOffset > 0;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="space-between" marginBottom={1}>
<Text color="cyan" bold>
MCP Server Browser
</Text>
<Text color="gray">
{servers.length} servers
{selectedCategory && `${MCP_CATEGORY_LABELS[selectedCategory]}`}
</Text>
</Box>
{hasLess && (
<Box justifyContent="center">
<Text color="gray"> more</Text>
</Box>
)}
{visibleItems.map((server, index) => {
const actualIndex = index + scrollOffset;
const isSelected = actualIndex === selectedIndex;
const installed = isServerInstalled((server as MCPRegistryServer).id);
return (
<Box
key={(server as MCPRegistryServer).id}
paddingX={1}
backgroundColor={isSelected ? "cyan" : undefined}
>
<Box width={45}>
<Text
color={isSelected ? "black" : installed ? "cyan" : "white"}
bold={isSelected}
>
{(server as MCPRegistryServer).verified ? "✓ " : " "}
{(server as MCPRegistryServer).name}
</Text>
</Box>
<Box width={10} justifyContent="flex-end">
{installed ? (
<Text color={isSelected ? "black" : "cyan"}>installed</Text>
) : (
<Text color={isSelected ? "black" : "gray"}>
{MCP_CATEGORY_ICONS[(server as MCPRegistryServer).category]}
</Text>
)}
</Box>
</Box>
);
})}
{hasMore && (
<Box justifyContent="center">
<Text color="gray"> more</Text>
</Box>
)}
{message && (
<Box justifyContent="center" marginTop={1}>
<Text
color={
messageType === "success"
? "green"
: messageType === "error"
? "red"
: "yellow"
}
>
{message}
</Text>
</Box>
)}
<Box
justifyContent="center"
marginTop={1}
paddingTop={1}
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
>
<Text color="gray">
<Text color="cyan">[/]</Text> Search{" "}
<Text color="cyan">[c]</Text> Category{" "}
<Text color="cyan">[i]</Text> Install{" "}
<Text color="cyan">[Esc]</Text> Close
</Text>
</Box>
</Box>
);
}
export default MCPBrowser;

View File

@@ -1,533 +0,0 @@
/**
* MCPSelect Component - MCP server management menu
*
* Shows configured MCP servers with status and allows adding new servers
*/
import React, { useState, useMemo, useEffect } from "react";
import { Box, Text, useInput } from "ink";
import {
initializeMCP,
getMCPConfig,
getServerInstances,
connectServer,
disconnectServer,
addServer,
} from "@services/mcp/index";
import type { MCPServerInstance, MCPServerConfig } from "@/types/mcp";
interface MCPSelectProps {
onClose: () => void;
onBrowse?: () => void;
isActive?: boolean;
}
type MenuMode = "list" | "add_name" | "add_command" | "add_args";
type ActionType = "add" | "browse" | "search" | "popular";
interface MenuItem {
id: string;
name: string;
type: "server" | "action";
actionType?: ActionType;
server?: MCPServerInstance;
config?: MCPServerConfig;
}
const MAX_VISIBLE = 8;
const STATE_COLORS: Record<string, string> = {
connected: "green",
connecting: "yellow",
disconnected: "gray",
error: "red",
};
export function MCPSelect({
onClose,
onBrowse,
isActive = true,
}: MCPSelectProps): React.ReactElement {
const [servers, setServers] = useState<Map<string, MCPServerInstance>>(
new Map(),
);
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
// Add new server state
const [mode, setMode] = useState<MenuMode>("list");
const [newServerName, setNewServerName] = useState("");
const [newServerCommand, setNewServerCommand] = useState("");
const [newServerArgs, setNewServerArgs] = useState("");
// Load servers on mount
const loadServers = async () => {
setLoading(true);
await initializeMCP();
const instances = getServerInstances();
setServers(instances);
setLoading(false);
};
useEffect(() => {
loadServers();
}, []);
// Build menu items
const menuItems = useMemo((): MenuItem[] => {
const items: MenuItem[] = [];
// Add action items first
items.push({
id: "__browse__",
name: "🔍 Browse & Search servers",
type: "action",
actionType: "browse",
});
items.push({
id: "__popular__",
name: "⭐ Popular servers",
type: "action",
actionType: "popular",
});
items.push({
id: "__add__",
name: "+ Add server manually",
type: "action",
actionType: "add",
});
// Add servers
for (const [name, instance] of servers) {
items.push({
id: name,
name,
type: "server",
server: instance,
config: instance.config,
});
}
return items;
}, [servers]);
// Filter items
const filteredItems = useMemo(() => {
if (!filter) return menuItems;
const query = filter.toLowerCase();
return menuItems.filter((item) => item.name.toLowerCase().includes(query));
}, [menuItems, filter]);
// Handle server toggle (connect/disconnect)
const toggleServer = async (item: MenuItem) => {
if (!item.server) return;
setMessage(null);
try {
if (item.server.state === "connected") {
await disconnectServer(item.id);
setMessage(`Disconnected from ${item.id}`);
} else {
setMessage(`Connecting to ${item.id}...`);
await connectServer(item.id);
setMessage(`Connected to ${item.id}`);
}
await loadServers();
} catch (err) {
setMessage(`Error: ${err}`);
}
};
// Handle adding new server
const handleAddServer = async () => {
if (!newServerName || !newServerCommand) {
setMessage("Name and command are required");
setMode("list");
return;
}
setMessage(`Adding ${newServerName}...`);
try {
const args = newServerArgs.split(/\s+/).filter((a) => a.length > 0);
await addServer(newServerName, {
command: newServerCommand,
args: args.length > 0 ? args : undefined,
enabled: true,
});
setMessage(`Added ${newServerName}`);
setNewServerName("");
setNewServerCommand("");
setNewServerArgs("");
setMode("list");
await loadServers();
} catch (err) {
setMessage(`Error: ${err}`);
setMode("list");
}
};
useInput(
(input, key) => {
if (!isActive) return;
// Handle add server input modes
if (mode === "add_name") {
if (key.escape) {
setMode("list");
setNewServerName("");
return;
}
if (key.return) {
if (newServerName.trim()) {
setMode("add_command");
}
return;
}
if (key.backspace || key.delete) {
setNewServerName(newServerName.slice(0, -1));
return;
}
if (input && !key.ctrl && !key.meta) {
setNewServerName(newServerName + input);
}
return;
}
if (mode === "add_command") {
if (key.escape) {
setMode("add_name");
return;
}
if (key.return) {
if (newServerCommand.trim()) {
setMode("add_args");
}
return;
}
if (key.backspace || key.delete) {
setNewServerCommand(newServerCommand.slice(0, -1));
return;
}
if (input && !key.ctrl && !key.meta) {
setNewServerCommand(newServerCommand + input);
}
return;
}
if (mode === "add_args") {
if (key.escape) {
setMode("add_command");
return;
}
if (key.return) {
handleAddServer();
return;
}
if (key.backspace || key.delete) {
setNewServerArgs(newServerArgs.slice(0, -1));
return;
}
if (input && !key.ctrl && !key.meta) {
setNewServerArgs(newServerArgs + input);
}
return;
}
// List mode
if (key.escape) {
onClose();
return;
}
if (key.return) {
if (filteredItems.length > 0) {
const selected = filteredItems[selectedIndex];
if (selected) {
if (selected.type === "action") {
const actionHandlers: Record<ActionType, () => void> = {
add: () => {
setMode("add_name");
setMessage(null);
},
browse: () => {
if (onBrowse) {
onBrowse();
} else {
onClose();
}
},
popular: () => {
if (onBrowse) {
onBrowse();
} else {
onClose();
}
},
search: () => {
if (onBrowse) {
onBrowse();
} else {
onClose();
}
},
};
const handler = actionHandlers[selected.actionType || "add"];
handler();
} else if (selected.type === "server") {
toggleServer(selected);
}
}
}
return;
}
// Navigate up
if (key.upArrow) {
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredItems.length - 1;
if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
if (prev === 0 && newIndex === filteredItems.length - 1) {
setScrollOffset(Math.max(0, filteredItems.length - MAX_VISIBLE));
}
return newIndex;
});
return;
}
// Navigate down
if (key.downArrow) {
setSelectedIndex((prev) => {
const newIndex = prev < filteredItems.length - 1 ? prev + 1 : 0;
if (newIndex >= scrollOffset + MAX_VISIBLE) {
setScrollOffset(newIndex - MAX_VISIBLE + 1);
}
if (prev === filteredItems.length - 1 && newIndex === 0) {
setScrollOffset(0);
}
return newIndex;
});
return;
}
// Backspace
if (key.backspace || key.delete) {
if (filter.length > 0) {
setFilter(filter.slice(0, -1));
setSelectedIndex(0);
setScrollOffset(0);
}
return;
}
// Regular character input for filtering
if (input && !key.ctrl && !key.meta) {
setFilter(filter + input);
setSelectedIndex(0);
setScrollOffset(0);
}
},
{ isActive },
);
if (loading) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={1}
paddingY={0}
>
<Text color="magenta" bold>
Loading MCP servers...
</Text>
</Box>
);
}
// Add new server form
if (mode !== "list") {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={1}
paddingY={0}
>
<Box marginBottom={1}>
<Text color="magenta" bold>
Add New MCP Server
</Text>
</Box>
<Box flexDirection="column">
<Box>
<Text dimColor>Name: </Text>
<Text color={mode === "add_name" ? "cyan" : "white"}>
{newServerName || (mode === "add_name" ? "█" : "")}
</Text>
{mode === "add_name" && newServerName && (
<Text color="cyan"></Text>
)}
</Box>
<Box>
<Text dimColor>Command: </Text>
<Text color={mode === "add_command" ? "cyan" : "white"}>
{newServerCommand || (mode === "add_command" ? "█" : "")}
</Text>
{mode === "add_command" && newServerCommand && (
<Text color="cyan"></Text>
)}
</Box>
<Box>
<Text dimColor>Args (space-separated): </Text>
<Text color={mode === "add_args" ? "cyan" : "white"}>
{newServerArgs || (mode === "add_args" ? "█" : "(optional)")}
</Text>
{mode === "add_args" && newServerArgs && (
<Text color="cyan"></Text>
)}
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>
{mode === "add_name" && "Enter server name, then press Enter"}
{mode === "add_command" && "Enter command (e.g., npx), then Enter"}
{mode === "add_args" && "Enter args or press Enter to finish"}
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Esc to go back</Text>
</Box>
{message && (
<Box marginTop={1}>
<Text color="yellow">{message}</Text>
</Box>
)}
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={1}
paddingY={0}
>
<Box marginBottom={1}>
<Text color="magenta" bold>
MCP Servers
</Text>
{filter && (
<>
<Text dimColor> - filtering: </Text>
<Text color="yellow">{filter}</Text>
</>
)}
</Box>
{message && (
<Box marginBottom={1}>
<Text color="yellow">{message}</Text>
</Box>
)}
{filteredItems.length === 0 ? (
<Text dimColor>No servers match "{filter}"</Text>
) : (
<Box flexDirection="column">
{/* Scroll up indicator */}
{scrollOffset > 0 && (
<Text dimColor> {scrollOffset} more above</Text>
)}
{/* Visible items */}
{filteredItems
.slice(scrollOffset, scrollOffset + MAX_VISIBLE)
.map((item, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
if (item.type === "action") {
return (
<Box key={item.id}>
<Text
color={isSelected ? "magenta" : undefined}
bold={isSelected}
>
{isSelected ? "> " : " "}
</Text>
<Text color={isSelected ? "magenta" : "cyan"}>
{item.name}
</Text>
</Box>
);
}
const state = item.server?.state || "disconnected";
const stateColor = STATE_COLORS[state] || "gray";
const toolCount =
state === "connected"
? ` (${item.server?.tools.length || 0} tools)`
: "";
return (
<Box key={item.id} flexDirection="column">
<Box>
<Text
color={isSelected ? "magenta" : undefined}
bold={isSelected}
>
{isSelected ? "> " : " "}
</Text>
<Text color={isSelected ? "magenta" : "white"}>
{item.name}
</Text>
<Text> </Text>
<Text color={stateColor}>[{state}]</Text>
<Text dimColor>{toolCount}</Text>
</Box>
{item.server?.error && (
<Box marginLeft={4}>
<Text color="red">{item.server.error}</Text>
</Box>
)}
</Box>
);
})}
{/* Scroll down indicator */}
{scrollOffset + MAX_VISIBLE < filteredItems.length && (
<Text dimColor>
{filteredItems.length - scrollOffset - MAX_VISIBLE} more below
</Text>
)}
</Box>
)}
<Box marginTop={1}>
<Text dimColor>
navigate | Enter toggle/add | Type to filter | Esc close
</Text>
</Box>
</Box>
);
}

View File

@@ -1,264 +0,0 @@
/**
* ModelSelect Component - Model selection menu
*
* Shows available models with "auto" option like VSCode Copilot
* Displays cost multiplier for each model (0.0x = unlimited, 1.0x = standard, etc.)
*/
import React, { useState, useMemo } from "react";
import { Box, Text, useInput } from "ink";
import { useAppStore } from "@tui/store";
import type { ProviderModel } from "@/types/providers";
interface ModelSelectProps {
onSelect: (model: string) => void;
onClose: () => void;
isActive?: boolean;
}
const MAX_VISIBLE = 10;
// Auto model option
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): string | undefined => {
if (model.id === "auto") return undefined;
const multiplier = model.costMultiplier;
if (multiplier === undefined) {
return "gray";
}
if (multiplier === 0 || model.isUnlimited) {
return "green";
}
if (multiplier <= 0.1) {
return "cyan";
}
if (multiplier <= 1.0) {
return "yellow";
}
return "red";
};
export function ModelSelect({
onSelect,
onClose,
isActive = true,
}: ModelSelectProps): React.ReactElement {
const availableModels = useAppStore((state) => state.availableModels);
const currentModel = useAppStore((state) => state.model);
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [filter, setFilter] = useState("");
// Add "auto" option at the beginning
const allModels = useMemo((): ProviderModel[] => {
return [AUTO_MODEL, ...availableModels];
}, [availableModels]);
// Filter models based on input
const filteredModels = useMemo((): ProviderModel[] => {
if (!filter) return allModels;
const query = filter.toLowerCase();
return allModels.filter(
(model) =>
model.id.toLowerCase().includes(query) ||
model.name.toLowerCase().includes(query),
);
}, [allModels, filter]);
useInput(
(input, key) => {
if (!isActive) return;
// Escape to close
if (key.escape) {
onClose();
return;
}
// Enter to select
if (key.return) {
if (filteredModels.length > 0) {
const selected = filteredModels[selectedIndex];
if (selected) {
onSelect(selected.id);
onClose();
}
}
return;
}
// Navigate up
if (key.upArrow) {
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredModels.length - 1;
// Scroll up if needed
if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
// Jump to bottom - scroll to show bottom items
if (prev === 0 && newIndex === filteredModels.length - 1) {
setScrollOffset(Math.max(0, filteredModels.length - MAX_VISIBLE));
}
return newIndex;
});
return;
}
// Navigate down
if (key.downArrow) {
setSelectedIndex((prev) => {
const newIndex = prev < filteredModels.length - 1 ? prev + 1 : 0;
// Scroll down if needed
if (newIndex >= scrollOffset + MAX_VISIBLE) {
setScrollOffset(newIndex - MAX_VISIBLE + 1);
}
// Jump to top - scroll to top
if (prev === filteredModels.length - 1 && newIndex === 0) {
setScrollOffset(0);
}
return newIndex;
});
return;
}
// Backspace
if (key.backspace || key.delete) {
if (filter.length > 0) {
setFilter(filter.slice(0, -1));
setSelectedIndex(0);
setScrollOffset(0);
}
return;
}
// Regular character input for filtering
if (input && !key.ctrl && !key.meta) {
setFilter(filter + input);
setSelectedIndex(0);
setScrollOffset(0);
}
},
{ isActive },
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={1}
paddingY={0}
>
<Box marginBottom={1}>
<Text color="magenta" bold>
Select Model
</Text>
{filter && (
<>
<Text dimColor> - filtering: </Text>
<Text color="yellow">{filter}</Text>
</>
)}
</Box>
<Box marginBottom={1}>
<Text dimColor>Current: </Text>
<Text color="cyan">{currentModel}</Text>
</Box>
{filteredModels.length === 0 ? (
<Text dimColor>No models match "{filter}"</Text>
) : (
<Box flexDirection="column">
{/* Scroll up indicator */}
{scrollOffset > 0 && (
<Text dimColor> {scrollOffset} more above</Text>
)}
{/* Visible models */}
{filteredModels
.slice(scrollOffset, scrollOffset + MAX_VISIBLE)
.map((model, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const isCurrent = model.id === currentModel;
const isAuto = model.id === "auto";
const costLabel = formatCostMultiplier(model);
const costColor = getCostColor(model);
return (
<Box key={model.id}>
<Text
color={isSelected ? "magenta" : undefined}
bold={isSelected}
>
{isSelected ? "> " : " "}
</Text>
<Text
color={
isAuto ? "yellow" : isSelected ? "magenta" : undefined
}
bold={isSelected || isAuto}
>
{model.id}
</Text>
{costLabel && !isAuto && (
<Text color={costColor}> [{costLabel}]</Text>
)}
{isCurrent && <Text color="green"> (current)</Text>}
{isAuto && (
<Text dimColor> - Let Copilot choose the best model</Text>
)}
</Box>
);
})}
{/* Scroll down indicator */}
{scrollOffset + MAX_VISIBLE < filteredModels.length && (
<Text dimColor>
{" "}
{filteredModels.length - scrollOffset - MAX_VISIBLE} more below
</Text>
)}
</Box>
)}
<Box marginTop={1} flexDirection="column">
<Box>
<Text dimColor>Cost: </Text>
<Text color="green">Unlimited</Text>
<Text dimColor> | </Text>
<Text color="cyan">Low</Text>
<Text dimColor> | </Text>
<Text color="yellow">Standard</Text>
<Text dimColor> | </Text>
<Text color="red">Premium</Text>
</Box>
<Text dimColor>
navigate | Enter select | Type to filter | Esc close
</Text>
</Box>
</Box>
);
}

View File

@@ -1,78 +0,0 @@
/**
* Permission Modal Component - Y/N prompts for tool execution
*/
import React from "react";
import { Box, Text } from "ink";
import { useAppStore } from "@tui/store";
import { SelectMenu } from "@tui/components/SelectMenu";
import type {
SelectOption,
PermissionType,
PermissionScope,
} from "@/types/tui";
import {
PERMISSION_OPTIONS,
PERMISSION_TYPE_LABELS,
} from "@constants/tui-components";
export function PermissionModal(): React.ReactElement | null {
const permissionRequest = useAppStore((state) => state.permissionRequest);
const setPermissionRequest = useAppStore(
(state) => state.setPermissionRequest,
);
if (!permissionRequest) return null;
const handleSelect = (option: SelectOption): void => {
permissionRequest.resolve({
allowed: option.value !== "deny",
scope:
option.value === "deny" ? undefined : (option.value as PermissionScope),
});
setPermissionRequest(null);
};
const typeLabel =
PERMISSION_TYPE_LABELS[permissionRequest.type as PermissionType] ??
"Unknown operation";
return (
<Box
flexDirection="column"
borderStyle="double"
borderColor="yellow"
paddingX={2}
paddingY={1}
marginY={1}
>
<Box marginBottom={1}>
<Text color="yellow" bold>
Permission Required
</Text>
</Box>
<Box marginBottom={1}>
<Text>{typeLabel}: </Text>
<Text color="cyan" bold>
{permissionRequest.command ||
permissionRequest.path ||
permissionRequest.description}
</Text>
</Box>
{permissionRequest.description &&
permissionRequest.description !== permissionRequest.command && (
<Box marginBottom={1}>
<Text dimColor>{permissionRequest.description}</Text>
</Box>
)}
<SelectMenu
options={PERMISSION_OPTIONS}
onSelect={handleSelect}
isActive={!!permissionRequest}
/>
</Box>
);
}

View File

@@ -1,104 +0,0 @@
/**
* SelectMenu Component - Keyboard navigable selection menu
*
* - Up/Down arrows to navigate
* - Enter to select
* - Optional shortcut keys for quick selection
*/
import React, { useState } from "react";
import { Box, Text, useInput } from "ink";
import type { SelectOption } from "@/types/tui";
// Re-export type for backwards compatibility
export type { SelectOption } from "@/types/tui";
interface SelectMenuProps {
options: SelectOption[];
onSelect: (option: SelectOption) => void;
title?: string;
initialIndex?: number;
isActive?: boolean;
}
export function SelectMenu({
options,
onSelect,
title,
initialIndex = 0,
isActive = true,
}: SelectMenuProps): React.ReactElement {
const [selectedIndex, setSelectedIndex] = useState(initialIndex);
useInput(
(input, key) => {
if (!isActive) return;
// Navigate with arrow keys
if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
return;
}
if (key.downArrow) {
setSelectedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
return;
}
// Select with Enter
if (key.return) {
onSelect(options[selectedIndex]);
return;
}
// Quick select with shortcut key
const lowerInput = input.toLowerCase();
const optionIndex = options.findIndex(
(o) => o.key?.toLowerCase() === lowerInput,
);
if (optionIndex !== -1) {
onSelect(options[optionIndex]);
}
},
{ isActive },
);
return (
<Box flexDirection="column">
{title && (
<Box marginBottom={1}>
<Text bold color="yellow">
{title}
</Text>
</Box>
)}
{options.map((option, index) => {
const isSelected = index === selectedIndex;
return (
<Box key={option.value}>
<Text color={isSelected ? "cyan" : undefined} bold={isSelected}>
{isSelected ? " " : " "}
</Text>
{option.key && (
<Text color={isSelected ? "cyan" : "gray"}>[{option.key}] </Text>
)}
<Text color={isSelected ? "cyan" : undefined} bold={isSelected}>
{option.label}
</Text>
{option.description && (
<Text dimColor> - {option.description}</Text>
)}
</Box>
);
})}
<Box marginTop={1}>
<Text dimColor> to navigate Enter to select</Text>
{options.some((o) => o.key) && (
<Text dimColor> or press shortcut key</Text>
)}
</Box>
</Box>
);
}

View File

@@ -1,205 +0,0 @@
/**
* Status Bar Component - Shows session stats, keyboard hints, and current mode
*/
import React, { useEffect, useState, useRef } from "react";
import { Box, Text } from "ink";
import { useAppStore } from "@tui/store";
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";
import { useTodoStore } from "@tui/hooks/useTodoStore";
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;
// Override text for dynamic modes
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(): React.ReactElement {
const mode = useAppStore((state) => state.mode);
const currentToolCall = useAppStore((state) => state.currentToolCall);
const sessionStats = useAppStore((state) => state.sessionStats);
const todosVisible = useAppStore((state) => state.todosVisible);
const interruptPending = useAppStore((state) => state.interruptPending);
const hasPlan = useTodoStore((state) => state.currentPlan !== null);
// Elapsed time tracking with re-render every second
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setElapsed(Date.now() - sessionStats.startTime);
}, 1000);
return () => clearInterval(timer);
}, [sessionStats.startTime]);
// Calculate thinking time - only update once per second to reduce re-renders
const [thinkingTime, setThinkingTime] = useState(0);
useEffect(() => {
if (sessionStats.thinkingStartTime === null) {
setThinkingTime(0);
return;
}
// Update immediately
setThinkingTime(
Math.floor((Date.now() - sessionStats.thinkingStartTime) / 1000),
);
// Then update every second (not 100ms to reduce re-renders)
const timer = setInterval(() => {
setThinkingTime(
Math.floor((Date.now() - sessionStats.thinkingStartTime!) / 1000),
);
}, 1000);
return () => clearInterval(timer);
}, [sessionStats.thinkingStartTime]);
// Rotating whimsical status message
const [statusMessage, setStatusMessage] = useState("");
const prevModeRef = useRef(mode);
const prevToolRef = useRef(currentToolCall?.name);
useEffect(() => {
const isProcessing = mode === "thinking" || mode === "tool_execution";
if (!isProcessing) {
setStatusMessage("");
return;
}
// Generate new message when mode or tool changes
const modeChanged = prevModeRef.current !== mode;
const toolChanged = prevToolRef.current !== currentToolCall?.name;
if (modeChanged || toolChanged) {
if (mode === "thinking") {
setStatusMessage(getThinkingMessage());
} else if (mode === "tool_execution" && currentToolCall) {
setStatusMessage(
getToolMessage(currentToolCall.name, currentToolCall.description),
);
}
}
prevModeRef.current = mode;
prevToolRef.current = currentToolCall?.name;
// Rotate message every 2.5 seconds during processing
const timer = setInterval(() => {
if (mode === "thinking") {
setStatusMessage(getThinkingMessage());
} else if (mode === "tool_execution" && currentToolCall) {
setStatusMessage(
getToolMessage(currentToolCall.name, currentToolCall.description),
);
}
}, 2500);
return () => clearInterval(timer);
}, [mode, currentToolCall]);
const isProcessing = mode === "thinking" || mode === "tool_execution";
const totalTokens = sessionStats.inputTokens + sessionStats.outputTokens;
// Build status hints
const hints: string[] = [];
// Interrupt hint
if (isProcessing) {
hints.push(
interruptPending
? STATUS_HINTS.INTERRUPT_CONFIRM
: STATUS_HINTS.INTERRUPT,
);
}
// Todo toggle hint (only show when there's a plan)
if (hasPlan) {
hints.push(
todosVisible ? STATUS_HINTS.TOGGLE_TODOS : STATUS_HINTS.TOGGLE_TODOS_SHOW,
);
}
// Elapsed time
hints.push(formatDuration(elapsed));
// Token count
if (totalTokens > 0) {
hints.push(`${formatTokens(totalTokens)} tokens`);
}
// Thinking time (only during thinking or show last duration)
if (sessionStats.thinkingStartTime !== null) {
hints.push(`${thinkingTime}s`);
} else if (sessionStats.lastThinkingDuration > 0 && mode === "idle") {
hints.push(`thought for ${sessionStats.lastThinkingDuration}s`);
}
const modeDisplay = getModeDisplay(mode, statusMessage, currentToolCall);
return (
<Box
paddingX={1}
justifyContent="space-between"
borderStyle="single"
borderColor="gray"
borderTop={false}
borderLeft={false}
borderRight={false}
>
<Box>
<Text color={modeDisplay.color}>{modeDisplay.text}</Text>
</Box>
<Box>
<Text dimColor>{hints.join(STATUS_SEPARATOR)}</Text>
</Box>
</Box>
);
}

View File

@@ -1,78 +0,0 @@
/**
* Streaming Message Component
*
* Renders an assistant message that updates in real-time as content streams in.
* Shows a cursor indicator while streaming is active.
*/
import React from "react";
import { Box, Text } from "ink";
import type { LogEntry } from "@/types/tui";
// =============================================================================
// Props
// =============================================================================
interface StreamingMessageProps {
entry: LogEntry;
}
// =============================================================================
// Streaming Cursor Component
// =============================================================================
const StreamingCursor: React.FC = () => {
const [visible, setVisible] = React.useState(true);
React.useEffect(() => {
const interval = setInterval(() => {
setVisible((v) => !v);
}, 500);
return () => clearInterval(interval);
}, []);
return <Text color="green">{visible ? "▌" : " "}</Text>;
};
// =============================================================================
// Main Component
// =============================================================================
export const StreamingMessage: React.FC<StreamingMessageProps> = ({
entry,
}) => {
const isStreaming = entry.metadata?.isStreaming ?? false;
const content = entry.content || "";
// Split content into lines for display
const lines = content.split("\n");
const lastLineIndex = lines.length - 1;
return (
<Box flexDirection="column" marginBottom={1}>
<Text color="green" bold>
CodeTyper
</Text>
<Box marginLeft={2} flexDirection="column">
{lines.map((line, index) => (
<Box key={index}>
<Text>{line}</Text>
{isStreaming && index === lastLineIndex && <StreamingCursor />}
</Box>
))}
{content === "" && isStreaming && (
<Box>
<StreamingCursor />
</Box>
)}
</Box>
</Box>
);
};
// =============================================================================
// Export
// =============================================================================
export default StreamingMessage;

View File

@@ -1,199 +0,0 @@
/**
* ThemeSelect Component - Theme selection menu
*
* Shows available color themes for the TUI
* Previews themes live as user navigates
*/
import React, { useState, useMemo, useEffect, useRef } from "react";
import { Box, Text, useInput } from "ink";
import { useThemeStore, useThemeColors } from "@tui/hooks/useThemeStore";
import { THEMES } from "@constants/themes";
interface ThemeSelectProps {
onSelect: (theme: string) => void;
onClose: () => void;
isActive?: boolean;
}
export function ThemeSelect({
onSelect,
onClose,
isActive = true,
}: ThemeSelectProps): React.ReactElement {
const currentTheme = useThemeStore((state) => state.currentTheme);
const setTheme = useThemeStore((state) => state.setTheme);
const colors = useThemeColors();
const [selectedIndex, setSelectedIndex] = useState(0);
const [filter, setFilter] = useState("");
// Store the original theme to revert on cancel
const originalTheme = useRef<string>(currentTheme);
const allThemes = useMemo(() => {
return Object.values(THEMES).map((theme) => ({
name: theme.name,
displayName: theme.displayName,
colors: theme.colors,
}));
}, []);
const filteredThemes = useMemo(() => {
if (!filter) return allThemes;
const query = filter.toLowerCase();
return allThemes.filter(
(theme) =>
theme.name.toLowerCase().includes(query) ||
theme.displayName.toLowerCase().includes(query),
);
}, [allThemes, filter]);
// Preview theme as user navigates
useEffect(() => {
if (filteredThemes.length > 0 && filteredThemes[selectedIndex]) {
const previewTheme = filteredThemes[selectedIndex].name;
setTheme(previewTheme);
}
}, [selectedIndex, filteredThemes, setTheme]);
// Store original theme on mount
useEffect(() => {
originalTheme.current = currentTheme;
}, []);
const handleCancel = () => {
// Revert to original theme
setTheme(originalTheme.current);
onClose();
};
const handleConfirm = () => {
if (filteredThemes.length > 0) {
const selected = filteredThemes[selectedIndex];
if (selected) {
// Theme is already applied via preview, just confirm it
onSelect(selected.name);
onClose();
}
}
};
useInput(
(input, key) => {
if (!isActive) return;
if (key.escape) {
handleCancel();
return;
}
if (key.return) {
handleConfirm();
return;
}
if (key.upArrow) {
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : filteredThemes.length - 1,
);
return;
}
if (key.downArrow) {
setSelectedIndex((prev) =>
prev < filteredThemes.length - 1 ? prev + 1 : 0,
);
return;
}
if (key.backspace || key.delete) {
if (filter.length > 0) {
setFilter(filter.slice(0, -1));
setSelectedIndex(0);
}
return;
}
if (input && !key.ctrl && !key.meta) {
setFilter(filter + input);
setSelectedIndex(0);
}
},
{ isActive },
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.borderModal}
paddingX={1}
paddingY={0}
>
<Box marginBottom={1}>
<Text color={colors.accent} bold>
Select Theme
</Text>
{filter && (
<>
<Text dimColor> - filtering: </Text>
<Text color={colors.warning}>{filter}</Text>
</>
)}
</Box>
<Box marginBottom={1}>
<Text dimColor>Original: </Text>
<Text color={colors.primary}>
{THEMES[originalTheme.current]?.displayName ?? originalTheme.current}
</Text>
<Text dimColor> Preview: </Text>
<Text color={colors.accent} bold>
{THEMES[currentTheme]?.displayName ?? currentTheme}
</Text>
</Box>
{filteredThemes.length === 0 ? (
<Text dimColor>No themes match "{filter}"</Text>
) : (
<Box flexDirection="column">
{filteredThemes.map((theme, index) => {
const isSelected = index === selectedIndex;
const isOriginal = theme.name === originalTheme.current;
return (
<Box key={theme.name}>
<Text
color={isSelected ? colors.accent : undefined}
bold={isSelected}
>
{isSelected ? "> " : " "}
</Text>
<Text
color={isSelected ? colors.accent : undefined}
bold={isSelected}
>
{theme.displayName}
</Text>
{isOriginal && <Text color={colors.success}> (original)</Text>}
<Text dimColor> </Text>
{/* Color preview squares */}
<Text color={theme.colors.primary}></Text>
<Text color={theme.colors.success}></Text>
<Text color={theme.colors.warning}></Text>
<Text color={theme.colors.error}></Text>
<Text color={theme.colors.accent}></Text>
</Box>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text dimColor>
live preview | Enter confirm | Type to filter | Esc cancel
</Text>
</Box>
</Box>
);
}

View File

@@ -1,206 +0,0 @@
/**
* TodoPanel Component - Shows agent-generated task plan as a right-side pane
*
* Displays current plan with task status and progress in Claude Code style:
* - Spinner with current task title, duration, and tokens
* - ✓ with strikethrough for completed tasks
* - ■ for in_progress tasks
* - □ for pending tasks
* - Collapsible completed tasks view
*/
import React, { useEffect, useState } from "react";
import { Box, Text } from "ink";
import { useTodoStore } from "@tui/hooks/useTodoStore";
import { useAppStore } from "@tui/store";
import type { TodoItem, TodoStatus } from "@/types/todo";
const STATUS_ICONS: Record<TodoStatus, string> = {
pending: "□",
in_progress: "■",
completed: "✓",
failed: "✗",
};
const STATUS_COLORS: Record<TodoStatus, string> = {
pending: "white",
in_progress: "yellow",
completed: "green",
failed: "red",
};
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const MAX_VISIBLE_COMPLETED = 3;
const PANEL_WIDTH = 50;
const formatDuration = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
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`;
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
};
const formatTokens = (count: number): string => {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`;
}
return String(count);
};
interface TaskItemProps {
item: TodoItem;
isLast: boolean;
}
const TaskItem = ({ item, isLast }: TaskItemProps): React.ReactElement => {
const icon = STATUS_ICONS[item.status];
const color = STATUS_COLORS[item.status];
const isCompleted = item.status === "completed";
const isInProgress = item.status === "in_progress";
// Tree connector: L for last item, ├ for other items
const connector = isLast ? "└" : "├";
return (
<Box>
<Text dimColor>{connector} </Text>
<Text color={color}>{icon} </Text>
<Text
color={isInProgress ? "white" : color}
bold={isInProgress}
strikethrough={isCompleted}
dimColor={isCompleted}
>
{item.title}
</Text>
</Box>
);
};
export function TodoPanel(): React.ReactElement | null {
const currentPlan = useTodoStore((state) => state.currentPlan);
const todosVisible = useAppStore((state) => state.todosVisible);
const sessionStats = useAppStore((state) => state.sessionStats);
// Spinner animation
const [spinnerFrame, setSpinnerFrame] = useState(0);
// Elapsed time tracking
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!currentPlan) return;
const timer = setInterval(() => {
setSpinnerFrame((f) => (f + 1) % SPINNER_FRAMES.length);
setElapsed(Date.now() - currentPlan.createdAt);
}, 100);
return () => clearInterval(timer);
}, [currentPlan]);
// Don't render if no plan or hidden
if (!currentPlan || !todosVisible) {
return null;
}
const { completed, total } = useTodoStore.getState().getProgress();
const totalTokens = sessionStats.inputTokens + sessionStats.outputTokens;
// Get current in_progress task
const currentTask = currentPlan.items.find(
(item) => item.status === "in_progress",
);
// Separate tasks by status
const completedTasks = currentPlan.items.filter(
(item) => item.status === "completed",
);
const pendingTasks = currentPlan.items.filter(
(item) => item.status === "pending",
);
const inProgressTasks = currentPlan.items.filter(
(item) => item.status === "in_progress",
);
const failedTasks = currentPlan.items.filter(
(item) => item.status === "failed",
);
// Determine which completed tasks to show (most recent)
const visibleCompletedTasks = completedTasks.slice(-MAX_VISIBLE_COMPLETED);
const hiddenCompletedCount = Math.max(
0,
completedTasks.length - MAX_VISIBLE_COMPLETED,
);
// Build task list in display order:
// 1. Visible completed tasks (oldest of the visible first)
// 2. In-progress task (current)
// 3. Pending tasks
// 4. Failed tasks
const displayTasks = [
...visibleCompletedTasks,
...inProgressTasks,
...pendingTasks,
...failedTasks,
];
return (
<Box
flexDirection="column"
width={PANEL_WIDTH}
borderStyle="single"
borderColor="gray"
paddingX={1}
>
{/* Header with spinner, task name, duration, and tokens */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color="magenta">{SPINNER_FRAMES[spinnerFrame]} </Text>
<Text color="white" bold>
{currentTask?.title ?? currentPlan.title}
</Text>
</Box>
<Box>
<Text dimColor>
({formatDuration(elapsed)} · {formatTokens(totalTokens)} tokens)
</Text>
</Box>
</Box>
{/* Task list with tree connectors */}
<Box flexDirection="column">
{displayTasks.map((item, index) => (
<TaskItem
key={item.id}
item={item}
isLast={index === displayTasks.length - 1 && hiddenCompletedCount === 0}
/>
))}
{/* Hidden completed tasks summary */}
{hiddenCompletedCount > 0 && (
<Box>
<Text dimColor> ... +{hiddenCompletedCount} completed</Text>
</Box>
)}
</Box>
{/* Footer with progress */}
<Box marginTop={1} justifyContent="space-between">
<Text dimColor>
{completed}/{total} tasks
</Text>
<Text dimColor>Ctrl+T to hide</Text>
</Box>
</Box>
);
}

View File

@@ -1,150 +0,0 @@
/**
* VimStatusLine Component
*
* Displays the current vim mode and hints
*/
import React from "react";
import { Box, Text } from "ink";
import { useVimStore } from "@tui/hooks/useVimStore";
import { useThemeColors } from "@tui/hooks/useThemeStore";
import {
VIM_MODE_LABELS,
VIM_MODE_COLORS,
VIM_MODE_HINTS,
} from "@constants/vim";
import type { VimMode } from "@/types/vim";
/**
* Props for VimStatusLine
*/
interface VimStatusLineProps {
/** Whether to show hints */
showHints?: boolean;
/** Whether to show command buffer in command mode */
showCommandBuffer?: boolean;
/** Whether to show search pattern */
showSearchPattern?: boolean;
}
/**
* Get mode display color
*/
const getModeColor = (mode: VimMode): string => {
return VIM_MODE_COLORS[mode] || "white";
};
/**
* VimStatusLine Component
*/
export const VimStatusLine: React.FC<VimStatusLineProps> = ({
showHints = true,
showCommandBuffer = true,
showSearchPattern = true,
}) => {
const enabled = useVimStore((state) => state.enabled);
const mode = useVimStore((state) => state.mode);
const commandBuffer = useVimStore((state) => state.commandBuffer);
const searchPattern = useVimStore((state) => state.searchPattern);
const searchMatches = useVimStore((state) => state.searchMatches);
const currentMatchIndex = useVimStore((state) => state.currentMatchIndex);
const colors = useThemeColors();
// Don't render if vim mode is disabled
if (!enabled) {
return null;
}
const modeLabel = VIM_MODE_LABELS[mode];
const modeColor = getModeColor(mode);
const modeHint = VIM_MODE_HINTS[mode];
// Build status content
const renderModeIndicator = () => (
<Box marginRight={1}>
<Text backgroundColor={modeColor} color="black" bold>
{` ${modeLabel} `}
</Text>
</Box>
);
const renderCommandBuffer = () => {
if (!showCommandBuffer || mode !== "command" || !commandBuffer) {
return null;
}
return (
<Box marginRight={1}>
<Text color={colors.primary}>:</Text>
<Text color={colors.text}>{commandBuffer.slice(1)}</Text>
<Text color={colors.primary}>_</Text>
</Box>
);
};
const renderSearchInfo = () => {
if (!showSearchPattern || !searchPattern) {
return null;
}
const matchInfo =
searchMatches.length > 0
? ` [${currentMatchIndex + 1}/${searchMatches.length}]`
: " [no matches]";
return (
<Box marginRight={1}>
<Text color={colors.textDim}>
/{searchPattern}
{matchInfo}
</Text>
</Box>
);
};
const renderHints = () => {
if (!showHints || mode === "command") {
return null;
}
return (
<Box>
<Text dimColor>{modeHint}</Text>
</Box>
);
};
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
{renderModeIndicator()}
{renderCommandBuffer()}
{renderSearchInfo()}
</Box>
{renderHints()}
</Box>
);
};
/**
* Compact vim mode indicator (just the mode label)
*/
export const VimModeIndicator: React.FC = () => {
const enabled = useVimStore((state) => state.enabled);
const mode = useVimStore((state) => state.mode);
if (!enabled) {
return null;
}
const modeLabel = VIM_MODE_LABELS[mode];
const modeColor = getModeColor(mode);
return (
<Text backgroundColor={modeColor} color="black">
{` ${modeLabel} `}
</Text>
);
};
export default VimStatusLine;

View File

@@ -1,102 +0,0 @@
/**
* DiffView Component - Human-readable diff display for TUI
*
* Renders git-style diffs with:
* - Clear file headers
* - Line numbers
* - Color-coded additions/deletions
* - Syntax highlighting based on file type
* - Hunk separators
* - Summary statistics
*/
import React from "react";
import { Box, Text } from "ink";
import type { DiffViewProps } from "@/types/tui";
import {
detectLanguage,
getLanguageDisplayName,
} from "@utils/syntax-highlight/detect";
import { DiffLine } from "@tui/components/diff-view/line-renderers";
export function DiffView({
lines,
filePath,
additions = 0,
deletions = 0,
compact = false,
language: providedLanguage,
}: DiffViewProps): React.ReactElement {
// Calculate max line number width for alignment
const maxLineNum = lines.reduce((max, line) => {
const oldNum = line.oldLineNum ?? 0;
const newNum = line.newLineNum ?? 0;
return Math.max(max, oldNum, newNum);
}, 0);
const maxLineNumWidth = Math.max(3, String(maxLineNum).length);
const showLineNumbers = !compact && lines.length > 0;
// Auto-detect language from file path if not provided
const language =
providedLanguage || (filePath ? detectLanguage(filePath) : undefined);
const languageDisplay = language
? getLanguageDisplayName(language)
: undefined;
return (
<Box flexDirection="column" paddingY={1}>
{/* File header */}
{filePath && (
<Box marginBottom={1}>
<Text color="blue" bold>
📄{" "}
</Text>
<Text color="white" bold>
{filePath}
</Text>
{languageDisplay && (
<Text color="gray" dimColor>
{" "}
({languageDisplay})
</Text>
)}
</Box>
)}
{/* Diff content */}
<Box flexDirection="column" paddingLeft={1}>
{lines.length === 0 ? (
<Text dimColor>No changes</Text>
) : (
lines.map((line, index) => (
<DiffLine
key={index}
line={line}
showLineNumbers={showLineNumbers}
maxLineNumWidth={maxLineNumWidth}
language={language}
/>
))
)}
</Box>
{/* Summary */}
{(additions > 0 || deletions > 0) && (
<Box marginTop={1} paddingLeft={1}>
<Text dimColor>Changes: </Text>
{additions > 0 && (
<Text color="green" bold>
+{additions}{" "}
</Text>
)}
{deletions > 0 && (
<Text color="red" bold>
-{deletions}
</Text>
)}
</Box>
)}
</Box>
);
}

View File

@@ -1,223 +0,0 @@
/**
* Diff Line Renderer Components
*
* Each diff line type has its own renderer function
*/
import React from "react";
import { Box, Text, Transform } from "ink";
import type { DiffLineData, DiffLineProps, DiffLineType } from "@/types/tui";
import { highlightLine } from "@utils/syntax-highlight/highlight";
// ============================================================================
// Utility Components
// ============================================================================
interface HighlightedCodeProps {
content: string;
language?: string;
}
const HighlightedCode = ({
content,
language,
}: HighlightedCodeProps): React.ReactElement => {
if (!language || !content.trim()) {
return <Text>{content}</Text>;
}
const highlighted = highlightLine(content, language);
// Transform passes through the ANSI codes from cli-highlight
return (
<Transform transform={(output) => output}>
<Text>{highlighted}</Text>
</Transform>
);
};
// ============================================================================
// Line Number Formatting
// ============================================================================
const padNum = (num: number | undefined, width: number): string => {
if (num === undefined) return " ".repeat(width);
return String(num).padStart(width, " ");
};
// ============================================================================
// Line Renderers by Type
// ============================================================================
type LineRendererContext = {
showLineNumbers: boolean;
maxLineNumWidth: number;
language?: string;
};
const renderHeaderLine = (line: DiffLineData): React.ReactElement => (
<Box>
<Text color="white" bold>
{line.content}
</Text>
</Box>
);
const renderHunkLine = (line: DiffLineData): React.ReactElement => (
<Box marginTop={1}>
<Text color="cyan" dimColor>
{line.content}
</Text>
</Box>
);
const renderAddLine = (
line: DiffLineData,
ctx: LineRendererContext,
): React.ReactElement => (
<Box>
{ctx.showLineNumbers && (
<>
<Text color="gray" dimColor>
{" ".repeat(ctx.maxLineNumWidth)}
</Text>
<Text color="gray" dimColor>
{" "}
{" "}
</Text>
<Text color="green">
{padNum(line.newLineNum, ctx.maxLineNumWidth)}
</Text>
<Text color="gray" dimColor>
{" "}
{" "}
</Text>
</>
)}
<Text backgroundColor="#1a3d1a" color="white">
+{line.content}
</Text>
</Box>
);
const renderRemoveLine = (
line: DiffLineData,
ctx: LineRendererContext,
): React.ReactElement => (
<Box>
{ctx.showLineNumbers && (
<>
<Text color="red">{padNum(line.oldLineNum, ctx.maxLineNumWidth)}</Text>
<Text color="gray" dimColor>
{" "}
{" "}
</Text>
<Text color="gray" dimColor>
{" ".repeat(ctx.maxLineNumWidth)}
</Text>
<Text color="gray" dimColor>
{" "}
{" "}
</Text>
</>
)}
<Text backgroundColor="#3d1a1a" color="white">
-{line.content}
</Text>
</Box>
);
const renderContextLine = (
line: DiffLineData,
ctx: LineRendererContext,
): React.ReactElement => (
<Box>
{ctx.showLineNumbers && (
<>
<Text color="gray" dimColor>
{padNum(line.oldLineNum, ctx.maxLineNumWidth)}
</Text>
<Text color="gray" dimColor>
{" "}
{" "}
</Text>
<Text color="gray" dimColor>
{padNum(line.newLineNum, ctx.maxLineNumWidth)}
</Text>
<Text color="gray" dimColor>
{" "}
{" "}
</Text>
</>
)}
<Text color="gray"> </Text>
<HighlightedCode content={line.content} language={ctx.language} />
</Box>
);
const renderSummaryLine = (): React.ReactElement => (
<Box marginTop={1}>
<Text dimColor></Text>
</Box>
);
const renderDefaultLine = (line: DiffLineData): React.ReactElement => (
<Box>
<Text>{line.content}</Text>
</Box>
);
// ============================================================================
// Line Renderer Registry
// ============================================================================
type SimpleLineRenderer = (line: DiffLineData) => React.ReactElement;
type ContextLineRenderer = (
line: DiffLineData,
ctx: LineRendererContext,
) => React.ReactElement;
const SIMPLE_LINE_RENDERERS: Partial<Record<DiffLineType, SimpleLineRenderer>> =
{
header: renderHeaderLine,
hunk: renderHunkLine,
summary: renderSummaryLine,
};
const CONTEXT_LINE_RENDERERS: Partial<
Record<DiffLineType, ContextLineRenderer>
> = {
add: renderAddLine,
remove: renderRemoveLine,
context: renderContextLine,
};
// ============================================================================
// Main Export
// ============================================================================
export function DiffLine({
line,
showLineNumbers,
maxLineNumWidth,
language,
}: DiffLineProps): React.ReactElement {
// Try simple renderers first (no context needed)
const simpleRenderer = SIMPLE_LINE_RENDERERS[line.type];
if (simpleRenderer) {
return simpleRenderer(line);
}
// Try context-aware renderers
const contextRenderer = CONTEXT_LINE_RENDERERS[line.type];
if (contextRenderer) {
return contextRenderer(line, {
showLineNumbers,
maxLineNumWidth,
language,
});
}
// Default fallback
return renderDefaultLine(line);
}

View File

@@ -1,162 +0,0 @@
/**
* Diff View Utility Functions
*/
import type { DiffLineData } from "@/types/tui";
/**
* Strip ANSI escape codes from a string
*/
export const stripAnsi = (str: string): string => {
return str.replace(/\x1b\[[0-9;]*m/g, "");
};
/**
* Check if content looks like a diff output
*/
export const isDiffContent = (content: string): boolean => {
// Strip ANSI codes for pattern matching
const cleanContent = stripAnsi(content);
// Check for common diff markers (not anchored to line start due to title prefixes)
const diffPatterns = [
/@@\s*-\d+/m, // Hunk header
/---\s+[ab]?\//m, // File header
/\+\+\+\s+[ab]?\//m, // File header
];
return diffPatterns.some((pattern) => pattern.test(cleanContent));
};
/**
* Parse raw diff output into structured DiffLineData
*/
export const parseDiffOutput = (
diffOutput: string,
): {
lines: DiffLineData[];
filePath?: string;
additions: number;
deletions: number;
} => {
const rawLines = diffOutput.split("\n");
const lines: DiffLineData[] = [];
let filePath: string | undefined;
let additions = 0;
let deletions = 0;
let currentOldLine = 0;
let currentNewLine = 0;
let inDiff = false; // Track if we're inside the diff content
for (const rawLine of rawLines) {
// Strip ANSI codes for parsing
const cleanLine = stripAnsi(rawLine);
// Skip title lines (e.g., "Edited: filename" before diff starts)
if (
!inDiff &&
!cleanLine.startsWith("---") &&
!cleanLine.startsWith("@@")
) {
// Check if this looks like a title line
if (cleanLine.match(/^(Edited|Created|Wrote|Modified|Deleted):/i)) {
continue;
}
// Skip empty lines before diff starts
if (cleanLine.trim() === "") {
continue;
}
}
// File header detection (marks start of diff)
if (cleanLine.startsWith("--- a/") || cleanLine.startsWith("--- ")) {
inDiff = true;
continue; // Skip old file header
}
if (cleanLine.startsWith("+++ b/") || cleanLine.startsWith("+++ ")) {
inDiff = true;
filePath = cleanLine.replace(/^\+\+\+ [ab]?\//, "").trim();
continue;
}
// Hunk header: @@ -oldStart,oldCount +newStart,newCount @@
const hunkMatch = cleanLine.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunkMatch) {
inDiff = true;
currentOldLine = parseInt(hunkMatch[1], 10);
currentNewLine = parseInt(hunkMatch[2], 10);
lines.push({ type: "hunk", content: cleanLine });
continue;
}
// Only process diff content lines after we've entered the diff
if (!inDiff) {
continue;
}
// Summary line (e.g., "+5 / -3") - marks end of diff
if (cleanLine.match(/^\+\d+\s*\/\s*-\d+$/)) {
const summaryMatch = cleanLine.match(/^\+(\d+)\s*\/\s*-(\d+)$/);
if (summaryMatch) {
// Use parsed values if we haven't counted any yet
if (additions === 0 && deletions === 0) {
additions = parseInt(summaryMatch[1], 10);
deletions = parseInt(summaryMatch[2], 10);
}
}
continue;
}
// Addition line
if (cleanLine.startsWith("+")) {
lines.push({
type: "add",
content: cleanLine.slice(1),
newLineNum: currentNewLine,
});
currentNewLine++;
additions++;
continue;
}
// Deletion line
if (cleanLine.startsWith("-")) {
lines.push({
type: "remove",
content: cleanLine.slice(1),
oldLineNum: currentOldLine,
});
currentOldLine++;
deletions++;
continue;
}
// Context line (starts with space or is part of diff)
if (cleanLine.startsWith(" ")) {
lines.push({
type: "context",
content: cleanLine.slice(1),
oldLineNum: currentOldLine,
newLineNum: currentNewLine,
});
currentOldLine++;
currentNewLine++;
continue;
}
// Handle empty lines in diff context
if (cleanLine === "") {
lines.push({
type: "context",
content: "",
oldLineNum: currentOldLine,
newLineNum: currentNewLine,
});
currentOldLine++;
currentNewLine++;
}
}
return { lines, filePath, additions, deletions };
};

View File

@@ -1,66 +0,0 @@
/**
* HomeContent Component
* Logo and info content for the home screen (without input)
*/
import React, { useMemo } from "react";
import { Box, Text, useStdout } from "ink";
import { Logo } from "./Logo";
interface HomeContentProps {
provider: string;
model: string;
version: string;
}
export const HomeContent: React.FC<HomeContentProps> = ({
provider,
model,
version,
}) => {
const { stdout } = useStdout();
const terminalHeight = stdout?.rows ?? 24;
// Build info line like: "v0.1.74 | GitHub Copilot | gpt-5.1"
const infoLine = useMemo(() => {
const parts: string[] = [];
if (version) parts.push(`v${version}`);
if (provider) parts.push(provider);
if (model) parts.push(model);
return parts.join(" | ");
}, [version, provider, model]);
// Calculate spacing to center the logo
// Logo (3 lines) + subtitle (1) + info (1) + margins (~2) = ~7 lines
// Input box (~4 lines)
const contentHeight = 7;
const inputHeight = 4;
const availableSpace = terminalHeight - contentHeight - inputHeight;
const topPadding = Math.max(0, Math.floor(availableSpace / 2));
return (
<Box flexDirection="column" flexGrow={1}>
{/* Top padding to center content */}
{topPadding > 0 && <Box height={topPadding} />}
{/* Main content area - logo and info */}
<Box flexDirection="column" alignItems="center">
{/* Logo with gradient */}
<Logo />
{/* Subtitle */}
<Box marginTop={1}>
<Text dimColor>AI Coding Assistant</Text>
</Box>
{/* Version and provider info */}
<Box marginTop={1}>
<Text dimColor>{infoLine}</Text>
</Box>
</Box>
{/* Spacer to push input to bottom */}
<Box flexGrow={1} />
</Box>
);
};

View File

@@ -1,55 +0,0 @@
/**
* HomeFooter Component
* Bottom status bar showing directory, MCP status, and version
*/
import React from "react";
import { Box, Text } from "ink";
import { useThemeColors } from "@tui/hooks/useThemeStore";
import { MCP_INDICATORS } from "@constants/home-screen";
import type { HomeFooterProps } from "@types/home-screen";
export const HomeFooter: React.FC<HomeFooterProps> = ({
directory,
mcpConnectedCount,
mcpHasErrors,
version,
}) => {
const colors = useThemeColors();
const mcpStatusColor = mcpHasErrors
? colors.error
: mcpConnectedCount > 0
? colors.success
: colors.textDim;
return (
<Box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
>
<Text color={colors.textDim}>{directory}</Text>
{mcpConnectedCount > 0 && (
<Box gap={1} flexDirection="row" flexShrink={0}>
<Text color={colors.text}>
<Text color={mcpStatusColor}>{MCP_INDICATORS.connected} </Text>
{mcpConnectedCount} MCP
</Text>
<Text color={colors.textDim}>/status</Text>
</Box>
)}
<Box flexGrow={1} />
<Box flexShrink={0}>
<Text color={colors.textDim}>v{version}</Text>
</Box>
</Box>
);
};

View File

@@ -1,69 +0,0 @@
/**
* HomeScreen Component
* Main welcome/home screen with logo vertically centered and input at footer
*/
import React, { useMemo } from "react";
import { Box, Text, useStdout } from "ink";
import { Logo } from "./Logo";
import { PromptBox } from "./PromptBox";
import type { HomeScreenProps } from "@types/home-screen";
export const HomeScreen: React.FC<HomeScreenProps> = ({
onSubmit,
provider,
model,
version,
}) => {
const { stdout } = useStdout();
const terminalHeight = stdout?.rows ?? 24;
// Build info line like: "v0.1.74 | GitHub Copilot | gpt-5.1"
const infoLine = useMemo(() => {
const parts: string[] = [];
if (version) parts.push(`v${version}`);
if (provider) parts.push(provider);
if (model) parts.push(model);
return parts.join(" | ");
}, [version, provider, model]);
// Calculate spacing to center the logo
// Logo (3 lines) + subtitle (1) + info (1) + margins (~2) = ~7 lines
// Input box (~4 lines)
// So we need (terminalHeight - 7 - 4) / 2 lines of padding above the logo
const contentHeight = 7;
const inputHeight = 4;
const availableSpace = terminalHeight - contentHeight - inputHeight;
const topPadding = Math.max(0, Math.floor(availableSpace / 2));
return (
<Box flexDirection="column" height={terminalHeight}>
{/* Top padding to center content */}
{topPadding > 0 && <Box height={topPadding} />}
{/* Main content area - logo and info */}
<Box flexDirection="column" alignItems="center">
{/* Logo with gradient */}
<Logo />
{/* Subtitle */}
<Box marginTop={1}>
<Text dimColor>AI Coding Assistant</Text>
</Box>
{/* Version and provider info */}
<Box marginTop={1}>
<Text dimColor>{infoLine}</Text>
</Box>
</Box>
{/* Spacer to push input to bottom */}
<Box flexGrow={1} />
{/* Footer with input box - always at bottom, full width */}
<Box flexDirection="column" flexShrink={0} width="100%">
<PromptBox onSubmit={onSubmit} />
</Box>
</Box>
);
};

View File

@@ -1,27 +0,0 @@
/**
* Logo Component
* ASCII art logo with gradient colors for the home screen
*/
import React from "react";
import { Box, Text } from "ink";
import { TUI_BANNER, HEADER_GRADIENT_COLORS } from "@constants/tui-components";
export const Logo: React.FC = () => {
return (
<Box flexDirection="column" alignItems="center">
{TUI_BANNER.map((line, i) => (
<Text
key={i}
color={
HEADER_GRADIENT_COLORS[
Math.min(i, HEADER_GRADIENT_COLORS.length - 1)
]
}
>
{line}
</Text>
))}
</Box>
);
};

View File

@@ -1,134 +0,0 @@
/**
* PromptBox Component
* Bordered input box at the footer with placeholder and help text
*/
import React, { useState, useCallback } from "react";
import { Box, Text, useInput } from "ink";
import { useThemeColors } from "@tui/hooks/useThemeStore";
import { PLACEHOLDERS } from "@constants/home-screen";
interface PromptBoxProps {
onSubmit: (message: string) => void;
placeholder?: string;
}
const getRandomPlaceholder = (): string => {
return PLACEHOLDERS[Math.floor(Math.random() * PLACEHOLDERS.length)];
};
export const PromptBox: React.FC<PromptBoxProps> = ({
onSubmit,
placeholder,
}) => {
const colors = useThemeColors();
const [value, setValue] = useState("");
const [cursorPos, setCursorPos] = useState(0);
const [randomPlaceholder] = useState(
() => placeholder ?? getRandomPlaceholder(),
);
const handleInput = useCallback(
(
input: string,
key: {
return?: boolean;
backspace?: boolean;
delete?: boolean;
leftArrow?: boolean;
rightArrow?: boolean;
ctrl?: boolean;
},
) => {
if (key.return) {
if (value.trim()) {
onSubmit(value.trim());
setValue("");
setCursorPos(0);
}
return;
}
if (key.backspace) {
if (cursorPos > 0) {
setValue(value.slice(0, cursorPos - 1) + value.slice(cursorPos));
setCursorPos(cursorPos - 1);
}
return;
}
if (key.delete) {
if (cursorPos < value.length) {
setValue(value.slice(0, cursorPos) + value.slice(cursorPos + 1));
}
return;
}
if (key.leftArrow) {
setCursorPos(Math.max(0, cursorPos - 1));
return;
}
if (key.rightArrow) {
setCursorPos(Math.min(value.length, cursorPos + 1));
return;
}
// Clear line with Ctrl+U
if (key.ctrl && input === "u") {
setValue("");
setCursorPos(0);
return;
}
if (input && !key.return) {
setValue(value.slice(0, cursorPos) + input + value.slice(cursorPos));
setCursorPos(cursorPos + input.length);
}
},
[value, cursorPos, onSubmit],
);
useInput(handleInput);
const isEmpty = value.length === 0;
return (
<Box
flexDirection="column"
borderStyle="single"
borderColor={colors.borderFocus}
paddingX={1}
width="100%"
>
{/* Input line */}
{isEmpty ? (
<Box>
<Text color={colors.primary}>&gt; </Text>
<Text backgroundColor={colors.bgCursor} color={colors.textDim}>
T
</Text>
<Text dimColor>ype your message...</Text>
</Box>
) : (
<Box>
<Text color={colors.primary}>&gt; </Text>
<Text>
{value.slice(0, cursorPos)}
<Text backgroundColor={colors.bgCursor} color="black">
{cursorPos < value.length ? value[cursorPos] : " "}
</Text>
{value.slice(cursorPos + 1)}
</Text>
</Box>
)}
{/* Help text */}
<Box marginTop={1}>
<Text dimColor>
Enter to send Alt+Enter for newline @ to add files
</Text>
</Box>
</Box>
);
};

View File

@@ -1,161 +0,0 @@
/**
* SessionHeader Component
* Header showing session title, token count, cost, version, and brain status
*/
import React from "react";
import { Box, Text } from "ink";
import { useThemeColors } from "@tui/hooks/useThemeStore";
import type { SessionHeaderProps } from "@types/home-screen";
import { BRAIN_BANNER } from "@constants/brain";
const formatCost = (cost: number): string => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cost);
};
const formatTokenCount = (count: number): string => {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`;
}
return count.toLocaleString();
};
const MODE_COLORS: Record<string, string> = {
agent: "cyan",
ask: "green",
"code-review": "yellow",
};
const MODE_LABELS: Record<string, string> = {
agent: "AGENT",
ask: "ASK",
"code-review": "REVIEW",
};
const BRAIN_STATUS_COLORS: Record<string, string> = {
connected: "green",
connecting: "yellow",
disconnected: "gray",
error: "red",
};
const BRAIN_STATUS_ICONS: Record<string, string> = {
connected: BRAIN_BANNER.EMOJI_CONNECTED,
connecting: "...",
disconnected: BRAIN_BANNER.EMOJI_DISCONNECTED,
error: "!",
};
export const SessionHeader: React.FC<SessionHeaderProps> = ({
title,
tokenCount,
contextPercentage,
cost,
version,
interactionMode = "agent",
brain,
onDismissBrainBanner,
}) => {
const colors = useThemeColors();
const contextInfo =
contextPercentage !== undefined
? `${formatTokenCount(tokenCount)} ${contextPercentage}%`
: formatTokenCount(tokenCount);
const modeColor = MODE_COLORS[interactionMode] || "cyan";
const modeLabel = MODE_LABELS[interactionMode] || interactionMode.toUpperCase();
const brainStatus = brain?.status ?? "disconnected";
const brainColor = BRAIN_STATUS_COLORS[brainStatus] || "gray";
const brainIcon = BRAIN_STATUS_ICONS[brainStatus] || BRAIN_BANNER.EMOJI_DISCONNECTED;
const showBrainBanner = brain?.showBanner && brainStatus === "disconnected";
return (
<Box flexDirection="column" flexShrink={0}>
{/* Brain Banner - shown when not connected */}
{showBrainBanner && (
<Box
paddingLeft={2}
paddingRight={2}
paddingTop={0}
paddingBottom={0}
backgroundColor="#1a1a2e"
>
<Box flexDirection="row" justifyContent="space-between" width="100%">
<Box flexDirection="row" gap={1}>
<Text color="magenta" bold>
{BRAIN_BANNER.EMOJI_CONNECTED}
</Text>
<Text color="white" bold>
{BRAIN_BANNER.TITLE}
</Text>
<Text color="gray">
{" "}
- {BRAIN_BANNER.CTA}:{" "}
</Text>
<Text color="cyan" underline>
{BRAIN_BANNER.URL}
</Text>
</Box>
<Text color="gray" dimColor>
[press q to dismiss]
</Text>
</Box>
</Box>
)}
{/* Main Header */}
<Box
borderStyle="single"
borderLeft={true}
borderRight={false}
borderTop={false}
borderBottom={false}
borderColor={colors.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={colors.backgroundPanel}
>
<Box flexDirection="row" justifyContent="space-between" width="100%">
{/* Title and Mode */}
<Box flexDirection="row" gap={2}>
<Text color={colors.text}>
<Text bold>#</Text> <Text bold>{title}</Text>
</Text>
<Text color={modeColor} bold>
[{modeLabel}]
</Text>
</Box>
{/* Brain status, Context info and version */}
<Box flexDirection="row" gap={1} flexShrink={0}>
{/* Brain status indicator */}
{brain && (
<Box flexDirection="row" gap={0}>
<Text color={brainColor}>
{brainIcon}
</Text>
{brainStatus === "connected" && (
<Text color={colors.textDim}>
{" "}
{brain.knowledgeCount}K/{brain.memoryCount}M
</Text>
)}
</Box>
)}
<Text color={colors.textDim}>
{contextInfo} ({formatCost(cost)})
</Text>
<Text color={colors.textDim}>v{version}</Text>
</Box>
</Box>
</Box>
</Box>
);
};

View File

@@ -1,11 +0,0 @@
/**
* Home Screen Components
* Export all home screen related components
*/
export { Logo } from "./Logo";
export { HomeFooter } from "./HomeFooter";
export { PromptBox } from "./PromptBox";
export { HomeScreen } from "./HomeScreen";
export { HomeContent } from "./HomeContent";
export { SessionHeader } from "./SessionHeader";

View File

@@ -1,44 +0,0 @@
/**
* TUI Components - Export all components
*/
export { LogPanel } from "@tui/components/LogPanel";
export { InputArea } from "@tui/components/InputArea";
export { PermissionModal } from "@tui/components/PermissionModal";
export { StatusBar } from "@tui/components/StatusBar";
export { Header } from "@tui/components/Header";
export { SelectMenu } from "@tui/components/SelectMenu";
export { FilePicker } from "@tui/components/FilePicker";
export { CommandMenu, SLASH_COMMANDS } from "@tui/components/CommandMenu";
export { ModelSelect } from "@tui/components/ModelSelect";
export { AgentSelect } from "@tui/components/AgentSelect";
export { ThemeSelect } from "@tui/components/ThemeSelect";
export { MCPSelect } from "@tui/components/MCPSelect";
export { MCPBrowser } from "@tui/components/MCPBrowser";
export { TodoPanel } from "@tui/components/TodoPanel";
export { LearningModal } from "@tui/components/LearningModal";
export { ImageAttachment, ImageAttachmentCompact } from "@tui/components/ImageAttachment";
export { BouncingLoader } from "@tui/components/BouncingLoader";
export {
DiffView,
parseDiffOutput,
isDiffContent,
} from "@tui/components/DiffView";
export {
VimStatusLine,
VimModeIndicator,
} from "@tui/components/VimStatusLine";
// Home screen components
export {
HomeScreen,
HomeContent,
SessionHeader,
Logo,
HomeFooter,
PromptBox,
} from "@tui/components/home/index";
// Re-export types for convenience
export type { SelectOption } from "@/types/tui";
export type { DiffLineData, DiffViewProps } from "@/types/tui";

View File

@@ -1,216 +0,0 @@
/**
* Input Line Component with Styled Placeholders
*/
import React from "react";
import { Text } from "ink";
import type { PastedContent } from "@interfaces/PastedContent";
type InputLineProps = {
line: string;
lineIndex: number;
lineStartPos: number;
cursorLine: number;
cursorCol: number;
pastedBlocks: Map<string, PastedContent>;
colors: {
primary: string;
bgCursor: string;
textDim: string;
secondary: string;
};
};
type Segment = {
text: string;
isPasted: boolean;
startPos: number;
endPos: number;
};
/**
* Splits a line into segments, identifying pasted placeholders
*/
const segmentLine = (
line: string,
lineStartPos: number,
pastedBlocks: Map<string, PastedContent>,
): Segment[] => {
const segments: Segment[] = [];
const lineEndPos = lineStartPos + line.length;
// Find all pasted blocks that overlap with this line
const overlappingBlocks: PastedContent[] = [];
for (const block of pastedBlocks.values()) {
if (block.startPos < lineEndPos && block.endPos > lineStartPos) {
overlappingBlocks.push(block);
}
}
if (overlappingBlocks.length === 0) {
return [
{
text: line,
isPasted: false,
startPos: lineStartPos,
endPos: lineEndPos,
},
];
}
// Sort blocks by start position
overlappingBlocks.sort((a, b) => a.startPos - b.startPos);
let currentPos = lineStartPos;
for (const block of overlappingBlocks) {
// Add text before this block
if (currentPos < block.startPos && block.startPos <= lineEndPos) {
const beforeEnd = Math.min(block.startPos, lineEndPos);
const textStart = currentPos - lineStartPos;
const textEnd = beforeEnd - lineStartPos;
segments.push({
text: line.slice(textStart, textEnd),
isPasted: false,
startPos: currentPos,
endPos: beforeEnd,
});
currentPos = beforeEnd;
}
// Add the pasted block portion within this line
const blockStart = Math.max(block.startPos, lineStartPos);
const blockEnd = Math.min(block.endPos, lineEndPos);
if (blockStart < blockEnd) {
const textStart = blockStart - lineStartPos;
const textEnd = blockEnd - lineStartPos;
segments.push({
text: line.slice(textStart, textEnd),
isPasted: true,
startPos: blockStart,
endPos: blockEnd,
});
currentPos = blockEnd;
}
}
// Add remaining text after all blocks
if (currentPos < lineEndPos) {
const textStart = currentPos - lineStartPos;
segments.push({
text: line.slice(textStart),
isPasted: false,
startPos: currentPos,
endPos: lineEndPos,
});
}
return segments;
};
export function InputLine({
line,
lineIndex,
lineStartPos,
cursorLine,
cursorCol,
pastedBlocks,
colors,
}: InputLineProps): React.ReactElement {
const isCursorLine = lineIndex === cursorLine;
const segments = segmentLine(line, lineStartPos, pastedBlocks);
// If this is the cursor line, we need to handle cursor rendering within segments
if (isCursorLine) {
return (
<Text>
{segments.map((segment, segIdx) => {
const segmentStartInLine = segment.startPos - lineStartPos;
const segmentEndInLine = segment.endPos - lineStartPos;
const cursorInSegment =
cursorCol >= segmentStartInLine && cursorCol < segmentEndInLine;
if (cursorInSegment) {
const cursorPosInSegment = cursorCol - segmentStartInLine;
const beforeCursor = segment.text.slice(0, cursorPosInSegment);
const atCursor = segment.text[cursorPosInSegment] ?? " ";
const afterCursor = segment.text.slice(cursorPosInSegment + 1);
return (
<Text key={segIdx}>
{segment.isPasted ? (
<>
<Text color={colors.secondary} dimColor>
{beforeCursor}
</Text>
<Text backgroundColor={colors.bgCursor} color="black">
{atCursor}
</Text>
<Text color={colors.secondary} dimColor>
{afterCursor}
</Text>
</>
) : (
<>
{beforeCursor}
<Text backgroundColor={colors.bgCursor} color="black">
{atCursor}
</Text>
{afterCursor}
</>
)}
</Text>
);
}
// Cursor is not in this segment
if (segment.isPasted) {
return (
<Text key={segIdx} color={colors.secondary} dimColor>
{segment.text}
</Text>
);
}
return <Text key={segIdx}>{segment.text}</Text>;
})}
{/* Handle cursor at end of line */}
{cursorCol >= line.length && (
<Text backgroundColor={colors.bgCursor} color="black">
{" "}
</Text>
)}
</Text>
);
}
// Non-cursor line - simple rendering
return (
<Text>
{segments.map((segment, segIdx) =>
segment.isPasted ? (
<Text key={segIdx} color={colors.secondary} dimColor>
{segment.text}
</Text>
) : (
<Text key={segIdx}>{segment.text}</Text>
),
)}
</Text>
);
}
/**
* Calculates the starting position of a line in the buffer
*/
export const calculateLineStartPos = (
buffer: string,
lineIndex: number,
): number => {
const lines = buffer.split("\n");
let pos = 0;
for (let i = 0; i < lineIndex && i < lines.length; i++) {
pos += lines[i].length + 1; // +1 for newline
}
return pos;
};

View File

@@ -1,172 +0,0 @@
/**
* Log Entry Renderer Components
*
* Each entry type has its own renderer function
*/
import React from "react";
import { Box, Text } from "ink";
import type { LogEntry, LogEntryProps, ToolStatus } from "@/types/tui";
import {
TOOL_STATUS_ICONS,
TOOL_STATUS_COLORS,
type ToolStatusColor,
} from "@constants/tui-components";
import {
DiffView,
parseDiffOutput,
isDiffContent,
} from "@tui/components/DiffView";
import { StreamingMessage } from "@tui/components/StreamingMessage";
// ============================================================================
// Entry Renderers by Type
// ============================================================================
const renderUserEntry = (entry: LogEntry): React.ReactElement => (
<Box flexDirection="column" marginBottom={1}>
<Text color="cyan" bold>
You
</Text>
<Box marginLeft={2}>
<Text>{entry.content}</Text>
</Box>
</Box>
);
const renderAssistantEntry = (entry: LogEntry): React.ReactElement => (
<Box flexDirection="column" marginBottom={1}>
<Text color="green" bold>
CodeTyper
</Text>
<Box marginLeft={2}>
<Text>{entry.content}</Text>
</Box>
</Box>
);
const renderErrorEntry = (entry: LogEntry): React.ReactElement => (
<Box marginBottom={1}>
<Text color="red"> Error: {entry.content}</Text>
</Box>
);
const renderSystemEntry = (entry: LogEntry): React.ReactElement => (
<Box marginBottom={1}>
<Text dimColor> {entry.content}</Text>
</Box>
);
const renderThinkingEntry = (entry: LogEntry): React.ReactElement => (
<Box marginBottom={1}>
<Text color="magenta"> {entry.content}</Text>
</Box>
);
const renderDefaultEntry = (entry: LogEntry): React.ReactElement => (
<Box marginBottom={1}>
<Text>{entry.content}</Text>
</Box>
);
const renderToolEntry = (entry: LogEntry): React.ReactElement => {
const toolStatus: ToolStatus = entry.metadata?.toolStatus ?? "pending";
const statusIcon = TOOL_STATUS_ICONS[toolStatus];
const statusColor = TOOL_STATUS_COLORS[toolStatus] as ToolStatusColor;
// Check if content contains diff output
const hasDiff =
entry.metadata?.diffData?.isDiff || isDiffContent(entry.content);
if (hasDiff && entry.metadata?.toolStatus === "success") {
const diffData = parseDiffOutput(entry.content);
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={statusColor}>{statusIcon} </Text>
<Text color="yellow">{entry.metadata?.toolName || "tool"}</Text>
{entry.metadata?.toolDescription && (
<>
<Text dimColor>: </Text>
<Text dimColor>{entry.metadata.toolDescription}</Text>
</>
)}
</Box>
<Box marginLeft={2}>
<DiffView
lines={diffData.lines}
filePath={diffData.filePath}
additions={diffData.additions}
deletions={diffData.deletions}
compact={false}
/>
</Box>
</Box>
);
}
// Check if content is multiline (file read output)
const isMultiline = entry.content.includes("\n");
if (isMultiline) {
// Render multiline content compactly in a column layout
const lines = entry.content.split("\n");
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={statusColor}>{statusIcon} </Text>
<Text color="yellow">{entry.metadata?.toolName || "tool"}</Text>
<Text dimColor>: </Text>
<Text dimColor>{entry.metadata?.toolDescription || lines[0]}</Text>
</Box>
<Box flexDirection="column" marginLeft={2}>
{lines
.slice(entry.metadata?.toolDescription ? 0 : 1)
.map((line, i) => (
<Text key={i} dimColor>
{line}
</Text>
))}
</Box>
</Box>
);
}
return (
<Box marginBottom={1}>
<Text color={statusColor}>{statusIcon} </Text>
<Text color="yellow">{entry.metadata?.toolName || "tool"}</Text>
<Text dimColor>: </Text>
<Text dimColor>{entry.content}</Text>
</Box>
);
};
// ============================================================================
// Entry Renderer Registry
// ============================================================================
type EntryRenderer = (entry: LogEntry) => React.ReactElement;
const renderStreamingEntry = (entry: LogEntry): React.ReactElement => (
<StreamingMessage entry={entry} />
);
const ENTRY_RENDERERS: Record<string, EntryRenderer> = {
user: renderUserEntry,
assistant: renderAssistantEntry,
assistant_streaming: renderStreamingEntry,
tool: renderToolEntry,
error: renderErrorEntry,
system: renderSystemEntry,
thinking: renderThinkingEntry,
};
// ============================================================================
// Main Export
// ============================================================================
export function LogEntryDisplay({ entry }: LogEntryProps): React.ReactElement {
const renderer = ENTRY_RENDERERS[entry.type] ?? renderDefaultEntry;
return renderer(entry);
}

View File

@@ -1,186 +0,0 @@
/**
* Log Panel Component - Displays conversation history and tool execution
* Supports scrolling with Page Up/Down, mouse wheel, and auto-scroll on new messages
*
* Features:
* - Auto-scroll to bottom when new content arrives (during active operations)
* - User scroll detection (scrolling up pauses auto-scroll)
* - Resume auto-scroll when user scrolls back to bottom
* - Virtual scrolling for performance
* - Shows logo and welcome screen when no logs
*/
import React, { useEffect, useRef, useMemo } from "react";
import { Box, Text, useStdout } from "ink";
import { useAppStore } from "@tui/store";
import {
LOG_PANEL_RESERVED_HEIGHT,
LOG_PANEL_MIN_HEIGHT,
LOG_PANEL_DEFAULT_TERMINAL_HEIGHT,
} from "@constants/tui-components";
import { LogEntryDisplay } from "@tui/components/log-panel/entry-renderers";
import { ThinkingIndicator } from "@tui/components/log-panel/thinking-indicator";
import { estimateEntryLines } from "@tui/components/log-panel/utils";
import { Logo } from "@tui/components/home/Logo";
export function LogPanel(): React.ReactElement {
const allLogs = useAppStore((state) => state.logs);
const thinkingMessage = useAppStore((state) => state.thinkingMessage);
const mode = useAppStore((state) => state.mode);
const autoScroll = useAppStore((state) => state.autoScroll);
const userScrolled = useAppStore((state) => state.userScrolled);
const setScrollDimensions = useAppStore((state) => state.setScrollDimensions);
const getEffectiveScrollOffset = useAppStore(
(state) => state.getEffectiveScrollOffset,
);
const { stdout } = useStdout();
// Filter out quiet tool entries (read-only operations like file reads, ls, etc.)
const logs = useMemo(() => {
return allLogs.filter((entry) => {
// Always show non-tool entries
if (entry.type !== "tool") return true;
// Hide quiet operations
if (entry.metadata?.quiet) return false;
return true;
});
}, [allLogs]);
// Get terminal height (subtract space for header, status bar, input, borders)
const terminalHeight = stdout?.rows ?? LOG_PANEL_DEFAULT_TERMINAL_HEIGHT;
const visibleHeight = Math.max(
LOG_PANEL_MIN_HEIGHT,
terminalHeight - LOG_PANEL_RESERVED_HEIGHT,
);
// Calculate total content height
const totalLines = useMemo(() => {
return logs.reduce((sum, entry) => sum + estimateEntryLines(entry), 0);
}, [logs]);
// Track previous values for change detection
const prevLogCountRef = useRef(logs.length);
const prevTotalLinesRef = useRef(totalLines);
// Update scroll dimensions when content or viewport changes
useEffect(() => {
setScrollDimensions(totalLines, visibleHeight);
}, [totalLines, visibleHeight, setScrollDimensions]);
// Auto-scroll to bottom when new logs arrive (only if auto-scroll is enabled)
useEffect(() => {
const logsChanged = logs.length !== prevLogCountRef.current;
const contentGrew = totalLines > prevTotalLinesRef.current;
if ((logsChanged || contentGrew) && autoScroll && !userScrolled) {
// New content added and auto-scroll is active, update dimensions to scroll to bottom
setScrollDimensions(totalLines, visibleHeight);
}
prevLogCountRef.current = logs.length;
prevTotalLinesRef.current = totalLines;
}, [
logs.length,
totalLines,
autoScroll,
userScrolled,
visibleHeight,
setScrollDimensions,
]);
// Calculate effective scroll offset
const maxScroll = Math.max(0, totalLines - visibleHeight);
const effectiveOffset = getEffectiveScrollOffset();
// Calculate which entries to show based on scroll position
const { visibleEntries } = useMemo(() => {
let currentLine = 0;
let startIdx = 0;
let endIdx = logs.length;
let skipLines = 0;
// Find start index
for (let i = 0; i < logs.length; i++) {
const entryLines = estimateEntryLines(logs[i]);
if (currentLine + entryLines > effectiveOffset) {
startIdx = i;
skipLines = effectiveOffset - currentLine;
break;
}
currentLine += entryLines;
}
// Find end index
currentLine = 0;
for (let i = startIdx; i < logs.length; i++) {
const entryLines = estimateEntryLines(logs[i]);
currentLine += entryLines;
if (currentLine >= visibleHeight + (i === startIdx ? skipLines : 0)) {
endIdx = i + 1;
break;
}
}
return {
visibleEntries: logs.slice(startIdx, endIdx),
startOffset: skipLines,
};
}, [logs, effectiveOffset, visibleHeight]);
// Show scroll indicators
const canScrollUp = effectiveOffset > 0;
const canScrollDown = effectiveOffset < maxScroll;
return (
<Box
flexDirection="column"
flexGrow={1}
paddingX={1}
borderStyle="single"
borderColor="gray"
overflow="hidden"
>
{/* Scroll up indicator */}
{canScrollUp && (
<Box justifyContent="center">
<Text dimColor>
{userScrolled ? "Auto-scroll paused • " : ""}Scroll: Shift+ |
PageUp | Mouse
</Text>
</Box>
)}
{logs.length === 0 && !thinkingMessage ? (
<Box flexDirection="column" flexGrow={1} alignItems="center" justifyContent="center">
<Logo />
<Box marginTop={1}>
<Text dimColor>AI Coding Assistant</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Type your prompt below Ctrl+M to switch modes</Text>
</Box>
</Box>
) : (
<Box flexDirection="column" flexGrow={1}>
{visibleEntries.map((entry) => (
<LogEntryDisplay key={entry.id} entry={entry} />
))}
{mode === "thinking" && thinkingMessage && (
<ThinkingIndicator message={thinkingMessage} />
)}
</Box>
)}
{/* Scroll down indicator */}
{canScrollDown && (
<Box justifyContent="center">
<Text dimColor>
Scroll: Shift+ | PageDown | Mouse
{userScrolled ? " • Ctrl+End to resume" : ""}
</Text>
</Box>
)}
</Box>
);
}

View File

@@ -1,31 +0,0 @@
/**
* Thinking Indicator Component
*/
import React, { useState, useEffect } from "react";
import { Box, Text } from "ink";
import type { ThinkingIndicatorProps } from "@/types/tui";
import {
THINKING_SPINNER_FRAMES,
THINKING_SPINNER_INTERVAL,
} from "@constants/tui-components";
export function ThinkingIndicator({
message,
}: ThinkingIndicatorProps): React.ReactElement {
const [frame, setFrame] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setFrame((prev) => (prev + 1) % THINKING_SPINNER_FRAMES.length);
}, THINKING_SPINNER_INTERVAL);
return () => clearInterval(timer);
}, []);
return (
<Box>
<Text color="magenta">{THINKING_SPINNER_FRAMES[frame]} </Text>
<Text color="magenta">{message}</Text>
</Box>
);
}

View File

@@ -1,36 +0,0 @@
/**
* Log Panel Utility Functions
*/
import type { LogEntry, LogEntryType } from "@/types/tui";
import { LOG_ENTRY_EXTRA_LINES } from "@constants/tui-components";
import { isDiffContent } from "@tui/components/DiffView";
const ENTRY_LINES_CONFIG: Record<LogEntryType, number> = {
user: LOG_ENTRY_EXTRA_LINES.user,
assistant: LOG_ENTRY_EXTRA_LINES.assistant,
assistant_streaming: LOG_ENTRY_EXTRA_LINES.assistant,
tool: LOG_ENTRY_EXTRA_LINES.tool,
error: LOG_ENTRY_EXTRA_LINES.default,
system: LOG_ENTRY_EXTRA_LINES.default,
thinking: LOG_ENTRY_EXTRA_LINES.default,
};
/**
* Estimate lines needed for a log entry (rough calculation)
*/
export const estimateEntryLines = (entry: LogEntry): number => {
const contentLines = entry.content.split("\n").length;
const baseExtra =
ENTRY_LINES_CONFIG[entry.type] ?? LOG_ENTRY_EXTRA_LINES.default;
// Diff views take more space
if (
entry.type === "tool" &&
(entry.metadata?.diffData?.isDiff || isDiffContent(entry.content))
) {
return contentLines + LOG_ENTRY_EXTRA_LINES.toolWithDiff;
}
return contentLines + baseExtra;
};

View File

@@ -1,16 +0,0 @@
/**
* TUI Hooks exports
*/
export { useMouseScroll } from "@tui/hooks/useMouseScroll";
export { useAutoScroll } from "@tui/hooks/useAutoScroll";
// Re-export types for convenience
export type { MouseScrollOptions } from "@interfaces/MouseScrollOptions";
export type { ScrollDirection } from "@constants/mouse-scroll";
export type {
AutoScrollOptions,
AutoScrollState,
AutoScrollActions,
AutoScrollReturn,
} from "@interfaces/AutoScrollOptions";

View File

@@ -1,286 +0,0 @@
/**
* Auto-Scroll Hook
*
* Manages auto-scroll behavior for the log panel, inspired by opencode's implementation.
* Features:
* - Automatic scroll to bottom when content is being generated
* - User scroll detection (scrolling up pauses auto-scroll)
* - Resume auto-scroll when user scrolls back to bottom
* - Settling period after operations complete
* - Distinguishes between user-initiated and programmatic scrolls
*/
import { useState, useCallback, useEffect, useRef } from "react";
import type {
AutoScrollOptions,
AutoScrollReturn,
} from "@interfaces/AutoScrollOptions";
import {
BOTTOM_THRESHOLD,
SETTLE_TIMEOUT_MS,
AUTO_SCROLL_MARK_TIMEOUT_MS,
KEYBOARD_SCROLL_LINES,
} from "@constants/auto-scroll";
interface AutoMark {
offset: number;
time: number;
}
export const useAutoScroll = ({
isWorking,
totalLines,
visibleHeight,
onUserInteracted,
bottomThreshold = BOTTOM_THRESHOLD,
}: AutoScrollOptions): AutoScrollReturn => {
const [scrollOffset, setScrollOffset] = useState(0);
const [autoScroll, setAutoScroll] = useState(true);
const [userScrolled, setUserScrolled] = useState(false);
const [isSettling, setIsSettling] = useState(false);
// Refs for timers and auto-mark tracking
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoMarkRef = useRef<AutoMark | null>(null);
const autoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevWorkingRef = useRef(false);
const prevTotalLinesRef = useRef(totalLines);
// Calculate max scroll offset
const maxScroll = Math.max(0, totalLines - visibleHeight);
// Check if currently active (working or settling)
const isActive = isWorking() || isSettling;
/**
* Mark a scroll as programmatic (auto-scroll)
* This helps distinguish auto-scrolls from user scrolls in async scenarios
*/
const markAuto = useCallback((offset: number) => {
autoMarkRef.current = {
offset,
time: Date.now(),
};
if (autoTimerRef.current) {
clearTimeout(autoTimerRef.current);
}
autoTimerRef.current = setTimeout(() => {
autoMarkRef.current = null;
autoTimerRef.current = null;
}, AUTO_SCROLL_MARK_TIMEOUT_MS);
}, []);
/**
* Calculate distance from bottom
*/
const distanceFromBottom = useCallback(
(offset: number): number => {
return maxScroll - offset;
},
[maxScroll],
);
/**
* Check if we can scroll (content exceeds viewport)
*/
const canScroll = useCallback((): boolean => {
return totalLines > visibleHeight;
}, [totalLines, visibleHeight]);
/**
* Scroll to bottom programmatically
*/
const scrollToBottomInternal = useCallback(
(force: boolean) => {
if (!force && !isActive) return;
if (!force && userScrolled) return;
if (force && userScrolled) {
setUserScrolled(false);
}
const distance = distanceFromBottom(scrollOffset);
if (distance < 2) return;
// Mark as auto-scroll and update offset
markAuto(maxScroll);
setScrollOffset(maxScroll);
setAutoScroll(true);
},
[
isActive,
userScrolled,
distanceFromBottom,
scrollOffset,
markAuto,
maxScroll,
],
);
/**
* Pause auto-scroll (user scrolled up)
*/
const pause = useCallback(() => {
if (!canScroll()) {
if (userScrolled) setUserScrolled(false);
return;
}
if (userScrolled) return;
setUserScrolled(true);
setAutoScroll(false);
onUserInteracted?.();
}, [canScroll, userScrolled, onUserInteracted]);
/**
* Resume auto-scroll mode
*/
const resume = useCallback(() => {
if (userScrolled) {
setUserScrolled(false);
}
scrollToBottomInternal(true);
}, [userScrolled, scrollToBottomInternal]);
/**
* User-initiated scroll up
*/
const scrollUp = useCallback(
(lines: number = KEYBOARD_SCROLL_LINES) => {
const newOffset = Math.max(0, scrollOffset - lines);
setScrollOffset(newOffset);
// User scrolling up always pauses auto-scroll
pause();
},
[scrollOffset, pause],
);
/**
* User-initiated scroll down
*/
const scrollDown = useCallback(
(lines: number = KEYBOARD_SCROLL_LINES) => {
const newOffset = Math.min(maxScroll, scrollOffset + lines);
setScrollOffset(newOffset);
// Check if user scrolled back to bottom
if (distanceFromBottom(newOffset) <= bottomThreshold) {
setUserScrolled(false);
setAutoScroll(true);
}
},
[scrollOffset, maxScroll, distanceFromBottom, bottomThreshold],
);
/**
* Scroll to top (user-initiated)
*/
const scrollToTop = useCallback(() => {
setScrollOffset(0);
pause();
}, [pause]);
/**
* Scroll to bottom and resume auto-scroll
*/
const scrollToBottom = useCallback(() => {
resume();
}, [resume]);
/**
* Get effective scroll offset (clamped)
*/
const getEffectiveOffset = useCallback((): number => {
if (autoScroll && !userScrolled) {
return maxScroll;
}
return Math.min(scrollOffset, maxScroll);
}, [autoScroll, userScrolled, scrollOffset, maxScroll]);
/**
* Check if can scroll up
*/
const canScrollUp = useCallback((): boolean => {
return getEffectiveOffset() > 0;
}, [getEffectiveOffset]);
/**
* Check if can scroll down
*/
const canScrollDown = useCallback((): boolean => {
return getEffectiveOffset() < maxScroll;
}, [getEffectiveOffset, maxScroll]);
// Handle working state changes (settling period)
useEffect(() => {
const working = isWorking();
if (working !== prevWorkingRef.current) {
prevWorkingRef.current = working;
// Clear existing settle timer
if (settleTimerRef.current) {
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}
if (working) {
// Starting work - scroll to bottom if not user-scrolled
setIsSettling(false);
if (!userScrolled) {
scrollToBottomInternal(true);
}
} else {
// Finished work - enter settling period
setIsSettling(true);
settleTimerRef.current = setTimeout(() => {
setIsSettling(false);
settleTimerRef.current = null;
}, SETTLE_TIMEOUT_MS);
}
}
}, [isWorking, userScrolled, scrollToBottomInternal]);
// Auto-scroll when new content arrives
useEffect(() => {
if (totalLines > prevTotalLinesRef.current) {
// New content added
if (autoScroll && !userScrolled) {
markAuto(maxScroll);
setScrollOffset(maxScroll);
}
}
prevTotalLinesRef.current = totalLines;
}, [totalLines, autoScroll, userScrolled, maxScroll, markAuto]);
// Cleanup timers on unmount
useEffect(() => {
return () => {
if (settleTimerRef.current) {
clearTimeout(settleTimerRef.current);
}
if (autoTimerRef.current) {
clearTimeout(autoTimerRef.current);
}
};
}, []);
return {
scrollOffset,
autoScroll,
userScrolled,
isSettling,
scrollUp,
scrollDown,
scrollToTop,
scrollToBottom,
resume,
pause,
getEffectiveOffset,
canScrollUp,
canScrollDown,
};
};

View File

@@ -1,109 +0,0 @@
/**
* Mouse scroll hook for Ink TUI
* Enables mouse mode in the terminal and handles scroll wheel events
*/
import { useEffect } from "react";
import { useStdin } from "ink";
import type { MouseScrollOptions } from "@interfaces/MouseScrollOptions";
import type { ScrollDirection } from "@constants/mouse-scroll";
import {
MOUSE_ESCAPE_SEQUENCES,
MOUSE_BUTTON_TO_DIRECTION,
SGR_MOUSE_PATTERN,
X10_MOUSE_PREFIX,
X10_MIN_LENGTH,
X10_BUTTON_OFFSET,
} from "@constants/mouse-scroll";
type ScrollHandlers = {
up: () => void;
down: () => void;
};
/**
* Parse SGR mouse mode format and return scroll direction
* Format: \x1b[<button;x;yM or \x1b[<button;x;ym
*/
const parseSgrMouseEvent = (data: string): ScrollDirection | null => {
const match = data.match(SGR_MOUSE_PATTERN);
if (!match) return null;
const button = parseInt(match[1], 10);
return MOUSE_BUTTON_TO_DIRECTION[button] ?? null;
};
/**
* Parse X10/Normal mouse mode format and return scroll direction
* Format: \x1b[M followed by 3 bytes (button at offset 3)
*/
const parseX10MouseEvent = (data: string): ScrollDirection | null => {
const isX10Format =
data.startsWith(X10_MOUSE_PREFIX) && data.length >= X10_MIN_LENGTH;
if (!isX10Format) return null;
const button = data.charCodeAt(X10_BUTTON_OFFSET);
return MOUSE_BUTTON_TO_DIRECTION[button] ?? null;
};
/**
* Parse mouse event data and return scroll direction
*/
const parseMouseEvent = (data: string): ScrollDirection | null => {
// Try SGR format first, then X10 format
return parseSgrMouseEvent(data) ?? parseX10MouseEvent(data);
};
/**
* Create scroll handler that dispatches to the appropriate callback
*/
const createScrollDispatcher = (handlers: ScrollHandlers) => {
return (direction: ScrollDirection): void => {
handlers[direction]();
};
};
/**
* Hook to enable mouse scroll support in the terminal
*
* @param options - Scroll handlers and enabled flag
*/
export const useMouseScroll = ({
onScrollUp,
onScrollDown,
enabled = true,
}: MouseScrollOptions): void => {
const { stdin } = useStdin();
useEffect(() => {
if (!enabled || !stdin) return;
// Enable mouse mode
process.stdout.write(MOUSE_ESCAPE_SEQUENCES.ENABLE);
const handlers: ScrollHandlers = {
up: onScrollUp,
down: onScrollDown,
};
const dispatchScroll = createScrollDispatcher(handlers);
const handleData = (data: Buffer): void => {
const str = data.toString();
const direction = parseMouseEvent(str);
if (direction) {
dispatchScroll(direction);
}
};
stdin.on("data", handleData);
return () => {
// Disable mouse mode on cleanup
process.stdout.write(MOUSE_ESCAPE_SEQUENCES.DISABLE);
stdin.off("data", handleData);
};
}, [enabled, stdin, onScrollUp, onScrollDown]);
};

View File

@@ -1,43 +0,0 @@
/**
* useThemeStore React Hook
*
* React hook for accessing theme store state
*/
import { useStore } from "zustand";
import {
themeStoreVanilla,
themeActions,
type ThemeState,
} from "@stores/theme-store";
import type { ThemeColors } from "@/types/theme";
/**
* Theme store with actions for React components
*/
interface ThemeStoreWithActions extends ThemeState {
setTheme: (themeName: string) => void;
}
/**
* React hook for theme store
*/
export const useThemeStore = <T>(
selector: (state: ThemeStoreWithActions) => T,
): T => {
const state = useStore(themeStoreVanilla, (s) => s);
const stateWithActions: ThemeStoreWithActions = {
...state,
setTheme: themeActions.setTheme,
};
return selector(stateWithActions);
};
/**
* React hook for theme colors only
*/
export const useThemeColors = (): ThemeColors => {
return useStore(themeStoreVanilla, (state) => state.colors);
};
export default useThemeStore;

View File

@@ -1,35 +0,0 @@
/**
* useTodoStore React Hook
*
* React hook for accessing todo store state
*/
import { useStore } from "zustand";
import {
todoStoreVanilla,
todoStore,
type TodoState,
} from "@stores/todo-store";
/**
* Todo store with actions for React components
*/
interface TodoStoreWithActions extends TodoState {
getProgress: () => { completed: number; total: number; percentage: number };
}
/**
* React hook for todo store
*/
export const useTodoStore = <T>(
selector: (state: TodoStoreWithActions) => T,
): T => {
const state = useStore(todoStoreVanilla, (s) => s);
const stateWithActions: TodoStoreWithActions = {
...state,
getProgress: todoStore.getProgress,
};
return selector(stateWithActions);
};
export default useTodoStore;

View File

@@ -1,359 +0,0 @@
/**
* useVimMode Hook
*
* React hook for vim mode keyboard handling in the TUI
*/
import { useCallback, useEffect } from "react";
import { useInput } from "ink";
import type { Key } from "ink";
import type { VimMode, VimAction, VimKeyEventResult } from "@/types/vim";
import { useVimStore, vimActions } from "@stores/vim-store";
import {
VIM_DEFAULT_BINDINGS,
VIM_SCROLL_AMOUNTS,
ESCAPE_KEYS,
VIM_COMMANDS,
} from "@constants/vim";
/**
* Options for useVimMode hook
*/
interface UseVimModeOptions {
/** Whether the hook is active */
isActive?: boolean;
/** Callback when scrolling up */
onScrollUp?: (lines: number) => void;
/** Callback when scrolling down */
onScrollDown?: (lines: number) => void;
/** Callback when going to top */
onGoToTop?: () => void;
/** Callback when going to bottom */
onGoToBottom?: () => void;
/** Callback when entering insert mode */
onEnterInsert?: () => void;
/** Callback when executing a command */
onCommand?: (command: string) => void;
/** Callback when search pattern changes */
onSearch?: (pattern: string) => void;
/** Callback when going to next search match */
onSearchNext?: () => void;
/** Callback when going to previous search match */
onSearchPrev?: () => void;
/** Callback for quit command */
onQuit?: () => void;
/** Callback for write (save) command */
onWrite?: () => void;
}
/**
* Parse a vim command string
*/
const parseCommand = (
command: string
): { name: string; args: string[] } | null => {
const trimmed = command.trim();
if (!trimmed) return null;
const parts = trimmed.split(/\s+/);
const name = parts[0] || "";
const args = parts.slice(1);
return { name, args };
};
/**
* Find matching key binding
*/
const findBinding = (
key: string,
mode: VimMode,
ctrl: boolean,
shift: boolean
) => {
return VIM_DEFAULT_BINDINGS.find((binding) => {
if (binding.mode !== mode) return false;
if (binding.key.toLowerCase() !== key.toLowerCase()) return false;
if (binding.ctrl && !ctrl) return false;
if (binding.shift && !shift) return false;
return true;
});
};
/**
* Check if key is escape
*/
const isEscape = (input: string, key: Key): boolean => {
return key.escape || ESCAPE_KEYS.includes(input);
};
/**
* useVimMode hook
*/
export const useVimMode = (options: UseVimModeOptions = {}) => {
const {
isActive = true,
onScrollUp,
onScrollDown,
onGoToTop,
onGoToBottom,
onEnterInsert,
onCommand,
onSearch,
onSearchNext,
onSearchPrev,
onQuit,
onWrite,
} = options;
const mode = useVimStore((state) => state.mode);
const enabled = useVimStore((state) => state.enabled);
const commandBuffer = useVimStore((state) => state.commandBuffer);
const searchPattern = useVimStore((state) => state.searchPattern);
const config = useVimStore((state) => state.config);
/**
* Handle action execution
*/
const executeAction = useCallback(
(action: VimAction, argument?: string | number): void => {
const actionHandlers: Record<VimAction, () => void> = {
scroll_up: () => onScrollUp?.(VIM_SCROLL_AMOUNTS.LINE),
scroll_down: () => onScrollDown?.(VIM_SCROLL_AMOUNTS.LINE),
scroll_half_up: () => onScrollUp?.(VIM_SCROLL_AMOUNTS.HALF_PAGE),
scroll_half_down: () => onScrollDown?.(VIM_SCROLL_AMOUNTS.HALF_PAGE),
goto_top: () => onGoToTop?.(),
goto_bottom: () => onGoToBottom?.(),
enter_insert: () => {
vimActions.setMode("insert");
onEnterInsert?.();
},
enter_command: () => {
vimActions.setMode("command");
vimActions.clearCommandBuffer();
},
enter_visual: () => {
vimActions.setMode("visual");
},
exit_mode: () => {
vimActions.setMode("normal");
vimActions.clearCommandBuffer();
vimActions.clearSearch();
},
search_start: () => {
vimActions.setMode("command");
vimActions.setCommandBuffer("/");
},
search_next: () => {
vimActions.nextMatch();
onSearchNext?.();
},
search_prev: () => {
vimActions.prevMatch();
onSearchPrev?.();
},
execute_command: () => {
const buffer = vimActions.getState().commandBuffer;
// Check if it's a search
if (buffer.startsWith("/")) {
const pattern = buffer.slice(1);
vimActions.setSearchPattern(pattern);
onSearch?.(pattern);
vimActions.setMode("normal");
vimActions.clearCommandBuffer();
return;
}
const parsed = parseCommand(buffer);
if (parsed) {
const { name, args } = parsed;
// Handle built-in commands
if (name === VIM_COMMANDS.QUIT || name === VIM_COMMANDS.QUIT_FORCE) {
onQuit?.();
} else if (name === VIM_COMMANDS.WRITE) {
onWrite?.();
} else if (name === VIM_COMMANDS.WRITE_QUIT) {
onWrite?.();
onQuit?.();
} else if (name === VIM_COMMANDS.NOHL) {
vimActions.clearSearch();
} else {
onCommand?.(buffer);
}
}
vimActions.setMode("normal");
vimActions.clearCommandBuffer();
},
cancel: () => {
vimActions.setMode("normal");
vimActions.clearCommandBuffer();
},
yank: () => {
// Yank would copy content to register
// Implementation depends on what content is available
},
paste: () => {
// Paste from register
// Implementation depends on context
},
delete: () => {
// Delete selected content
},
undo: () => {
// Undo last change
},
redo: () => {
// Redo last undone change
},
word_forward: () => {
// Move to next word
},
word_backward: () => {
// Move to previous word
},
line_start: () => {
// Move to line start
},
line_end: () => {
// Move to line end
},
none: () => {
// No action
},
};
const handler = actionHandlers[action];
if (handler) {
handler();
}
},
[
onScrollUp,
onScrollDown,
onGoToTop,
onGoToBottom,
onEnterInsert,
onCommand,
onSearch,
onSearchNext,
onSearchPrev,
onQuit,
onWrite,
]
);
/**
* Handle key input
*/
const handleInput = useCallback(
(input: string, key: Key): VimKeyEventResult => {
// Not enabled, pass through
if (!enabled) {
return { handled: false, preventDefault: false };
}
// Handle escape in any mode
if (isEscape(input, key) && mode !== "normal") {
executeAction("exit_mode");
return { handled: true, preventDefault: true };
}
// Command mode - build command buffer
if (mode === "command") {
if (key.return) {
executeAction("execute_command");
return { handled: true, preventDefault: true };
}
if (key.backspace || key.delete) {
const buffer = vimActions.getState().commandBuffer;
if (buffer.length > 0) {
vimActions.setCommandBuffer(buffer.slice(0, -1));
}
if (buffer.length <= 1) {
executeAction("cancel");
}
return { handled: true, preventDefault: true };
}
// Add character to command buffer
if (input && input.length === 1) {
vimActions.appendCommandBuffer(input);
return { handled: true, preventDefault: true };
}
return { handled: true, preventDefault: true };
}
// Normal mode - check bindings
if (mode === "normal") {
const binding = findBinding(input, mode, key.ctrl, key.shift);
if (binding) {
executeAction(binding.action, binding.argument);
return {
handled: true,
action: binding.action,
preventDefault: true,
};
}
// Handle 'gg' for go to top (two-key sequence)
// For simplicity, we handle 'g' as goto_top
// A full implementation would track pending keys
return { handled: false, preventDefault: false };
}
// Visual mode
if (mode === "visual") {
const binding = findBinding(input, mode, key.ctrl, key.shift);
if (binding) {
executeAction(binding.action, binding.argument);
return {
handled: true,
action: binding.action,
preventDefault: true,
};
}
return { handled: false, preventDefault: false };
}
// Insert mode - pass through to normal input handling
return { handled: false, preventDefault: false };
},
[enabled, mode, executeAction]
);
// Use ink's input hook
useInput(
(input, key) => {
if (!isActive || !enabled) return;
const result = handleInput(input, key);
// Result handling is done by the callbacks
},
{ isActive: isActive && enabled }
);
return {
mode,
enabled,
commandBuffer,
searchPattern,
config,
handleInput,
enable: vimActions.enable,
disable: vimActions.disable,
toggle: vimActions.toggle,
setMode: vimActions.setMode,
};
};
export default useVimMode;

View File

@@ -1,18 +0,0 @@
/**
* useVimStore React Hook
*
* React hook for accessing vim store state
*/
import { useStore } from "zustand";
import { vimStore } from "@stores/vim-store";
import type { VimStore } from "@stores/vim-store";
/**
* React hook for vim store
*/
export const useVimStore = <T>(selector: (state: VimStore) => T): T => {
return useStore(vimStore, selector);
};
export default useVimStore;

View File

@@ -3,7 +3,36 @@
* Re-exports from @opentui/solid implementation
*/
export * from "@tui/types";
export type {
AppMode,
CommandCategory,
SlashCommand,
CommandMenuState,
SelectOption,
DiffData,
DiffLineType,
DiffLineData,
DiffViewProps,
DiffLineProps,
LogEntryType,
ToolStatus,
LogEntryMetadata,
LogEntry,
LogEntryProps,
ThinkingIndicatorProps,
ToolCall,
PermissionType,
PermissionScope,
PermissionRequest,
PermissionResponse,
LearningScope,
LearningPrompt,
LearningResponse,
SessionStats,
HeaderProps,
CommandMenuProps,
AppState,
} from "@/types/tui";
export { tui, appStore } from "@tui-solid/index";
export type { TuiRenderOptions } from "@tui-solid/app";
export {

View File

@@ -1,232 +0,0 @@
/**
* Mouse Handler - Intercepts mouse events from stdin before Ink processes them
*
* This module intercepts stdin data events to:
* 1. Enable mouse tracking in the terminal
* 2. Filter out mouse escape sequences
* 3. Handle scroll wheel events
* 4. Pass all other input through to Ink
*/
import type { MouseHandlerCallbacks } from "@interfaces/MouseHandlerCallbacks";
import type { MouseScrollDirection } from "@constants/mouse-handler";
import {
MOUSE_SCROLL_LINES,
SGR_MOUSE_REGEX,
X10_MOUSE_REGEX,
PARTIAL_SGR_REGEX,
PARTIAL_X10_REGEX,
MOUSE_TRACKING_SEQUENCES,
MOUSE_BUTTON_TO_SCROLL,
} from "@constants/mouse-handler";
// Re-export interface for convenience
export type { MouseHandlerCallbacks } from "@interfaces/MouseHandlerCallbacks";
// Scroll direction handlers type
type ScrollHandlers = Record<MouseScrollDirection, (lines: number) => void>;
/**
* Enable mouse tracking in the terminal
*/
export const enableMouseTracking = (): void => {
process.stdout.write(MOUSE_TRACKING_SEQUENCES.ENABLE_BUTTON);
process.stdout.write(MOUSE_TRACKING_SEQUENCES.ENABLE_SGR);
};
/**
* Disable mouse tracking in the terminal
*/
export const disableMouseTracking = (): void => {
process.stdout.write(MOUSE_TRACKING_SEQUENCES.DISABLE_SGR);
process.stdout.write(MOUSE_TRACKING_SEQUENCES.DISABLE_BUTTON);
};
/**
* Create scroll handlers from callbacks
*/
const createScrollHandlers = (
callbacks: MouseHandlerCallbacks,
): ScrollHandlers => ({
up: callbacks.onScrollUp,
down: callbacks.onScrollDown,
});
/**
* Dispatch scroll event to the appropriate handler
*/
const dispatchScrollEvent = (
direction: MouseScrollDirection,
handlers: ScrollHandlers,
lines: number,
): void => {
handlers[direction](lines);
};
/**
* Parse SGR mouse events and extract scroll wheel actions
* Returns the data with mouse sequences removed
*/
const filterMouseEvents = (
data: string,
callbacks: MouseHandlerCallbacks,
): string => {
const handlers = createScrollHandlers(callbacks);
// Handle SGR mouse sequences
let filtered = data.replace(
SGR_MOUSE_REGEX,
(_match, button, _col, _row, action) => {
const buttonNum = parseInt(button, 10);
const direction = MOUSE_BUTTON_TO_SCROLL[buttonNum];
// Only handle button press events (M), not release (m)
if (action === "M" && direction) {
dispatchScrollEvent(direction, handlers, MOUSE_SCROLL_LINES);
}
// Remove the sequence
return "";
},
);
// Remove X10 mouse sequences
filtered = filtered.replace(X10_MOUSE_REGEX, "");
// Remove partial/incomplete sequences
filtered = filtered.replace(PARTIAL_SGR_REGEX, "");
filtered = filtered.replace(PARTIAL_X10_REGEX, "");
return filtered;
};
// Store for the original emit function and current callbacks
let originalEmit: typeof process.stdin.emit | null = null;
let currentCallbacks: MouseHandlerCallbacks | null = null;
let isSetup = false;
/**
* Cleanup function that can be called from anywhere
*/
const doCleanup = (): void => {
if (!isSetup) return;
// Disable mouse tracking first (most important for terminal state)
disableMouseTracking();
// Restore original emit
if (originalEmit) {
process.stdin.emit = originalEmit as typeof process.stdin.emit;
originalEmit = null;
}
currentCallbacks = null;
isSetup = false;
};
/**
* Emergency cleanup on process exit - ensures terminal is restored
*/
const emergencyCleanup = (): void => {
// Just disable mouse tracking - don't try to restore emit
// This is safe to call multiple times
process.stdout.write(MOUSE_TRACKING_SEQUENCES.DISABLE_SGR);
process.stdout.write(MOUSE_TRACKING_SEQUENCES.DISABLE_BUTTON);
};
/**
* Register process exit handlers for cleanup
*/
const registerExitHandlers = (): void => {
process.on("exit", emergencyCleanup);
process.on("SIGINT", () => {
emergencyCleanup();
process.exit(130);
});
process.on("SIGTERM", () => {
emergencyCleanup();
process.exit(143);
});
process.on("uncaughtException", (err) => {
emergencyCleanup();
console.error("Uncaught exception:", err);
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
emergencyCleanup();
console.error("Unhandled rejection:", reason);
process.exit(1);
});
};
/**
* Create data event handler
*/
const createDataHandler = (
origEmit: typeof process.stdin.emit,
): ((event: string | symbol, ...args: unknown[]) => boolean) => {
return function (event: string | symbol, ...args: unknown[]): boolean {
if (event !== "data" || !currentCallbacks || !args[0]) {
return origEmit.call(process.stdin, event, ...args);
}
const chunk = args[0];
let data: string;
if (Buffer.isBuffer(chunk)) {
data = chunk.toString("utf8");
} else if (typeof chunk === "string") {
data = chunk;
} else {
return origEmit.call(process.stdin, event, ...args);
}
// Filter mouse events
const filtered = filterMouseEvents(data, currentCallbacks);
// Only emit if there's remaining data
if (filtered.length > 0) {
return origEmit.call(process.stdin, event, filtered);
}
// Return true to indicate event was handled
return true;
};
};
/**
* Setup mouse handling by intercepting stdin data events
* This approach preserves raw mode support required by Ink
*/
export const setupMouseHandler = (
callbacks: MouseHandlerCallbacks,
): {
cleanup: () => void;
} => {
if (isSetup) {
// Already setup, just update callbacks
currentCallbacks = callbacks;
return { cleanup: doCleanup };
}
currentCallbacks = callbacks;
isSetup = true;
// Register exit handlers
registerExitHandlers();
// Enable mouse tracking
enableMouseTracking();
// Store original emit
originalEmit = process.stdin.emit.bind(process.stdin);
// Override emit to intercept 'data' events
process.stdin.emit = createDataHandler(
originalEmit,
) as typeof process.stdin.emit;
return {
cleanup: doCleanup,
};
};

View File

@@ -1,684 +0,0 @@
/**
* Application State Management with Zustand
*/
import { create } from "zustand";
import type {
AppState,
AppMode,
ScreenMode,
InteractionMode,
LogEntry,
ToolCall,
PermissionRequest,
LearningPrompt,
SessionStats,
StreamingLogState,
SuggestionState,
SuggestionPrompt,
} from "@/types/tui";
import type { ProviderModel } from "@/types/providers";
import type { BrainConnectionStatus, BrainUser } from "@/types/brain";
const createInitialBrainState = () => ({
status: "disconnected" as BrainConnectionStatus,
user: null as BrainUser | null,
knowledgeCount: 0,
memoryCount: 0,
showBanner: true,
});
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,
});
let logIdCounter = 0;
const generateLogId = (): string => {
return `log-${++logIdCounter}-${Date.now()}`;
};
const INTERACTION_MODES: InteractionMode[] = ["agent", "ask", "code-review"];
export const useAppStore = create<AppState>((set, get) => ({
// Initial state
mode: "idle",
screenMode: "home",
interactionMode: "agent" as InteractionMode,
inputBuffer: "",
inputCursorPosition: 0,
logs: [],
currentToolCall: null,
permissionRequest: null,
learningPrompt: null,
thinkingMessage: null,
sessionId: null,
provider: "copilot",
model: "",
version: "0.1.0",
commandMenu: {
isOpen: false,
filter: "",
selectedIndex: 0,
},
availableModels: [],
sessionStats: createInitialSessionStats(),
todosVisible: true,
interruptPending: false,
exitPending: false,
isCompacting: false,
scrollOffset: 0,
autoScroll: true,
userScrolled: false,
isSettling: false,
totalLines: 0,
visibleHeight: 20,
streamingLog: createInitialStreamingState(),
suggestions: createInitialSuggestionState(),
brain: createInitialBrainState(),
// Mode actions
setMode: (mode: AppMode) => set({ mode }),
setScreenMode: (screenMode: ScreenMode) => set({ screenMode }),
setInteractionMode: (interactionMode: InteractionMode) => set({ interactionMode }),
toggleInteractionMode: () => set((state) => {
const currentIndex = INTERACTION_MODES.indexOf(state.interactionMode);
const nextIndex = (currentIndex + 1) % INTERACTION_MODES.length;
return { interactionMode: INTERACTION_MODES[nextIndex] };
}),
// Input actions
setInputBuffer: (buffer: string) => set({ inputBuffer: buffer }),
setInputCursorPosition: (position: number) =>
set({ inputCursorPosition: position }),
appendToInput: (text: string) => {
const { inputBuffer, inputCursorPosition } = get();
const before = inputBuffer.slice(0, inputCursorPosition);
const after = inputBuffer.slice(inputCursorPosition);
set({
inputBuffer: before + text + after,
inputCursorPosition: inputCursorPosition + text.length,
});
},
clearInput: () => set({ inputBuffer: "", inputCursorPosition: 0 }),
// Log actions
addLog: (entry: Omit<LogEntry, "id" | "timestamp">) => {
const newEntry: LogEntry = {
...entry,
id: generateLogId(),
timestamp: Date.now(),
};
set((state) => ({ logs: [...state.logs, newEntry] }));
return newEntry.id;
},
updateLog: (id: string, updates: Partial<LogEntry>) => {
set((state) => ({
logs: state.logs.map((log) =>
log.id === id ? { ...log, ...updates } : log,
),
}));
},
clearLogs: () => set({ logs: [] }),
// Tool call actions
setCurrentToolCall: (toolCall: ToolCall | null) =>
set({ currentToolCall: toolCall }),
updateToolCall: (updates: Partial<ToolCall>) => {
set((state) => ({
currentToolCall: state.currentToolCall
? { ...state.currentToolCall, ...updates }
: null,
}));
},
// Permission actions
setPermissionRequest: (request: PermissionRequest | null) =>
set({ permissionRequest: request }),
// Learning prompt actions
setLearningPrompt: (prompt: LearningPrompt | null) =>
set({ learningPrompt: prompt }),
// Thinking message
setThinkingMessage: (message: string | null) =>
set({ thinkingMessage: message }),
// Session info
setSessionInfo: (sessionId: string, provider: string, model: string) =>
set({ sessionId, provider, model }),
setVersion: (version: string) => set({ version }),
// Command menu actions
openCommandMenu: () =>
set({
mode: "command_menu",
commandMenu: { isOpen: true, filter: "", selectedIndex: 0 },
}),
closeCommandMenu: () =>
set({
mode: "idle",
commandMenu: { isOpen: false, filter: "", selectedIndex: 0 },
}),
setCommandFilter: (filter: string) =>
set((state) => ({
commandMenu: { ...state.commandMenu, filter, selectedIndex: 0 },
})),
setCommandSelectedIndex: (index: number) =>
set((state) => ({
commandMenu: { ...state.commandMenu, selectedIndex: index },
})),
// Model actions
setAvailableModels: (models: ProviderModel[]) =>
set({ availableModels: models }),
setModel: (model: string) => set({ model }),
// Session stats actions
startThinking: () =>
set((state) => ({
sessionStats: {
...state.sessionStats,
thinkingStartTime: Date.now(),
},
})),
stopThinking: () =>
set((state) => {
const duration = state.sessionStats.thinkingStartTime
? Math.floor((Date.now() - state.sessionStats.thinkingStartTime) / 1000)
: 0;
return {
sessionStats: {
...state.sessionStats,
thinkingStartTime: null,
lastThinkingDuration: duration,
},
};
}),
addTokens: (input: number, output: number) =>
set((state) => ({
sessionStats: {
...state.sessionStats,
inputTokens: state.sessionStats.inputTokens + input,
outputTokens: state.sessionStats.outputTokens + output,
},
})),
resetSessionStats: () => set({ sessionStats: createInitialSessionStats() }),
// UI state actions
toggleTodos: () => set((state) => ({ todosVisible: !state.todosVisible })),
setInterruptPending: (pending: boolean) => set({ interruptPending: pending }),
setExitPending: (pending: boolean) => set({ exitPending: pending }),
setIsCompacting: (compacting: boolean) => set({ isCompacting: compacting }),
// Scroll actions
scrollUp: (lines = 3) =>
set((state) => {
const newOffset = Math.max(0, state.scrollOffset - lines);
return {
scrollOffset: newOffset,
autoScroll: false,
userScrolled: true,
};
}),
scrollDown: (lines = 3) =>
set((state) => {
const maxScroll = Math.max(0, state.totalLines - state.visibleHeight);
const newOffset = Math.min(maxScroll, state.scrollOffset + lines);
const distanceFromBottom = maxScroll - newOffset;
const isAtBottom = distanceFromBottom <= 3;
return {
scrollOffset: newOffset,
autoScroll: isAtBottom,
userScrolled: !isAtBottom,
};
}),
scrollToTop: () =>
set({
scrollOffset: 0,
autoScroll: false,
userScrolled: true,
}),
scrollToBottom: () =>
set((state) => {
const maxScroll = Math.max(0, state.totalLines - state.visibleHeight);
return {
scrollOffset: maxScroll,
autoScroll: true,
userScrolled: false,
};
}),
setAutoScroll: (enabled: boolean) => set({ autoScroll: enabled }),
setUserScrolled: (scrolled: boolean) =>
set({
userScrolled: scrolled,
autoScroll: !scrolled,
}),
setScrollDimensions: (totalLines: number, visibleHeight: number) =>
set((state) => {
const maxScroll = Math.max(0, totalLines - visibleHeight);
// If auto-scroll is enabled, keep scroll at bottom
const newOffset = state.autoScroll
? maxScroll
: Math.min(state.scrollOffset, maxScroll);
return {
totalLines,
visibleHeight,
scrollOffset: newOffset,
};
}),
pauseAutoScroll: () =>
set({
autoScroll: false,
userScrolled: true,
}),
resumeAutoScroll: () =>
set((state) => {
const maxScroll = Math.max(0, state.totalLines - state.visibleHeight);
return {
autoScroll: true,
userScrolled: false,
scrollOffset: maxScroll,
};
}),
getEffectiveScrollOffset: () => {
const state = get();
const maxScroll = Math.max(0, state.totalLines - state.visibleHeight);
if (state.autoScroll && !state.userScrolled) {
return maxScroll;
}
return Math.min(state.scrollOffset, maxScroll);
},
// Streaming actions
startStreaming: () => {
const logId = generateLogId();
const entry: LogEntry = {
id: logId,
type: "assistant_streaming",
content: "",
timestamp: Date.now(),
metadata: { isStreaming: true, streamComplete: false },
};
set((state) => ({
logs: [...state.logs, entry],
streamingLog: {
logId,
content: "",
isStreaming: true,
},
}));
return logId;
},
appendStreamContent: (content: string) => {
set((state) => {
if (!state.streamingLog.logId || !state.streamingLog.isStreaming) {
return state;
}
const newContent = state.streamingLog.content + content;
return {
streamingLog: {
...state.streamingLog,
content: newContent,
},
logs: state.logs.map((log) =>
log.id === state.streamingLog.logId
? { ...log, content: newContent }
: log,
),
};
});
},
completeStreaming: () => {
set((state) => {
if (!state.streamingLog.logId) {
return state;
}
return {
streamingLog: createInitialStreamingState(),
logs: state.logs.map((log) =>
log.id === state.streamingLog.logId
? {
...log,
type: "assistant" as const,
metadata: {
...log.metadata,
isStreaming: false,
streamComplete: true,
},
}
: log,
),
};
});
},
cancelStreaming: () => {
set((state) => {
if (!state.streamingLog.logId) {
return state;
}
// Remove the streaming log entry if cancelled
return {
streamingLog: createInitialStreamingState(),
logs: state.logs.filter((log) => log.id !== state.streamingLog.logId),
};
});
},
// Suggestion actions
setSuggestions: (newSuggestions: SuggestionPrompt[]) =>
set({
suggestions: {
suggestions: newSuggestions,
selectedIndex: 0,
visible: newSuggestions.length > 0,
},
}),
clearSuggestions: () => set({ suggestions: createInitialSuggestionState() }),
selectSuggestion: (index: number) =>
set((state) => ({
suggestions: {
...state.suggestions,
selectedIndex: Math.max(
0,
Math.min(index, state.suggestions.suggestions.length - 1),
),
},
})),
nextSuggestion: () =>
set((state) => ({
suggestions: {
...state.suggestions,
selectedIndex:
(state.suggestions.selectedIndex + 1) %
Math.max(1, state.suggestions.suggestions.length),
},
})),
prevSuggestion: () =>
set((state) => ({
suggestions: {
...state.suggestions,
selectedIndex:
state.suggestions.selectedIndex === 0
? Math.max(0, state.suggestions.suggestions.length - 1)
: state.suggestions.selectedIndex - 1,
},
})),
hideSuggestions: () =>
set((state) => ({
suggestions: { ...state.suggestions, visible: false },
})),
showSuggestions: () =>
set((state) => ({
suggestions: {
...state.suggestions,
visible: state.suggestions.suggestions.length > 0,
},
})),
// Brain actions
setBrainStatus: (status: BrainConnectionStatus) =>
set((state) => ({
brain: { ...state.brain, status },
})),
setBrainUser: (user: BrainUser | null) =>
set((state) => ({
brain: { ...state.brain, user },
})),
setBrainCounts: (knowledgeCount: number, memoryCount: number) =>
set((state) => ({
brain: { ...state.brain, knowledgeCount, memoryCount },
})),
setBrainShowBanner: (showBanner: boolean) =>
set((state) => ({
brain: { ...state.brain, showBanner },
})),
dismissBrainBanner: () =>
set((state) => ({
brain: { ...state.brain, showBanner: false },
})),
// Computed - check if input should be locked
isInputLocked: () => {
const { mode } = get();
return (
mode === "thinking" ||
mode === "tool_execution" ||
mode === "permission_prompt"
);
},
}));
// Non-React access to store (for use in agent callbacks)
export const appStore = {
getState: () => useAppStore.getState(),
addLog: (entry: Omit<LogEntry, "id" | "timestamp">) => {
return useAppStore.getState().addLog(entry);
},
updateLog: (id: string, updates: Partial<LogEntry>) => {
useAppStore.getState().updateLog(id, updates);
},
setMode: (mode: AppMode) => {
useAppStore.getState().setMode(mode);
},
setCurrentToolCall: (toolCall: ToolCall | null) => {
useAppStore.getState().setCurrentToolCall(toolCall);
},
updateToolCall: (updates: Partial<ToolCall>) => {
useAppStore.getState().updateToolCall(updates);
},
setThinkingMessage: (message: string | null) => {
useAppStore.getState().setThinkingMessage(message);
},
setPermissionRequest: (request: PermissionRequest | null) => {
useAppStore.getState().setPermissionRequest(request);
},
setLearningPrompt: (prompt: LearningPrompt | null) => {
useAppStore.getState().setLearningPrompt(prompt);
},
clearInput: () => {
useAppStore.getState().clearInput();
},
clearLogs: () => {
useAppStore.getState().clearLogs();
},
openCommandMenu: () => {
useAppStore.getState().openCommandMenu();
},
closeCommandMenu: () => {
useAppStore.getState().closeCommandMenu();
},
setAvailableModels: (models: ProviderModel[]) => {
useAppStore.getState().setAvailableModels(models);
},
setModel: (model: string) => {
useAppStore.getState().setModel(model);
},
// Session stats
startThinking: () => {
useAppStore.getState().startThinking();
},
stopThinking: () => {
useAppStore.getState().stopThinking();
},
addTokens: (input: number, output: number) => {
useAppStore.getState().addTokens(input, output);
},
resetSessionStats: () => {
useAppStore.getState().resetSessionStats();
},
// UI state
toggleTodos: () => {
useAppStore.getState().toggleTodos();
},
toggleInteractionMode: () => {
useAppStore.getState().toggleInteractionMode();
},
setInteractionMode: (mode: InteractionMode) => {
useAppStore.getState().setInteractionMode(mode);
},
setInterruptPending: (pending: boolean) => {
useAppStore.getState().setInterruptPending(pending);
},
setIsCompacting: (compacting: boolean) => {
useAppStore.getState().setIsCompacting(compacting);
},
// Streaming
startStreaming: () => {
return useAppStore.getState().startStreaming();
},
appendStreamContent: (content: string) => {
useAppStore.getState().appendStreamContent(content);
},
completeStreaming: () => {
useAppStore.getState().completeStreaming();
},
cancelStreaming: () => {
useAppStore.getState().cancelStreaming();
},
// Suggestions
setSuggestions: (suggestions: SuggestionPrompt[]) => {
useAppStore.getState().setSuggestions(suggestions);
},
clearSuggestions: () => {
useAppStore.getState().clearSuggestions();
},
hideSuggestions: () => {
useAppStore.getState().hideSuggestions();
},
// Scroll
scrollUp: (lines?: number) => {
useAppStore.getState().scrollUp(lines);
},
scrollDown: (lines?: number) => {
useAppStore.getState().scrollDown(lines);
},
scrollToTop: () => {
useAppStore.getState().scrollToTop();
},
scrollToBottom: () => {
useAppStore.getState().scrollToBottom();
},
pauseAutoScroll: () => {
useAppStore.getState().pauseAutoScroll();
},
resumeAutoScroll: () => {
useAppStore.getState().resumeAutoScroll();
},
// Brain
setBrainStatus: (status: BrainConnectionStatus) => {
useAppStore.getState().setBrainStatus(status);
},
setBrainUser: (user: BrainUser | null) => {
useAppStore.getState().setBrainUser(user);
},
setBrainCounts: (knowledge: number, memory: number) => {
useAppStore.getState().setBrainCounts(knowledge, memory);
},
setBrainShowBanner: (show: boolean) => {
useAppStore.getState().setBrainShowBanner(show);
},
dismissBrainBanner: () => {
useAppStore.getState().dismissBrainBanner();
},
};

View File

@@ -1,34 +0,0 @@
/**
* TUI Types - Re-exports from centralized types
*/
export type {
AppMode,
CommandCategory,
SlashCommand,
CommandMenuState,
SelectOption,
DiffData,
DiffLineType,
DiffLineData,
DiffViewProps,
DiffLineProps,
LogEntryType,
ToolStatus,
LogEntryMetadata,
LogEntry,
LogEntryProps,
ThinkingIndicatorProps,
ToolCall,
PermissionType,
PermissionScope,
PermissionRequest,
PermissionResponse,
LearningScope,
LearningPrompt,
LearningResponse,
SessionStats,
HeaderProps,
CommandMenuProps,
AppState,
} from "@/types/tui";

View File

@@ -143,9 +143,9 @@ export const BRAIN_MCP_TOOLS: ReadonlyArray<BrainMcpTool> = [
properties: {
name: { type: "string", description: "The name of the concept" },
whatItDoes: { type: "string", description: "Description of what the concept does" },
keywords: { type: "array", items: { type: "string" }, description: "Keywords for the concept" },
patterns: { type: "array", items: { type: "string" }, description: "Code patterns related to the concept" },
files: { type: "array", items: { type: "string" }, description: "Files related to the concept" },
keywords: { type: "array", items: { type: "string", description: "Keyword item" }, description: "Keywords for the concept" },
patterns: { type: "array", items: { type: "string", description: "Pattern item" }, description: "Code patterns related to the concept" },
files: { type: "array", items: { type: "string", description: "File path item" }, description: "Files related to the concept" },
},
required: ["name", "whatItDoes"],
},

View File

@@ -46,7 +46,7 @@ export interface ParallelAgentConfig {
* Parallel task definition
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface ParallelTask<TInput = unknown, TOutput = unknown> {
export interface ParallelTask<TInput = unknown, _TOutput = unknown> {
id: string;
type: ParallelTaskType;
agent: ParallelAgentConfig;

View File

@@ -4,7 +4,7 @@
import chalk from "chalk";
import { DEFAULT_BAR_WIDTH } from "@constants/hooks.js";
import type { ProgressBarOptions } from "@interfactes/ProgressBar";
import type { ProgressBarOptions } from "@interfaces/ProgressBar";
const defaultOptions: Required<ProgressBarOptions> = {
width: DEFAULT_BAR_WIDTH,

View File

@@ -44,5 +44,5 @@
"jsxImportSource": "@opentui/solid"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "src/tui/App.tsx", "src/tui/components", "src/tui/hooks", "src/tui/store.ts"]
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}