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:
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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("---");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
compactConversation,
|
||||
checkCompactionNeeded,
|
||||
getModelCompactionConfig,
|
||||
createCompactionSummary,
|
||||
} from "@services/auto-compaction";
|
||||
import { appStore } from "@tui-solid/context/app";
|
||||
|
||||
|
||||
857
src/tui/App.tsx
857
src/tui/App.tsx
@@ -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}>> </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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">> </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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}>> </Text>
|
||||
<Text backgroundColor={colors.bgCursor} color={colors.textDim}>
|
||||
T
|
||||
</Text>
|
||||
<Text dimColor>ype your message...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Text color={colors.primary}>> </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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
684
src/tui/store.ts
684
src/tui/store.ts
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user