Terminal-based AI coding agent with interactive TUI for autonomous code generation.

Features:
  - Interactive TUI with React/Ink
  - Autonomous agent with tool calls (bash, read, write, edit, glob, grep)
  - Permission system with pattern-based rules
  - Session management with auto-compaction
  - Dual providers: GitHub Copilot and Ollama
  - MCP server integration
  - Todo panel and theme system
  - Streaming responses
  - GitHub-compatible project context
This commit is contained in:
2026-01-27 23:33:06 -05:00
commit 0062e5d9d9
521 changed files with 66418 additions and 0 deletions

36
src/commands/chat-tui.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* TUI-based Chat Command for CodeTyper CLI (Presentation Layer)
*
* This file is the main entry point for the chat TUI.
* It assembles callbacks and re-exports the execute function.
* All business logic is delegated to chat-tui-service.ts
*/
import type { ChatServiceCallbacks } from "@services/chat-tui-service.ts";
import { onModeChange } from "@commands/components/callbacks/on-mode-change.ts";
import { onLog } from "@commands/components/callbacks/on-log.ts";
import { onToolCall } from "@commands/components/callbacks/on-tool-call.ts";
import { onToolResult } from "@commands/components/callbacks/on-tool-result.ts";
import { onPermissionRequest } from "@commands/components/callbacks/on-permission-request.ts";
import { onLearningDetected } from "@commands/components/callbacks/on-learning-detected.ts";
import executeCommand from "@commands/components/execute/index.ts";
export const createCallbacks = (): ChatServiceCallbacks => ({
onModeChange,
onLog,
onToolCall,
onToolResult,
onPermissionRequest,
onLearningDetected,
});
export const execute = executeCommand;
export {
onModeChange,
onLog,
onToolCall,
onToolResult,
onPermissionRequest,
onLearningDetected,
};

8
src/commands/chat.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Interactive chat mode for CodeTyper CLI
*
* This file re-exports the modular chat implementation.
*/
export { execute, createInitialState } from "@commands/components/chat/index";
export type { ChatState } from "@commands/components/chat/index";

View File

@@ -0,0 +1,23 @@
import { v4 as uuidv4 } from "uuid";
import { appStore } from "@tui/index.ts";
import type { LearningResponse } from "@tui/types.ts";
import type { LearningCandidate } from "@services/learning-service.ts";
export const onLearningDetected = async (
candidate: LearningCandidate,
): Promise<LearningResponse> => {
return new Promise((resolve) => {
appStore.setMode("learning_prompt");
appStore.setLearningPrompt({
id: uuidv4(),
content: candidate.content,
context: candidate.context,
category: candidate.category,
resolve: (response: LearningResponse) => {
appStore.setLearningPrompt(null);
appStore.setMode("idle");
resolve(response);
},
});
});
};

View File

@@ -0,0 +1,14 @@
import { appStore } from "@tui/index.ts";
import type { LogType } from "@/types/log";
export const onLog = (
type: string,
content: string,
metadata?: Record<string, unknown>,
): void => {
appStore.addLog({
type: type as LogType,
content,
metadata,
});
};

View File

@@ -0,0 +1,5 @@
import { appStore } from "@tui/index.ts";
export const onModeChange = (mode: string): void => {
appStore.setMode(mode as Parameters<typeof appStore.setMode>[0]);
};

View File

@@ -0,0 +1,7 @@
interface PermissionResponse {
allowed: boolean;
}
export const onPermissionRequest = async (): Promise<PermissionResponse> => {
return { allowed: false };
};

View File

@@ -0,0 +1,25 @@
import { appStore } from "@tui/index.ts";
import { isQuietTool } from "@utils/tools.ts";
import type { ToolCallParams } from "@interfaces/ToolCallParams.ts";
export const onToolCall = (call: ToolCallParams): void => {
appStore.setCurrentToolCall({
id: call.id,
name: call.name,
description: call.description,
status: "running",
});
const isQuiet = isQuietTool(call.name, call.args);
appStore.addLog({
type: "tool",
content: call.description,
metadata: {
toolName: call.name,
toolStatus: "running",
toolDescription: call.description,
quiet: isQuiet,
},
});
};

View File

@@ -0,0 +1,47 @@
import { appStore } from "@tui/index.ts";
import {
truncateOutput,
detectDiffContent,
} from "@services/chat-tui-service.ts";
import { getThinkingMessage } from "@constants/status-messages.ts";
export const onToolResult = (
success: boolean,
title: string,
output?: string,
error?: string,
): void => {
appStore.updateToolCall({
status: success ? "success" : "error",
result: success ? output : undefined,
error: error,
});
const state = appStore.getState();
const logEntry = state.logs.find(
(log) => log.type === "tool" && log.metadata?.toolStatus === "running",
);
if (logEntry) {
const diffData = output ? detectDiffContent(output) : undefined;
const displayContent = diffData?.isDiff
? output
: output
? truncateOutput(output)
: "";
appStore.updateLog(logEntry.id, {
content: success
? `${title}${displayContent ? "\n" + displayContent : ""}`
: `${title}: ${error}`,
metadata: {
...logEntry.metadata,
toolStatus: success ? "success" : "error",
toolDescription: title,
diffData: diffData,
},
});
}
appStore.setThinkingMessage(getThinkingMessage());
};

View File

@@ -0,0 +1,35 @@
/**
* Show available agents command
*/
import chalk from "chalk";
import { agentLoader } from "@services/agent-loader";
import type { ChatState } from "@commands/components/chat/state";
export const showAgents = async (state: ChatState): Promise<void> => {
const agents = await agentLoader.getAvailableAgents(process.cwd());
const currentAgent = state.currentAgent ?? "coder";
console.log("\n" + chalk.bold.underline("Available Agents") + "\n");
for (const agent of agents) {
const isCurrent = agent.id === currentAgent;
const marker = isCurrent ? chalk.cyan("→") : " ";
const nameStyle = isCurrent ? chalk.cyan.bold : chalk.white;
console.log(`${marker} ${nameStyle(agent.name)}`);
if (agent.description) {
console.log(` ${chalk.gray(agent.description)}`);
}
if (agent.model) {
console.log(` ${chalk.gray(`Model: ${agent.model}`)}`);
}
console.log();
}
console.log(chalk.gray("Use /agent <name> to switch agents"));
console.log();
};

View File

@@ -0,0 +1,60 @@
/**
* Switch agent command
*/
import chalk from "chalk";
import { errorMessage, infoMessage, warningMessage } from "@utils/terminal";
import { agentLoader } from "@services/agent-loader";
import type { ChatState } from "@commands/components/chat/state";
export const switchAgent = async (
agentName: string,
state: ChatState,
): Promise<void> => {
if (!agentName.trim()) {
warningMessage("Usage: /agent <name>");
infoMessage("Use /agents to see available agents");
return;
}
const normalizedName = agentName.toLowerCase().trim();
const agents = await agentLoader.getAvailableAgents(process.cwd());
// Find agent by id or partial name match
const agent = agents.find(
(a) =>
a.id === normalizedName ||
a.name.toLowerCase() === normalizedName ||
a.id.includes(normalizedName) ||
a.name.toLowerCase().includes(normalizedName),
);
if (!agent) {
errorMessage(`Agent not found: ${agentName}`);
infoMessage("Use /agents to see available agents");
return;
}
state.currentAgent = agent.id;
// Update system prompt with agent prompt
if (agent.prompt) {
// Prepend agent prompt to system prompt
const basePrompt = state.systemPrompt;
state.systemPrompt = `${agent.prompt}\n\n${basePrompt}`;
// Update the system message in messages array
if (state.messages.length > 0 && state.messages[0].role === "system") {
state.messages[0].content = state.systemPrompt;
}
}
console.log();
console.log(chalk.green(`✓ Switched to agent: ${chalk.bold(agent.name)}`));
if (agent.description) {
console.log(chalk.gray(` ${agent.description}`));
}
console.log();
};

View File

@@ -0,0 +1,12 @@
import chalk from "chalk";
import type { ChatState } from "./state.ts";
export const createCleanup = (state: ChatState) => (): void => {
state.isRunning = false;
if (state.inputEditor) {
state.inputEditor.stop();
state.inputEditor = null;
}
console.log("\n" + chalk.cyan("Goodbye!"));
process.exit(0);
};

View File

@@ -0,0 +1,111 @@
import { saveSession } from "@services/session";
import { showHelp } from "@commands/components/chat/commands/show-help";
import { clearConversation } from "@commands/components/chat/history/clear-conversation";
import { showContextFiles } from "@commands/components/chat/context/show-context-files";
import { removeFile } from "@commands/components/chat/context/remove-file";
import { showContext } from "@commands/components/chat/history/show-context";
import { compactHistory } from "@commands/components/chat/history/compact-history";
import { showHistory } from "@commands/components/chat/history/show-history";
import { showModels } from "@commands/components/chat/models/show-models";
import { showProviders } from "@commands/components/chat/models/show-providers";
import { switchProvider } from "@commands/components/chat/models/switch-provider";
import { switchModel } from "@commands/components/chat/models/switch-model";
import { showSessionInfo } from "@commands/components/chat/session/show-session-info";
import { listSessions } from "@commands/components/chat/session/list-sessions";
import { showUsage } from "@commands/components/chat/usage/show-usage";
import { showAgents } from "@commands/components/chat/agents/show-agents";
import { switchAgent } from "@commands/components/chat/agents/switch-agent";
import { handleMCP } from "@commands/components/chat/mcp/handle-mcp";
import { CommandContext } from "@interfaces/commandContext";
import type { CommandHandler } from "@/types/commandHandler";
import { successMessage } from "@utils/terminal";
const COMMAND_REGISTRY: Map<string, CommandHandler> = new Map<
string,
CommandHandler
>([
["help", () => showHelp()],
["h", () => showHelp()],
["clear", (ctx: CommandContext) => clearConversation(ctx.state)],
["c", (ctx: CommandContext) => clearConversation(ctx.state)],
["files", (ctx: CommandContext) => showContextFiles(ctx.state.contextFiles)],
["f", (ctx: CommandContext) => showContextFiles(ctx.state.contextFiles)],
["exit", (ctx: CommandContext) => ctx.cleanup()],
["quit", (ctx: CommandContext) => ctx.cleanup()],
["q", (ctx: CommandContext) => ctx.cleanup()],
[
"save",
async () => {
await saveSession();
successMessage("Session saved");
},
],
[
"s",
async () => {
await saveSession();
successMessage("Session saved");
},
],
[
"models",
async (ctx: CommandContext) =>
showModels(ctx.state.currentProvider, ctx.state.currentModel),
],
[
"m",
async (ctx: CommandContext) =>
showModels(ctx.state.currentProvider, ctx.state.currentModel),
],
["providers", async () => showProviders()],
["p", async () => showProviders()],
[
"provider",
async (ctx: CommandContext) =>
switchProvider(ctx.args.join(" "), ctx.state),
],
[
"model",
async (ctx: CommandContext) => switchModel(ctx.args.join(" "), ctx.state),
],
["context", (ctx: CommandContext) => showContext(ctx.state)],
["compact", (ctx: CommandContext) => compactHistory(ctx.state)],
["history", (ctx: CommandContext) => showHistory(ctx.state)],
[
"remove",
(ctx: CommandContext) =>
removeFile(ctx.args.join(" "), ctx.state.contextFiles),
],
[
"rm",
(ctx: CommandContext) =>
removeFile(ctx.args.join(" "), ctx.state.contextFiles),
],
["session", async () => showSessionInfo()],
["sessions", async () => listSessions()],
["usage", async (ctx: CommandContext) => showUsage(ctx.state)],
["u", async (ctx: CommandContext) => showUsage(ctx.state)],
[
"agent",
async (ctx: CommandContext) => {
if (ctx.args.length === 0) {
await showAgents(ctx.state);
} else {
await switchAgent(ctx.args.join(" "), ctx.state);
}
},
],
[
"a",
async (ctx: CommandContext) => {
if (ctx.args.length === 0) {
await showAgents(ctx.state);
} else {
await switchAgent(ctx.args.join(" "), ctx.state);
}
},
],
["mcp", async (ctx: CommandContext) => handleMCP(ctx.args)],
]);
export default COMMAND_REGISTRY;

View File

@@ -0,0 +1,28 @@
import { warningMessage, infoMessage } from "@utils/terminal";
import type { ChatState } from "@commands/components/chat/state";
import COMMAND_REGISTRY from "@commands/components/chat/commands/commandsRegistry";
const isValidCommand = (cmd: string): boolean => {
return COMMAND_REGISTRY.has(cmd);
};
export const handleCommand = async (
command: string,
state: ChatState,
cleanup: () => void,
): Promise<void> => {
const parts = command.slice(1).split(/\s+/);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
if (!isValidCommand(cmd)) {
warningMessage(`Unknown command: /${cmd}`);
infoMessage("Type /help for available commands");
return;
}
const handler = COMMAND_REGISTRY.get(cmd);
if (handler) {
await handler({ state, args, cleanup });
}
};

View File

@@ -0,0 +1,25 @@
import chalk from "chalk";
import { HELP_COMMANDS } from "@constants/help-commands.ts";
export const showHelp = (): void => {
console.log("\n" + chalk.bold.underline("Commands") + "\n");
for (const [cmd, desc] of HELP_COMMANDS) {
console.log(` ${chalk.yellow(cmd.padEnd(20))} ${desc}`);
}
console.log("\n" + chalk.bold.underline("File References") + "\n");
console.log(` ${chalk.yellow("@<file>")} Add a file to context`);
console.log(
` ${chalk.yellow('@"file with spaces"')} Add file with spaces in name`,
);
console.log(
` ${chalk.yellow("@src/*.ts")} Add files matching glob pattern`,
);
console.log("\n" + chalk.bold.underline("Examples") + "\n");
console.log(" @src/app.ts explain this code");
console.log(" @src/utils.ts @src/types.ts refactor these files");
console.log(" /model gpt-4o");
console.log(" /provider copilot\n");
};

View File

@@ -0,0 +1,35 @@
import { resolve } from "path";
import { existsSync } from "fs";
import fg from "fast-glob";
import { errorMessage, warningMessage } from "@utils/terminal";
import { loadFile } from "@commands/components/chat/context/load-file";
import { IGNORE_FOLDERS } from "@constants/paths";
export const addContextFile = async (
pattern: string,
contextFiles: Map<string, string>,
): Promise<void> => {
try {
const paths = await fg(pattern, {
cwd: process.cwd(),
absolute: true,
ignore: IGNORE_FOLDERS,
});
if (paths.length === 0) {
const absolutePath = resolve(process.cwd(), pattern);
if (existsSync(absolutePath)) {
await loadFile(absolutePath, contextFiles);
} else {
warningMessage(`File not found: ${pattern}`);
}
return;
}
for (const filePath of paths) {
await loadFile(filePath, contextFiles);
}
} catch (error) {
errorMessage(`Failed to add file: ${error}`);
}
};

View File

@@ -0,0 +1,32 @@
import { readFile, stat } from "fs/promises";
import { basename } from "path";
import { warningMessage, successMessage, errorMessage } from "@utils/terminal";
import { addContextFile } from "@services/session";
export const loadFile = async (
filePath: string,
contextFiles: Map<string, string>,
): Promise<void> => {
try {
const stats = await stat(filePath);
if (stats.isDirectory()) {
warningMessage(`Skipping directory: ${filePath}`);
return;
}
if (stats.size > 100 * 1024) {
warningMessage(`File too large (>100KB): ${basename(filePath)}`);
return;
}
const content = await readFile(filePath, "utf-8");
contextFiles.set(filePath, content);
successMessage(
`Added: ${basename(filePath)} (${content.split("\n").length} lines)`,
);
await addContextFile(filePath);
} catch (error) {
errorMessage(`Failed to read file: ${error}`);
}
};

View File

@@ -0,0 +1,27 @@
import { FILE_REFERENCE_PATTERN } from "@constants/patterns";
import { addContextFile } from "@commands/components/chat/context/add-context-file";
export const processFileReferences = async (
input: string,
contextFiles: Map<string, string>,
): Promise<string> => {
const pattern = new RegExp(FILE_REFERENCE_PATTERN.source, "g");
let match;
const filesToAdd: string[] = [];
while ((match = pattern.exec(input)) !== null) {
const filePath = match[1] || match[2] || match[3];
filesToAdd.push(filePath);
}
for (const filePath of filesToAdd) {
await addContextFile(filePath, contextFiles);
}
const textOnly = input.replace(pattern, "").trim();
if (!textOnly && filesToAdd.length > 0) {
return `Analyze the files I've added to the context.`;
}
return input;
};

View File

@@ -0,0 +1,22 @@
import { basename } from "path";
import { warningMessage, successMessage } from "@utils/terminal";
export const removeFile = (
filename: string,
contextFiles: Map<string, string>,
): void => {
if (!filename) {
warningMessage("Please specify a file to remove");
return;
}
for (const [path] of contextFiles) {
if (path.includes(filename) || basename(path) === filename) {
contextFiles.delete(path);
successMessage(`Removed: ${basename(path)}`);
return;
}
}
warningMessage(`File not found in context: ${filename}`);
};

View File

@@ -0,0 +1,32 @@
import chalk from "chalk";
import { basename } from "path";
import { getCurrentSession } from "@services/session";
import { infoMessage, filePath } from "@utils/terminal";
export const showContextFiles = (contextFiles: Map<string, string>): void => {
const session = getCurrentSession();
const files = session?.contextFiles || [];
if (files.length === 0 && contextFiles.size === 0) {
infoMessage("No context files loaded");
return;
}
console.log("\n" + chalk.bold("Context Files:"));
if (contextFiles.size > 0) {
console.log(chalk.gray(" Pending (will be included in next message):"));
for (const [path] of contextFiles) {
console.log(` - ${filePath(basename(path))}`);
}
}
if (files.length > 0) {
console.log(chalk.gray(" In session:"));
files.forEach((file, index) => {
console.log(` ${index + 1}. ${filePath(file)}`);
});
}
console.log();
};

View File

@@ -0,0 +1,10 @@
import { clearMessages } from "@services/session";
import { successMessage } from "@utils/terminal";
import type { ChatState } from "@commands/components/chat/state";
export const clearConversation = (state: ChatState): void => {
state.messages = [{ role: "system", content: state.systemPrompt }];
state.contextFiles.clear();
clearMessages();
successMessage("Conversation cleared");
};

View File

@@ -0,0 +1,15 @@
import { successMessage, infoMessage } from "@utils/terminal";
import type { ChatState } from "@commands/components/chat/state";
export const compactHistory = (state: ChatState): void => {
if (state.messages.length <= 11) {
infoMessage("History is already compact");
return;
}
const systemPrompt = state.messages[0];
const recentMessages = state.messages.slice(-10);
state.messages = [systemPrompt, ...recentMessages];
successMessage(`Compacted to ${state.messages.length - 1} messages`);
};

View File

@@ -0,0 +1,18 @@
import chalk from "chalk";
import type { ChatState } from "@commands/components/chat/state";
export const showContext = (state: ChatState): void => {
const messageCount = state.messages.length - 1;
const totalChars = state.messages.reduce(
(acc, m) => acc + m.content.length,
0,
);
const estimatedTokens = Math.round(totalChars / 4);
console.log("\n" + chalk.bold("Context Information:"));
console.log(` Messages: ${messageCount}`);
console.log(` Characters: ${totalChars.toLocaleString()}`);
console.log(` Estimated tokens: ~${estimatedTokens.toLocaleString()}`);
console.log(` Pending files: ${state.contextFiles.size}`);
console.log();
};

View File

@@ -0,0 +1,18 @@
import chalk from "chalk";
import type { ChatState } from "@commands/components/chat/state";
export const showHistory = (state: ChatState): void => {
console.log("\n" + chalk.bold("Conversation History:") + "\n");
for (let i = 1; i < state.messages.length; i++) {
const msg = state.messages[i];
const role =
msg.role === "user" ? chalk.cyan("You") : chalk.green("Assistant");
const preview = msg.content.slice(0, 100).replace(/\n/g, " ");
console.log(
` ${i}. ${role}: ${preview}${msg.content.length > 100 ? "..." : ""}`,
);
}
console.log();
};

View File

@@ -0,0 +1,224 @@
import chalk from "chalk";
import { infoMessage, errorMessage, warningMessage } from "@utils/terminal";
import {
createSession,
loadSession,
getMostRecentSession,
findSession,
setWorkingDirectory,
} from "@services/session";
import { getConfig } from "@services/config";
import type { Provider as ProviderName, ChatSession } from "@/types/index";
import { getProvider, getProviderStatus } from "@providers/index.ts";
import {
printWelcome,
formatTipLine,
Style,
Theme,
createInputEditor,
} from "@ui/index";
import {
DEFAULT_SYSTEM_PROMPT,
buildSystemPromptWithRules,
} from "@prompts/index.ts";
import type { ChatOptions } from "@interfaces/ChatOptions.ts";
import { createInitialState, type ChatState } from "./state.ts";
import { restoreMessagesFromSession } from "./session/restore-messages.ts";
import { addContextFile } from "./context/add-context-file.ts";
import { handleCommand } from "./commands/handle-command.ts";
import { handleInput } from "./messages/handle-input.ts";
import { executePrintMode } from "./print-mode.ts";
import { createCleanup } from "./cleanup.ts";
export const execute = async (options: ChatOptions): Promise<void> => {
const config = await getConfig();
const state = createInitialState(
(options.provider || config.get("provider")) as ProviderName,
);
state.verbose = options.verbose || false;
state.autoApprove = options.autoApprove || false;
state.currentModel = options.model || config.get("model") || "auto";
const status = await getProviderStatus(state.currentProvider);
if (!status.valid) {
errorMessage(`Provider ${state.currentProvider} is not configured.`);
infoMessage(`Run: codetyper login ${state.currentProvider}`);
process.exit(1);
}
if (options.systemPrompt) {
state.systemPrompt = options.systemPrompt;
} else {
const { prompt: promptWithRules, rulesPaths } =
await buildSystemPromptWithRules(DEFAULT_SYSTEM_PROMPT, process.cwd());
state.systemPrompt = promptWithRules;
if (rulesPaths.length > 0 && state.verbose) {
infoMessage(`Loaded ${rulesPaths.length} rule file(s):`);
for (const rulePath of rulesPaths) {
infoMessage(` - ${rulePath}`);
}
}
if (options.appendSystemPrompt) {
state.systemPrompt =
state.systemPrompt + "\n\n" + options.appendSystemPrompt;
}
}
let session: ChatSession;
if (options.continueSession) {
const recent = await getMostRecentSession(process.cwd());
if (recent) {
session = recent;
await loadSession(session.id);
state.messages = restoreMessagesFromSession(session, state.systemPrompt);
if (state.verbose) {
infoMessage(`Continuing session: ${session.id}`);
}
} else {
warningMessage(
"No previous session found in this directory. Starting new session.",
);
session = await createSession("coder");
}
} else if (options.resumeSession) {
const found = await findSession(options.resumeSession);
if (found) {
session = found;
await loadSession(session.id);
state.messages = restoreMessagesFromSession(session, state.systemPrompt);
if (state.verbose) {
infoMessage(`Resumed session: ${session.id}`);
}
} else {
errorMessage(`Session not found: ${options.resumeSession}`);
process.exit(1);
}
} else {
session = await createSession("coder");
await setWorkingDirectory(process.cwd());
}
if (state.messages.length === 0) {
state.messages = [{ role: "system", content: state.systemPrompt }];
}
if (options.files && options.files.length > 0) {
for (const file of options.files) {
await addContextFile(file, state.contextFiles);
}
}
if (options.printMode && options.initialPrompt) {
await executePrintMode(options.initialPrompt, state);
return;
}
const hasInitialPrompt =
options.initialPrompt && options.initialPrompt.trim().length > 0;
const provider = getProvider(state.currentProvider);
const model = state.currentModel || "auto";
printWelcome("0.1.0", provider.displayName, model);
console.log(
Theme.textMuted +
" Session: " +
Style.RESET +
chalk.gray(session.id.slice(0, 16) + "..."),
);
console.log("");
console.log(
Theme.textMuted +
" Commands: " +
Style.RESET +
chalk.cyan("@file") +
" " +
chalk.cyan("/help") +
" " +
chalk.cyan("/clear") +
" " +
chalk.cyan("/exit"),
);
console.log(
Theme.textMuted +
" Input: " +
Style.RESET +
chalk.cyan("Enter") +
" to send, " +
chalk.cyan("Alt+Enter") +
" for newline",
);
console.log("");
console.log(" " + formatTipLine());
console.log("");
state.inputEditor = createInputEditor({
prompt: "\x1b[36m> \x1b[0m",
continuationPrompt: "\x1b[90m│ \x1b[0m",
});
state.isRunning = true;
const cleanup = createCleanup(state);
const commandHandler = async (command: string, st: ChatState) => {
await handleCommand(command, st, cleanup);
};
state.inputEditor.on("submit", async (input: string) => {
if (state.isProcessing) return;
state.isProcessing = true;
state.inputEditor?.lock();
try {
await handleInput(input, state, commandHandler);
} catch (error) {
errorMessage(`Error: ${error}`);
}
state.isProcessing = false;
if (state.isRunning && state.inputEditor) {
state.inputEditor.unlock();
}
});
state.inputEditor.on("interrupt", () => {
if (state.isProcessing) {
console.log("\n" + chalk.yellow("Interrupted"));
state.isProcessing = false;
state.inputEditor?.unlock();
} else {
cleanup();
}
});
state.inputEditor.on("close", () => {
cleanup();
});
state.inputEditor.start();
if (hasInitialPrompt) {
state.isProcessing = true;
state.inputEditor.lock();
console.log(chalk.cyan("> ") + options.initialPrompt);
try {
await handleInput(options.initialPrompt!, state, commandHandler);
} catch (error) {
errorMessage(`Error: ${error}`);
}
state.isProcessing = false;
if (state.isRunning && state.inputEditor) {
state.inputEditor.unlock();
}
}
};
export { createInitialState, type ChatState } from "./state.ts";

View File

@@ -0,0 +1,143 @@
/**
* Handle MCP commands in chat
*/
import chalk from "chalk";
import {
initializeMCP,
connectServer,
disconnectServer,
connectAllServers,
disconnectAllServers,
getAllTools,
} from "@services/mcp/index";
import { showMCPStatus } from "@commands/components/chat/mcp/show-mcp-status";
import { appStore } from "@tui-solid/context/app";
/**
* Handle MCP subcommands
*/
export const handleMCP = async (args: string[]): Promise<void> => {
const subcommand = args[0] || "status";
const handlers: Record<string, (args: string[]) => Promise<void>> = {
status: handleStatus,
connect: handleConnect,
disconnect: handleDisconnect,
tools: handleTools,
add: handleAdd,
};
const handler = handlers[subcommand];
if (!handler) {
console.log(chalk.yellow(`Unknown MCP command: ${subcommand}`));
console.log(
chalk.gray("Available: status, connect, disconnect, tools, add"),
);
return;
}
await handler(args.slice(1));
};
/**
* Show MCP status
*/
const handleStatus = async (_args: string[]): Promise<void> => {
await showMCPStatus();
};
/**
* Connect to MCP servers
*/
const handleConnect = async (args: string[]): Promise<void> => {
await initializeMCP();
const name = args[0];
if (name) {
try {
console.log(chalk.gray(`Connecting to ${name}...`));
const instance = await connectServer(name);
console.log(chalk.green(`✓ Connected to ${name}`));
console.log(chalk.gray(` Tools: ${instance.tools.length}`));
} catch (err) {
console.log(chalk.red(`✗ Failed to connect: ${err}`));
}
} else {
console.log(chalk.gray("Connecting to all servers..."));
const results = await connectAllServers();
for (const [serverName, instance] of results) {
if (instance.state === "connected") {
console.log(
chalk.green(`${serverName}: ${instance.tools.length} tools`),
);
} else {
console.log(
chalk.red(`${serverName}: ${instance.error || "Failed"}`),
);
}
}
}
console.log();
};
/**
* Disconnect from MCP servers
*/
const handleDisconnect = async (args: string[]): Promise<void> => {
const name = args[0];
if (name) {
await disconnectServer(name);
console.log(chalk.green(`✓ Disconnected from ${name}`));
} else {
await disconnectAllServers();
console.log(chalk.green("✓ Disconnected from all servers"));
}
console.log();
};
/**
* List available MCP tools
*/
const handleTools = async (_args: string[]): Promise<void> => {
await connectAllServers();
const tools = getAllTools();
if (tools.length === 0) {
console.log(chalk.yellow("\nNo tools available."));
console.log(chalk.gray("Connect to MCP servers first with /mcp connect"));
console.log();
return;
}
console.log(chalk.bold("\nMCP Tools\n"));
// Group by server
const byServer = new Map<string, typeof tools>();
for (const item of tools) {
const existing = byServer.get(item.server) || [];
existing.push(item);
byServer.set(item.server, existing);
}
for (const [server, serverTools] of byServer) {
console.log(chalk.cyan(`${server}:`));
for (const { tool } of serverTools) {
console.log(` ${chalk.white(tool.name)}`);
if (tool.description) {
console.log(` ${chalk.gray(tool.description)}`);
}
}
console.log();
}
};
/**
* Open the MCP add form
*/
const handleAdd = async (_args: string[]): Promise<void> => {
appStore.setMode("mcp_add");
};

View File

@@ -0,0 +1,6 @@
/**
* MCP chat commands
*/
export { showMCPStatus } from "@commands/components/chat/mcp/show-mcp-status";
export { handleMCP } from "@commands/components/chat/mcp/handle-mcp";

View File

@@ -0,0 +1,76 @@
/**
* Show MCP server status in chat
*/
import chalk from "chalk";
import {
initializeMCP,
getServerInstances,
getAllTools,
isMCPAvailable,
} from "@services/mcp/index";
/**
* Display MCP server status
*/
export const showMCPStatus = async (): Promise<void> => {
await initializeMCP();
const hasServers = await isMCPAvailable();
if (!hasServers) {
console.log(chalk.yellow("\nNo MCP servers configured."));
console.log(chalk.gray("Add a server with: codetyper mcp add <name>"));
console.log();
return;
}
const instances = getServerInstances();
const tools = getAllTools();
console.log(chalk.bold("\nMCP Status\n"));
// Server status
console.log(chalk.cyan("Servers:"));
for (const [name, instance] of instances) {
const stateColors: Record<string, (s: string) => string> = {
connected: chalk.green,
connecting: chalk.yellow,
disconnected: chalk.gray,
error: chalk.red,
};
const colorFn = stateColors[instance.state] || chalk.white;
const status = colorFn(instance.state);
const toolCount =
instance.state === "connected" ? ` (${instance.tools.length} tools)` : "";
console.log(` ${chalk.white(name)}: ${status}${chalk.gray(toolCount)}`);
if (instance.error) {
console.log(` ${chalk.red(instance.error)}`);
}
}
// Tool summary
if (tools.length > 0) {
console.log();
console.log(chalk.cyan(`Available Tools: ${chalk.white(tools.length)}`));
// Group by server
const byServer = new Map<string, string[]>();
for (const { server, tool } of tools) {
const existing = byServer.get(server) || [];
existing.push(tool.name);
byServer.set(server, existing);
}
for (const [server, toolNames] of byServer) {
console.log(` ${chalk.gray(server)}: ${toolNames.join(", ")}`);
}
}
console.log();
console.log(chalk.gray("Use /mcp connect to connect servers"));
console.log(chalk.gray("Use /mcp tools for detailed tool info"));
console.log();
};

View File

@@ -0,0 +1,17 @@
import { processFileReferences } from "@commands/components/chat/context/process-file-references";
import { sendMessage } from "@commands/components/chat/messages/send-message";
import type { ChatState } from "@commands/components/chat/state";
export const handleInput = async (
input: string,
state: ChatState,
handleCommand: (command: string, state: ChatState) => Promise<void>,
): Promise<void> => {
if (input.startsWith("/")) {
await handleCommand(input, state);
return;
}
const processedInput = await processFileReferences(input, state.contextFiles);
await sendMessage(processedInput, state);
};

View File

@@ -0,0 +1,228 @@
import chalk from "chalk";
import { basename, extname } from "path";
import { addMessage } from "@services/session";
import { initializePermissions } from "@services/permissions";
import { createAgent } from "@services/agent";
import { infoMessage, errorMessage, warningMessage } from "@utils/terminal";
import { getThinkingMessage } from "@constants/status-messages";
import {
detectDebuggingRequest,
buildDebuggingContext,
getDebuggingPrompt,
} from "@services/debugging-service";
import {
detectCodeReviewRequest,
buildCodeReviewContext,
getCodeReviewPrompt,
} from "@services/code-review-service";
import {
detectRefactoringRequest,
buildRefactoringContext,
getRefactoringPrompt,
} from "@services/refactoring-service";
import {
detectMemoryCommand,
processMemoryCommand,
buildRelevantMemoryPrompt,
} from "@services/memory-service";
import type { ChatState } from "@commands/components/chat/state";
export const sendMessage = async (
content: string,
state: ChatState,
): Promise<void> => {
let userMessage = content;
if (state.contextFiles.size > 0) {
const contextParts: string[] = [];
for (const [path, fileContent] of state.contextFiles) {
const ext = extname(path).slice(1) || "txt";
contextParts.push(
`File: ${basename(path)}\n\`\`\`${ext}\n${fileContent}\n\`\`\``,
);
}
userMessage = contextParts.join("\n\n") + "\n\n" + content;
state.contextFiles.clear();
}
// Detect debugging requests and enhance message with context
const debugContext = detectDebuggingRequest(userMessage);
if (debugContext.isDebugging) {
const debugPrompt = getDebuggingPrompt();
const contextInfo = buildDebuggingContext(debugContext);
// Inject debugging system message before user message if not already present
const hasDebuggingPrompt = state.messages.some(
(msg) => msg.role === "system" && msg.content.includes("debugging mode"),
);
if (!hasDebuggingPrompt) {
state.messages.push({ role: "system", content: debugPrompt });
}
// Append debug context to user message if extracted
if (contextInfo) {
userMessage = userMessage + "\n\n" + contextInfo;
}
if (state.verbose) {
infoMessage(`Debugging mode activated: ${debugContext.debugType}`);
}
}
// Detect code review requests and enhance message with context
const reviewContext = detectCodeReviewRequest(userMessage);
if (reviewContext.isReview) {
const reviewPrompt = getCodeReviewPrompt();
const contextInfo = buildCodeReviewContext(reviewContext);
// Inject code review system message before user message if not already present
const hasReviewPrompt = state.messages.some(
(msg) =>
msg.role === "system" && msg.content.includes("code review mode"),
);
if (!hasReviewPrompt) {
state.messages.push({ role: "system", content: reviewPrompt });
}
// Append review context to user message if extracted
if (contextInfo) {
userMessage = userMessage + "\n\n" + contextInfo;
}
if (state.verbose) {
infoMessage(`Code review mode activated: ${reviewContext.reviewType}`);
}
}
// Detect refactoring requests and enhance message with context
const refactorContext = detectRefactoringRequest(userMessage);
if (refactorContext.isRefactoring) {
const refactorPrompt = getRefactoringPrompt();
const contextInfo = buildRefactoringContext(refactorContext);
// Inject refactoring system message before user message if not already present
const hasRefactoringPrompt = state.messages.some(
(msg) =>
msg.role === "system" && msg.content.includes("refactoring mode"),
);
if (!hasRefactoringPrompt) {
state.messages.push({ role: "system", content: refactorPrompt });
}
// Append refactoring context to user message if extracted
if (contextInfo) {
userMessage = userMessage + "\n\n" + contextInfo;
}
if (state.verbose) {
infoMessage(
`Refactoring mode activated: ${refactorContext.refactoringType}`,
);
}
}
// Detect memory commands
const memoryContext = detectMemoryCommand(userMessage);
if (memoryContext.isMemoryCommand) {
const result = await processMemoryCommand(memoryContext);
console.log(chalk.cyan("\n[Memory System]"));
console.log(
result.success
? chalk.green(result.message)
: chalk.yellow(result.message),
);
// For store/forget commands, still send to agent for confirmation response
if (
memoryContext.commandType === "list" ||
memoryContext.commandType === "query"
) {
// Just display results, don't send to agent
return;
}
if (state.verbose) {
infoMessage(`Memory command: ${memoryContext.commandType}`);
}
}
// Auto-retrieve relevant memories for context
const relevantMemoryPrompt = await buildRelevantMemoryPrompt(userMessage);
if (relevantMemoryPrompt) {
userMessage = userMessage + "\n\n" + relevantMemoryPrompt;
if (state.verbose) {
infoMessage("Relevant memories retrieved");
}
}
state.messages.push({ role: "user", content: userMessage });
await addMessage("user", content);
await initializePermissions();
const agent = createAgent(process.cwd(), {
provider: state.currentProvider,
model: state.currentModel,
verbose: state.verbose,
autoApprove: state.autoApprove,
onToolCall: (call) => {
console.log(chalk.cyan(`\n[Tool: ${call.name}]`));
if (state.verbose) {
console.log(chalk.gray(JSON.stringify(call.arguments, null, 2)));
}
},
onToolResult: (_callId, result) => {
if (result.success) {
console.log(chalk.green(`${result.title}`));
} else {
console.log(chalk.red(`${result.title}: ${result.error}`));
}
},
onText: (_text) => {},
});
process.stdout.write(chalk.gray(getThinkingMessage() + "\n"));
try {
const result = await agent.run(state.messages);
if (result.finalResponse) {
state.messages.push({
role: "assistant",
content: result.finalResponse,
});
await addMessage("assistant", result.finalResponse);
console.log(chalk.bold("\nAssistant:"));
console.log(result.finalResponse);
console.log();
if (result.toolCalls.length > 0) {
const successful = result.toolCalls.filter(
(tc) => tc.result.success,
).length;
infoMessage(
chalk.gray(
`Tools: ${successful}/${result.toolCalls.length} successful, ${result.iterations} iteration(s)`,
),
);
}
} else if (result.toolCalls.length > 0) {
const successful = result.toolCalls.filter(
(tc) => tc.result.success,
).length;
infoMessage(
chalk.gray(
`Completed: ${successful}/${result.toolCalls.length} tools successful`,
),
);
} else {
warningMessage("No response received");
}
} catch (error) {
errorMessage(`Failed: ${error}`);
}
};

View File

@@ -0,0 +1,31 @@
import chalk from "chalk";
import type { Provider as ProviderName } from "@/types/index";
import { getProvider } from "@providers/index.ts";
export const showModels = async (
currentProvider: ProviderName,
currentModel: string | undefined,
): Promise<void> => {
const provider = getProvider(currentProvider);
const models = await provider.getModels();
const activeModel = currentModel || "auto";
const isAutoSelected = activeModel === "auto";
console.log(`\n${chalk.bold(provider.displayName + " Models")}\n`);
// Show "auto" option first
const autoMarker = isAutoSelected ? chalk.cyan("→") : " ";
console.log(
`${autoMarker} ${chalk.cyan("auto")} - Let API choose the best model`,
);
for (const model of models) {
const isCurrent = model.id === activeModel;
const marker = isCurrent ? chalk.cyan("→") : " ";
console.log(`${marker} ${chalk.cyan(model.id)} - ${model.name}`);
}
console.log("\n" + chalk.gray("Use /model <name> to switch"));
console.log();
};

View File

@@ -0,0 +1,7 @@
import { getConfig } from "@services/config";
import { displayProvidersStatus } from "@providers/index.ts";
export const showProviders = async (): Promise<void> => {
const config = await getConfig();
await displayProvidersStatus(config.get("provider"));
};

View File

@@ -0,0 +1,50 @@
import {
infoMessage,
warningMessage,
successMessage,
errorMessage,
} from "@utils/terminal";
import { getConfig } from "@services/config";
import { getProvider } from "@providers/index.ts";
import { showModels } from "./show-models.ts";
import type { ChatState } from "../state.ts";
export const switchModel = async (
modelName: string,
state: ChatState,
): Promise<void> => {
if (!modelName) {
warningMessage("Please specify a model name");
await showModels(state.currentProvider, state.currentModel);
return;
}
// Handle "auto" as a special case
if (modelName.toLowerCase() === "auto") {
state.currentModel = "auto";
successMessage("Switched to model: auto (API will choose)");
const config = await getConfig();
config.set("model", "auto");
await config.save();
return;
}
const provider = getProvider(state.currentProvider);
const models = await provider.getModels();
const model = models.find((m) => m.id === modelName || m.name === modelName);
if (!model) {
errorMessage(`Model not found: ${modelName}`);
infoMessage("Use /models to see available models, or use 'auto'");
return;
}
state.currentModel = model.id;
successMessage(`Switched to model: ${model.name}`);
// Persist model selection to config
const config = await getConfig();
config.set("model", model.id);
await config.save();
};

View File

@@ -0,0 +1,51 @@
import type { Provider as ProviderName } from "@/types/index";
import {
errorMessage,
warningMessage,
infoMessage,
successMessage,
} from "@utils/terminal";
import { getConfig } from "@services/config";
import {
getProvider,
getProviderStatus,
getDefaultModel,
} from "@providers/index.ts";
import type { ChatState } from "../state.ts";
export const switchProvider = async (
providerName: string,
state: ChatState,
): Promise<void> => {
if (!providerName) {
warningMessage("Please specify a provider: copilot, or ollama");
return;
}
const validProviders = ["copilot", "ollama"];
if (!validProviders.includes(providerName)) {
errorMessage(`Invalid provider: ${providerName}`);
infoMessage("Valid providers: " + validProviders.join(", "));
return;
}
const status = await getProviderStatus(providerName as ProviderName);
if (!status.valid) {
errorMessage(`Provider ${providerName} is not configured`);
infoMessage(`Run: codetyper login ${providerName}`);
return;
}
state.currentProvider = providerName as ProviderName;
state.currentModel = undefined;
const config = await getConfig();
config.set("provider", providerName as ProviderName);
await config.save();
const provider = getProvider(state.currentProvider);
const model = getDefaultModel(state.currentProvider);
successMessage(`Switched to ${provider.displayName}`);
infoMessage(`Using model: ${model}`);
};

View File

@@ -0,0 +1,71 @@
import chalk from "chalk";
import { basename, extname } from "path";
import { initializePermissions } from "@services/permissions";
import { createAgent } from "@services/agent";
import type { ChatState } from "@commands/components/chat/state";
import { processFileReferences } from "@commands/components/chat/context/process-file-references";
export const executePrintMode = async (
prompt: string,
state: ChatState,
): Promise<void> => {
const processedPrompt = await processFileReferences(
prompt,
state.contextFiles,
);
let userMessage = processedPrompt;
if (state.contextFiles.size > 0) {
const contextParts: string[] = [];
for (const [path, fileContent] of state.contextFiles) {
const ext = extname(path).slice(1) || "txt";
contextParts.push(
`File: ${basename(path)}\n\`\`\`${ext}\n${fileContent}\n\`\`\``,
);
}
userMessage = contextParts.join("\n\n") + "\n\n" + processedPrompt;
}
state.messages.push({ role: "user", content: userMessage });
await initializePermissions();
const agent = createAgent(process.cwd(), {
provider: state.currentProvider,
model: state.currentModel,
verbose: state.verbose,
autoApprove: state.autoApprove,
onToolCall: (call) => {
console.error(chalk.cyan(`[Tool: ${call.name}]`));
},
onToolResult: (_callId, result) => {
if (result.success) {
console.error(chalk.green(`${result.title}`));
} else {
console.error(chalk.red(`${result.title}: ${result.error}`));
}
},
});
try {
const result = await agent.run(state.messages);
if (result.finalResponse) {
console.log(result.finalResponse);
}
if (state.verbose && result.toolCalls.length > 0) {
const successful = result.toolCalls.filter(
(tc) => tc.result.success,
).length;
console.error(
chalk.gray(
`[Tools: ${successful}/${result.toolCalls.length} successful, ${result.iterations} iteration(s)]`,
),
);
}
} catch (error) {
console.error(chalk.red(`Error: ${error}`));
process.exit(1);
}
};

View File

@@ -0,0 +1,38 @@
import chalk from "chalk";
import { getSessionSummaries } from "@services/session";
import { infoMessage } from "@utils/terminal";
export const listSessions = async (): Promise<void> => {
const summaries = await getSessionSummaries();
if (summaries.length === 0) {
infoMessage("No saved sessions");
return;
}
console.log("\n" + chalk.bold("Saved Sessions:") + "\n");
for (const session of summaries.slice(0, 10)) {
const date = new Date(session.updatedAt).toLocaleDateString();
const time = new Date(session.updatedAt).toLocaleTimeString();
const preview = session.lastMessage
? session.lastMessage.slice(0, 50).replace(/\n/g, " ")
: "(no messages)";
console.log(` ${chalk.cyan(session.id.slice(0, 20))}...`);
console.log(
` ${chalk.gray(`${date} ${time}`)} - ${session.messageCount} messages`,
);
console.log(
` ${chalk.gray(preview)}${preview.length >= 50 ? "..." : ""}`,
);
console.log();
}
if (summaries.length > 10) {
infoMessage(`... and ${summaries.length - 10} more sessions`);
}
console.log(chalk.gray("Resume with: codetyper -r <session-id>"));
console.log();
};

View File

@@ -0,0 +1,20 @@
import type { ChatSession } from "@/types/index";
import type { Message } from "@providers/index.ts";
export const restoreMessagesFromSession = (
session: ChatSession,
systemPrompt: string,
): Message[] => {
const messages: Message[] = [{ role: "system", content: systemPrompt }];
for (const msg of session.messages) {
if (msg.role !== "system") {
messages.push({
role: msg.role as "user" | "assistant",
content: msg.content,
});
}
}
return messages;
};

View File

@@ -0,0 +1,20 @@
import chalk from "chalk";
import { getCurrentSession } from "@services/session";
import { warningMessage } from "@utils/terminal";
export const showSessionInfo = async (): Promise<void> => {
const session = getCurrentSession();
if (!session) {
warningMessage("No active session");
return;
}
console.log("\n" + chalk.bold("Session Information:"));
console.log(` ID: ${chalk.cyan(session.id)}`);
console.log(` Agent: ${session.agent}`);
console.log(` Messages: ${session.messages.length}`);
console.log(` Context files: ${session.contextFiles.length}`);
console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`);
console.log(` Updated: ${new Date(session.updatedAt).toLocaleString()}`);
console.log();
};

View File

@@ -0,0 +1,34 @@
import type { Provider as ProviderName } from "@/types/index";
import type { Message } from "@providers/index";
import type { InputEditorInstance } from "@ui/index";
import { DEFAULT_SYSTEM_PROMPT } from "@prompts/index";
export interface ChatState {
inputEditor: InputEditorInstance | null;
isRunning: boolean;
isProcessing: boolean;
currentProvider: ProviderName;
currentModel: string | undefined;
currentAgent: string | undefined;
messages: Message[];
contextFiles: Map<string, string>;
systemPrompt: string;
verbose: boolean;
autoApprove: boolean;
}
export const createInitialState = (
provider: ProviderName = "copilot",
): ChatState => ({
inputEditor: null,
isRunning: false,
isProcessing: false,
currentProvider: provider,
currentModel: undefined,
currentAgent: "coder",
messages: [],
contextFiles: new Map(),
systemPrompt: DEFAULT_SYSTEM_PROMPT,
verbose: false,
autoApprove: false,
});

View File

@@ -0,0 +1,129 @@
/**
* Show usage statistics command
*/
import chalk from "chalk";
import { usageStore } from "@stores/usage-store";
import { getUserInfo } from "@providers/copilot/credentials";
import { getCopilotUsage } from "@providers/copilot/usage";
import { getProvider } from "@providers/index";
import { renderUsageBar, renderUnlimitedBar } from "@utils/progress-bar";
import type { ChatState } from "@commands/components/chat/state";
import type { CopilotQuotaDetail } from "@/types/copilot-usage";
const formatNumber = (num: number): string => {
return num.toLocaleString();
};
const formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
};
const printQuotaBar = (
name: string,
quota: CopilotQuotaDetail | undefined,
resetInfo?: string,
): void => {
if (!quota) {
console.log(chalk.bold(name));
console.log(chalk.gray("N/A"));
console.log();
return;
}
if (quota.unlimited) {
renderUnlimitedBar(name).forEach((line) => console.log(line));
console.log();
return;
}
const used = quota.entitlement - quota.remaining;
renderUsageBar(name, used, quota.entitlement, resetInfo).forEach((line) =>
console.log(line),
);
console.log();
};
export const showUsage = async (state: ChatState): Promise<void> => {
const stats = usageStore.getStats();
const provider = getProvider(state.currentProvider);
const sessionDuration = Date.now() - stats.sessionStartTime;
console.log();
// User info and quota for Copilot
if (state.currentProvider === "copilot") {
const userInfo = await getUserInfo();
const copilotUsage = await getCopilotUsage();
if (copilotUsage) {
const resetDate = copilotUsage.quota_reset_date;
printQuotaBar(
"Premium Requests",
copilotUsage.quota_snapshots.premium_interactions,
`Resets ${resetDate}`,
);
printQuotaBar(
"Chat",
copilotUsage.quota_snapshots.chat,
`Resets ${resetDate}`,
);
printQuotaBar(
"Completions",
copilotUsage.quota_snapshots.completions,
`Resets ${resetDate}`,
);
}
console.log(chalk.bold("Account"));
console.log(`${chalk.gray("Provider:")} ${provider.displayName}`);
console.log(`${chalk.gray("Model:")} ${state.currentModel ?? "auto"}`);
if (userInfo) {
console.log(`${chalk.gray("User:")} ${userInfo.login}`);
}
if (copilotUsage) {
console.log(`${chalk.gray("Plan:")} ${copilotUsage.copilot_plan}`);
}
console.log();
} else {
console.log(chalk.bold("Provider"));
console.log(`${chalk.gray("Name:")} ${provider.displayName}`);
console.log(`${chalk.gray("Model:")} ${state.currentModel ?? "auto"}`);
console.log();
}
// Session stats with bar
console.log(chalk.bold("Current Session"));
renderUsageBar(
"Tokens",
stats.totalTokens,
stats.totalTokens || 1,
`${formatNumber(stats.promptTokens)} prompt + ${formatNumber(stats.completionTokens)} completion`,
)
.slice(1)
.forEach((line) => console.log(line));
console.log(`${chalk.gray("Requests:")} ${formatNumber(stats.requestCount)}`);
console.log(`${chalk.gray("Duration:")} ${formatDuration(sessionDuration)}`);
if (stats.requestCount > 0) {
const avgTokensPerRequest = Math.round(
stats.totalTokens / stats.requestCount,
);
console.log(
`${chalk.gray("Avg tokens/request:")} ${formatNumber(avgTokensPerRequest)}`,
);
}
console.log();
};

View File

@@ -0,0 +1,25 @@
/**
* Dashboard Config Builder
*/
import os from "os";
import { getConfig } from "@services/config";
import { DASHBOARD_TITLE } from "@constants/dashboard";
import type { DashboardConfig } from "@/types/dashboard";
export const buildDashboardConfig = async (
version: string,
): Promise<DashboardConfig> => {
const configMgr = await getConfig();
const username = os.userInfo().username;
const cwd = process.cwd();
const provider = configMgr.get("provider") as string;
return {
title: DASHBOARD_TITLE,
version,
user: username,
cwd,
provider,
};
};

View File

@@ -0,0 +1,33 @@
/**
* Dashboard Display
*
* Renders and displays the main dashboard UI.
*/
import { DASHBOARD_LAYOUT } from "@constants/dashboard";
import { buildDashboardConfig } from "@commands/components/dashboard/build-config";
import { renderHeader } from "@commands/components/dashboard/render-header";
import { renderContent } from "@commands/components/dashboard/render-content";
import { renderFooter } from "@commands/components/dashboard/render-footer";
const getTerminalWidth = (): number => {
return process.stdout.columns || DASHBOARD_LAYOUT.DEFAULT_WIDTH;
};
const renderDashboard = async (version: string): Promise<string> => {
const config = await buildDashboardConfig(version);
const width = getTerminalWidth();
const header = renderHeader(config, width);
const content = renderContent(config, width, DASHBOARD_LAYOUT.CONTENT_HEIGHT);
const footer = renderFooter(width);
return [header, content, footer].join("\n");
};
export const displayDashboard = async (version: string): Promise<void> => {
console.clear();
const dashboard = await renderDashboard(version);
console.log(dashboard);
process.exit(0);
};

View File

@@ -0,0 +1,41 @@
/**
* Dashboard Content Renderer
*/
import { DASHBOARD_LAYOUT, DASHBOARD_BORDER } from "@constants/dashboard";
import { renderLeftContent } from "@commands/components/dashboard/render-left-content";
import { renderRightContent } from "@commands/components/dashboard/render-right-content";
import type { DashboardConfig } from "@/types/dashboard";
const padContent = (content: string[], height: number): string[] => {
const padded = [...content];
while (padded.length < height) {
padded.push("");
}
return padded;
};
export const renderContent = (
config: DashboardConfig,
width: number,
height: number,
): string => {
const dividerPos = Math.floor(width * DASHBOARD_LAYOUT.LEFT_COLUMN_RATIO);
const leftWidth = dividerPos - DASHBOARD_LAYOUT.PADDING;
const rightWidth = width - dividerPos - DASHBOARD_LAYOUT.PADDING;
const leftContent = padContent(renderLeftContent(config), height);
const rightContent = padContent(renderRightContent(), height);
const lines: string[] = [];
for (let i = 0; i < height; i++) {
const left = (leftContent[i] || "").padEnd(leftWidth);
const right = (rightContent[i] || "").padEnd(rightWidth);
lines.push(
`${DASHBOARD_BORDER.VERTICAL} ${left} ${DASHBOARD_BORDER.VERTICAL} ${right} ${DASHBOARD_BORDER.VERTICAL}`,
);
}
return lines.join("\n");
};

View File

@@ -0,0 +1,31 @@
/**
* Dashboard Footer Renderer
*/
import chalk from "chalk";
import {
DASHBOARD_BORDER,
DASHBOARD_QUICK_COMMANDS,
} from "@constants/dashboard";
export const renderFooter = (width: number): string => {
const dashCount = Math.max(0, width - 2);
const dashes = DASHBOARD_BORDER.HORIZONTAL.repeat(dashCount);
const borderLine = `${DASHBOARD_BORDER.BOTTOM_LEFT}${dashes}${DASHBOARD_BORDER.BOTTOM_RIGHT}`;
const commandLines = DASHBOARD_QUICK_COMMANDS.map(
({ command, description }) =>
` ${chalk.cyan(command.padEnd(18))} ${description}`,
);
const lines = [
borderLine,
"",
chalk.dim("Quick Commands:"),
...commandLines,
"",
chalk.dim("Press Ctrl+C to exit • Type 'codetyper chat' to begin"),
];
return lines.join("\n");
};

View File

@@ -0,0 +1,18 @@
/**
* Dashboard Header Renderer
*/
import chalk from "chalk";
import { DASHBOARD_BORDER } from "@constants/dashboard";
import type { DashboardConfig } from "@/types/dashboard";
export const renderHeader = (
config: DashboardConfig,
width: number,
): string => {
const title = ` ${config.title} ${config.version} `;
const dashCount = Math.max(0, width - title.length - 2);
const dashes = DASHBOARD_BORDER.HORIZONTAL.repeat(dashCount);
return `${DASHBOARD_BORDER.TOP_LEFT}${DASHBOARD_BORDER.HORIZONTAL}${DASHBOARD_BORDER.HORIZONTAL}${DASHBOARD_BORDER.HORIZONTAL} ${chalk.cyan.bold(title)}${dashes}${DASHBOARD_BORDER.TOP_RIGHT}`;
};

View File

@@ -0,0 +1,24 @@
/**
* Dashboard Left Content Renderer
*/
import chalk from "chalk";
import { DASHBOARD_LOGO } from "@constants/dashboard";
import type { DashboardConfig } from "@/types/dashboard";
export const renderLeftContent = (config: DashboardConfig): string[] => {
const lines: string[] = [];
lines.push("");
lines.push(chalk.green(`Welcome back ${config.user}!`));
lines.push("");
const coloredLogo = DASHBOARD_LOGO.map((line) => chalk.cyan.bold(line));
lines.push(...coloredLogo);
lines.push("");
lines.push(chalk.cyan.bold(`${config.provider.toUpperCase()}`));
lines.push(chalk.dim(`${config.user}@codetyper`));
return lines;
};

View File

@@ -0,0 +1,21 @@
/**
* Dashboard Right Content Renderer
*/
import chalk from "chalk";
import { DASHBOARD_COMMANDS } from "@constants/dashboard";
export const renderRightContent = (): string[] => {
const lines: string[] = [];
lines.push(chalk.bold("Ready to code"));
lines.push("");
for (const { command, description } of DASHBOARD_COMMANDS) {
lines.push(chalk.cyan(command));
lines.push(` ${description}`);
lines.push("");
}
return lines;
};

View File

@@ -0,0 +1,55 @@
import { tui } from "@tui-solid/index";
import { getProviderInfo } from "@services/chat-tui-service";
import type { ChatServiceState } from "@services/chat-tui-service";
import type { AgentConfig } from "@/types/agent-config";
import type { PermissionScope, LearningScope } from "@/types/tui";
export interface RenderAppSolidProps {
sessionId: string;
handleSubmit: (message: string) => Promise<void>;
handleCommand: (command: string) => Promise<void>;
handleModelSelect: (model: string) => Promise<void>;
handleAgentSelect: (agentId: string, agent: AgentConfig) => Promise<void>;
handleThemeSelect: (theme: string) => void;
handlePermissionResponse?: (
allowed: boolean,
scope?: PermissionScope,
) => void;
handleLearningResponse?: (
save: boolean,
scope?: LearningScope,
editedContent?: string,
) => void;
handleExit: () => void;
showBanner: boolean;
state: ChatServiceState;
plan?: {
id: string;
title: string;
items: Array<{ id: string; text: string; completed: boolean }>;
} | null;
}
export const renderAppSolid = async (
props: RenderAppSolidProps,
): Promise<void> => {
const { displayName, model: defaultModel } = getProviderInfo(
props.state.provider,
);
const currentModel = props.state.model ?? defaultModel;
await tui({
sessionId: props.sessionId,
provider: displayName,
model: currentModel,
onSubmit: props.handleSubmit,
onCommand: props.handleCommand,
onModelSelect: props.handleModelSelect,
onThemeSelect: props.handleThemeSelect,
onPermissionResponse: props.handlePermissionResponse ?? (() => {}),
onLearningResponse: props.handleLearningResponse ?? (() => {}),
plan: props.plan,
});
props.handleExit();
};

View File

@@ -0,0 +1,104 @@
import { tui, appStore } from "@tui/index";
import { getProviderInfo } from "@services/chat-tui-service";
import { addServer, connectServer } from "@services/mcp/index";
import type { ChatServiceState } from "@services/chat-tui-service";
import type { AgentConfig } from "@/types/agent-config";
import type { PermissionScope, LearningScope } from "@/types/tui";
import type { ProviderModel } from "@/types/providers";
import type { MCPAddFormData } from "@/types/mcp";
interface AgentOption {
id: string;
name: string;
description?: string;
}
export interface RenderAppProps {
sessionId?: string;
handleSubmit: (message: string) => Promise<void>;
handleCommand: (command: string) => Promise<void>;
handleModelSelect: (model: string) => Promise<void>;
handleAgentSelect: (agentId: string, agent: AgentConfig) => Promise<void>;
handleThemeSelect: (theme: string) => void;
handleProviderSelect?: (providerId: string) => Promise<void>;
handleCascadeToggle?: (enabled: boolean) => Promise<void>;
handleMCPAdd?: (data: MCPAddFormData) => Promise<void>;
handlePermissionResponse?: (
allowed: boolean,
scope?: PermissionScope,
) => void;
handleLearningResponse?: (
save: boolean,
scope?: LearningScope,
editedContent?: string,
) => void;
handleExit: () => void;
showBanner: boolean;
state: ChatServiceState;
availableModels?: ProviderModel[];
agents?: AgentOption[];
initialPrompt?: string;
theme?: string;
cascadeEnabled?: boolean;
plan?: {
id: string;
title: string;
items: Array<{ id: string; text: string; completed: boolean }>;
} | null;
}
const defaultHandleMCPAdd = async (data: MCPAddFormData): Promise<void> => {
const serverArgs = data.args.trim()
? data.args.trim().split(/\s+/)
: undefined;
await addServer(
data.name,
{
command: data.command,
args: serverArgs,
enabled: true,
},
data.isGlobal,
);
await connectServer(data.name);
};
export const renderApp = async (props: RenderAppProps): Promise<void> => {
const { displayName, model: defaultModel } = getProviderInfo(
props.state.provider,
);
const currentModel = props.state.model ?? defaultModel;
await tui({
sessionId: props.sessionId,
provider: displayName,
model: currentModel,
theme: props.theme,
cascadeEnabled: props.cascadeEnabled,
availableModels: props.availableModels,
agents: props.agents,
initialPrompt: props.initialPrompt,
onSubmit: props.handleSubmit,
onCommand: props.handleCommand,
onModelSelect: props.handleModelSelect,
onThemeSelect: props.handleThemeSelect,
onProviderSelect: props.handleProviderSelect,
onCascadeToggle: props.handleCascadeToggle,
onAgentSelect: async (agentId: string) => {
const agent = props.agents?.find((a) => a.id === agentId);
if (agent) {
await props.handleAgentSelect(agentId, agent as AgentConfig);
}
},
onMCPAdd: props.handleMCPAdd ?? defaultHandleMCPAdd,
onPermissionResponse: props.handlePermissionResponse ?? (() => {}),
onLearningResponse: props.handleLearningResponse ?? (() => {}),
plan: props.plan,
});
props.handleExit();
};
export { appStore };

View File

@@ -0,0 +1,195 @@
import { renderApp, appStore } from "@commands/components/execute/execute";
import type { RenderAppProps } from "@commands/components/execute/execute";
import {
initializeChatService,
loadModels,
handleModelSelect as serviceHandleModelSelect,
executePrintMode,
setupPermissionHandler,
cleanupPermissionHandler,
executeCommand,
handleMessage,
} from "@services/chat-tui-service";
import type { ChatServiceState } from "@services/chat-tui-service";
import type { ChatTUIOptions } from "@interfaces/ChatTUIOptions";
import type { AgentConfig } from "@/types/agent-config";
import { getConfig } from "@services/config";
import { getThinkingMessage } from "@constants/status-messages";
import {
enterFullscreen,
registerExitHandlers,
exitFullscreen,
clearScreen,
} from "@utils/terminal";
import { createCallbacks } from "@commands/chat-tui";
import { agentLoader } from "@services/agent-loader";
interface ExecuteContext {
state: ChatServiceState | null;
}
const createHandleExit = (): (() => void) => (): void => {
cleanupPermissionHandler();
exitFullscreen();
clearScreen();
console.log("Goodbye!");
process.exit(0);
};
const createHandleModelSelect =
(ctx: ExecuteContext) =>
async (model: string): Promise<void> => {
if (!ctx.state) return;
await serviceHandleModelSelect(ctx.state, model, createCallbacks());
};
const createHandleAgentSelect =
(ctx: ExecuteContext) =>
async (agentId: string, agent: AgentConfig): Promise<void> => {
if (!ctx.state) return;
(ctx.state as ChatServiceState & { currentAgent?: string }).currentAgent =
agentId;
if (agent.prompt) {
const basePrompt = ctx.state.systemPrompt;
ctx.state.systemPrompt = `${agent.prompt}\n\n${basePrompt}`;
if (
ctx.state.messages.length > 0 &&
ctx.state.messages[0].role === "system"
) {
ctx.state.messages[0].content = ctx.state.systemPrompt;
}
}
};
const createHandleThemeSelect =
() =>
(themeName: string): void => {
getConfig().then((config) => {
config.set("theme", themeName);
config.save();
});
};
const createHandleProviderSelect =
(ctx: ExecuteContext) =>
async (providerId: string): Promise<void> => {
if (!ctx.state) return;
ctx.state.provider = providerId as "copilot" | "ollama";
const config = await getConfig();
config.set("provider", providerId as "copilot" | "ollama");
await config.save();
};
const createHandleCascadeToggle =
() =>
async (enabled: boolean): Promise<void> => {
const config = await getConfig();
config.set("cascadeEnabled", enabled);
await config.save();
};
const createHandleCommand =
(ctx: ExecuteContext, handleExit: () => void) =>
async (command: string): Promise<void> => {
if (!ctx.state) return;
if (["exit", "quit", "q"].includes(command.toLowerCase())) {
handleExit();
return;
}
await executeCommand(ctx.state, command, createCallbacks());
};
const createHandleSubmit =
(ctx: ExecuteContext, handleCommand: (command: string) => Promise<void>) =>
async (message: string): Promise<void> => {
if (!ctx.state) return;
if (message.startsWith("/")) {
const [command] = message.slice(1).split(/\s+/);
await handleCommand(command);
return;
}
// Set initial thinking message (streaming will update this)
appStore.setThinkingMessage(getThinkingMessage());
try {
await handleMessage(ctx.state, message, createCallbacks());
} finally {
// Clean up any remaining state after message handling
appStore.setThinkingMessage(null);
appStore.setCurrentToolCall(null);
appStore.setMode("idle");
}
};
const execute = async (options: ChatTUIOptions): Promise<void> => {
const ctx: ExecuteContext = {
state: null,
};
const { state, session } = await initializeChatService(options);
ctx.state = state;
if (options.printMode && options.initialPrompt) {
await executePrintMode(state, options.initialPrompt);
return;
}
setupPermissionHandler();
const models = await loadModels(state.provider);
const agents = await agentLoader.getAvailableAgents(process.cwd());
const config = await getConfig();
const savedTheme = config.get("theme");
// Register exit handlers to ensure terminal cleanup on abrupt termination
registerExitHandlers();
enterFullscreen();
const handleExit = createHandleExit();
const handleModelSelectFn = createHandleModelSelect(ctx);
const handleAgentSelectFn = createHandleAgentSelect(ctx);
const handleThemeSelectFn = createHandleThemeSelect();
const handleProviderSelectFn = createHandleProviderSelect(ctx);
const handleCascadeToggleFn = createHandleCascadeToggle();
const handleCommand = createHandleCommand(ctx, handleExit);
const handleSubmit = createHandleSubmit(ctx, handleCommand);
// Only pass sessionId if resuming/continuing - otherwise show Home view first
const isResuming = options.continueSession || options.resumeSession;
const savedCascadeEnabled = config.get("cascadeEnabled");
const renderProps: RenderAppProps = {
sessionId: isResuming ? session.id : undefined,
handleSubmit,
handleCommand,
handleModelSelect: handleModelSelectFn,
handleAgentSelect: handleAgentSelectFn,
handleThemeSelect: handleThemeSelectFn,
handleProviderSelect: handleProviderSelectFn,
handleCascadeToggle: handleCascadeToggleFn,
handleExit,
showBanner: true,
state,
availableModels: models,
agents: agents.map((a) => ({
id: a.id,
name: a.name,
description: a.description,
})),
initialPrompt: options.initialPrompt,
theme: savedTheme,
cascadeEnabled: savedCascadeEnabled ?? true,
};
await renderApp(renderProps);
};
export default execute;

View File

@@ -0,0 +1,8 @@
/**
* Dashboard Command
*
* Re-exports the modular dashboard implementation.
*/
export { displayDashboard } from "@commands/components/dashboard/display";
export type { DashboardConfig } from "@/types/dashboard";

25
src/commands/handlers.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Command handlers - Route commands to appropriate implementations
*/
import { errorMessage } from "@utils/terminal";
import { COMMAND_REGISTRY, isValidCommand } from "@commands/handlers/registry";
import type { CommandOptions } from "@/types/index";
export const handleCommand = async (
command: string,
options: CommandOptions,
): Promise<void> => {
try {
if (!isValidCommand(command)) {
errorMessage(`Unknown command: ${command}`);
process.exit(1);
}
const handler = COMMAND_REGISTRY[command];
await handler(options);
} catch (error) {
errorMessage(`Command failed: ${error}`);
throw error;
}
};

View File

@@ -0,0 +1,10 @@
/**
* Chat command handler
*/
import { execute as executeChat } from "@commands/chat";
import type { CommandOptions } from "@/types/index";
export const handleChat = async (options: CommandOptions): Promise<void> => {
await executeChat(options);
};

View File

@@ -0,0 +1,123 @@
/**
* Classify command handler
*/
import chalk from "chalk";
import {
succeedSpinner,
startSpinner,
errorMessage,
failSpinner,
headerMessage,
} from "@utils/terminal";
import {
INTENT_KEYWORDS,
CLASSIFICATION_CONFIDENCE,
} from "@constants/handlers";
import type {
CommandOptions,
IntentRequest,
IntentResponse,
} from "@/types/index";
const classifyIntent = async (
request: IntentRequest,
): Promise<IntentResponse> => {
await new Promise((resolve) => setTimeout(resolve, 1000));
const prompt = request.prompt.toLowerCase();
let intent: IntentResponse["intent"] = "ask";
let confidence: number = CLASSIFICATION_CONFIDENCE.DEFAULT;
const intentMatchers: Record<string, () => void> = {
fix: () => {
intent = "fix";
confidence = CLASSIFICATION_CONFIDENCE.HIGH;
},
test: () => {
intent = "test";
confidence = CLASSIFICATION_CONFIDENCE.MEDIUM;
},
refactor: () => {
intent = "refactor";
confidence = CLASSIFICATION_CONFIDENCE.LOW;
},
code: () => {
intent = "code";
confidence = CLASSIFICATION_CONFIDENCE.DEFAULT;
},
document: () => {
intent = "document";
confidence = CLASSIFICATION_CONFIDENCE.HIGH;
},
};
for (const [intentKey, keywords] of Object.entries(INTENT_KEYWORDS)) {
const hasMatch = keywords.some((keyword) => prompt.includes(keyword));
if (hasMatch) {
intentMatchers[intentKey]?.();
break;
}
}
return {
intent,
confidence,
reasoning: `Based on keywords in the prompt, this appears to be a ${intent} request.`,
needsClarification: confidence < CLASSIFICATION_CONFIDENCE.THRESHOLD,
clarificationQuestions:
confidence < CLASSIFICATION_CONFIDENCE.THRESHOLD
? [
"Which specific files should I focus on?",
"What is the expected outcome?",
]
: undefined,
};
};
export const handleClassify = async (
options: CommandOptions,
): Promise<void> => {
const { prompt, context, files = [] } = options;
if (!prompt) {
errorMessage("Prompt is required");
return;
}
headerMessage("Classifying Intent");
console.log(chalk.bold("Prompt:") + ` ${prompt}`);
if (context) {
console.log(chalk.bold("Context:") + ` ${context}`);
}
if (files.length > 0) {
console.log(chalk.bold("Files:") + ` ${files.join(", ")}`);
}
console.log();
startSpinner("Analyzing prompt...");
try {
const result = await classifyIntent({ prompt, context, files });
succeedSpinner("Analysis complete");
console.log();
console.log(chalk.bold("Intent:") + ` ${chalk.cyan(result.intent)}`);
console.log(
chalk.bold("Confidence:") +
` ${chalk.green((result.confidence * 100).toFixed(1) + "%")}`,
);
console.log(chalk.bold("Reasoning:") + ` ${result.reasoning}`);
if (result.needsClarification && result.clarificationQuestions) {
console.log();
console.log(chalk.yellow.bold("Clarification needed:"));
result.clarificationQuestions.forEach((q, i) => {
console.log(` ${i + 1}. ${q}`);
});
}
} catch (error) {
failSpinner("Classification failed");
throw error;
}
};

View File

@@ -0,0 +1,113 @@
/**
* Config command handler
*/
import {
errorMessage,
filePath,
successMessage,
hightLigthedJson,
headerMessage,
infoMessage,
} from "@utils/terminal";
import { getConfig } from "@services/config";
import {
VALID_CONFIG_KEYS,
VALID_PROVIDERS,
CONFIG_VALIDATION,
} from "@constants/handlers";
import type { CommandOptions, Provider } from "@/types/index";
import type { ConfigAction, ConfigKey } from "@/types/handlers";
type ConfigActionHandler = (key?: string, value?: string) => Promise<void>;
const showConfig = async (): Promise<void> => {
const config = await getConfig();
headerMessage("Configuration");
const allConfig = config.getAll();
hightLigthedJson(allConfig);
};
const showPath = async (): Promise<void> => {
const config = await getConfig();
const configPath = config.getConfigPath();
console.log(filePath(configPath));
};
const setConfigValue = async (key?: string, value?: string): Promise<void> => {
if (!key || value === undefined) {
errorMessage("Key and value are required");
return;
}
if (!VALID_CONFIG_KEYS.includes(key as ConfigKey)) {
errorMessage(`Invalid config key: ${key}`);
infoMessage(`Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`);
return;
}
const config = await getConfig();
const keySetters: Record<ConfigKey, () => boolean> = {
provider: () => {
if (!VALID_PROVIDERS.includes(value as Provider)) {
errorMessage(`Invalid provider: ${value}`);
infoMessage(`Valid providers: ${VALID_PROVIDERS.join(", ")}`);
return false;
}
config.set("provider", value as Provider);
return true;
},
model: () => {
config.set("model", value);
return true;
},
maxIterations: () => {
const num = parseInt(value, 10);
if (isNaN(num) || num < CONFIG_VALIDATION.MIN_ITERATIONS) {
errorMessage("maxIterations must be a positive number");
return false;
}
config.set("maxIterations", num);
return true;
},
timeout: () => {
const num = parseInt(value, 10);
if (isNaN(num) || num < CONFIG_VALIDATION.MIN_TIMEOUT_MS) {
errorMessage(
`timeout must be at least ${CONFIG_VALIDATION.MIN_TIMEOUT_MS}ms`,
);
return false;
}
config.set("timeout", num);
return true;
},
};
const setter = keySetters[key as ConfigKey];
const success = setter();
if (success) {
await config.save();
successMessage(`Set ${key} = ${value}`);
}
};
const CONFIG_ACTION_HANDLERS: Record<ConfigAction, ConfigActionHandler> = {
show: showConfig,
path: showPath,
set: setConfigValue,
};
export const handleConfig = async (options: CommandOptions): Promise<void> => {
const { action, key, value } = options;
const handler = CONFIG_ACTION_HANDLERS[action as ConfigAction];
if (!handler) {
errorMessage(`Unknown config action: ${action}`);
return;
}
await handler(key, value);
};

View File

@@ -0,0 +1,62 @@
/**
* Plan command handler
*/
import chalk from "chalk";
import {
hightLigthedJson,
filePath,
errorMessage,
failSpinner,
headerMessage,
startSpinner,
succeedSpinner,
successMessage,
} from "@utils/terminal";
import type { CommandOptions } from "@/types/index";
export const handlePlan = async (options: CommandOptions): Promise<void> => {
const { intent, task, files = [], output } = options;
if (!task) {
errorMessage("Task description is required");
return;
}
headerMessage("Generating Plan");
console.log(chalk.bold("Intent:") + ` ${chalk.cyan(intent || "unknown")}`);
console.log(chalk.bold("Task:") + ` ${task}`);
if (files.length > 0) {
console.log(chalk.bold("Files:") + ` ${files.join(", ")}`);
}
console.log();
startSpinner("Generating execution plan...");
try {
await new Promise((resolve) => setTimeout(resolve, 1500));
succeedSpinner("Plan generated");
const plan = {
intent,
task,
files,
steps: [
{ id: "step_1", type: "read", description: "Analyze existing code" },
{ id: "step_2", type: "edit", description: "Apply changes" },
{ id: "step_3", type: "execute", description: "Run tests" },
],
};
if (output) {
const fs = await import("fs/promises");
await fs.writeFile(output, JSON.stringify(plan, null, 2));
successMessage(`Plan saved to ${filePath(output)}`);
} else {
hightLigthedJson(plan);
}
} catch (error) {
failSpinner("Plan generation failed");
throw error;
}
};

View File

@@ -0,0 +1,28 @@
/**
* Command handler registry - object-based routing
*/
import { handleChat } from "@commands/handlers/chat";
import { handleRun } from "@commands/handlers/run";
import { handleClassify } from "@commands/handlers/classify";
import { handlePlan } from "@commands/handlers/plan";
import { handleValidate } from "@commands/handlers/validate";
import { handleConfig } from "@commands/handlers/config";
import { handleServe } from "@commands/handlers/serve";
import type { CommandRegistry } from "@/types/handlers";
export const COMMAND_REGISTRY: CommandRegistry = {
chat: handleChat,
run: handleRun,
classify: handleClassify,
plan: handlePlan,
validate: handleValidate,
config: handleConfig,
serve: handleServe,
};
export const isValidCommand = (
command: string,
): command is keyof CommandRegistry => {
return command in COMMAND_REGISTRY;
};

View File

@@ -0,0 +1,10 @@
/**
* Run command handler
*/
import { execute } from "@commands/runner";
import type { CommandOptions } from "@/types/index";
export const handleRun = async (options: CommandOptions): Promise<void> => {
await execute(options);
};

View File

@@ -0,0 +1,15 @@
/**
* Serve command handler
*/
import { boxMessage, warningMessage, infoMessage } from "@utils/terminal";
import type { CommandOptions } from "@/types/index";
import { SERVER_INFO } from "@constants/serve";
export const handleServe = async (_options: CommandOptions): Promise<void> => {
boxMessage(SERVER_INFO, "Server Mode");
warningMessage("Server mode not yet implemented");
infoMessage(
"This will integrate with the existing agent/main.py JSON-RPC server",
);
};

View File

@@ -0,0 +1,78 @@
/**
* Validate command handler
*/
import chalk from "chalk";
import {
failSpinner,
warningMessage,
successMessage,
succeedSpinner,
startSpinner,
errorMessage,
headerMessage,
filePath,
} from "@utils/terminal";
import { getConfig } from "@services/config";
import type { CommandOptions } from "@/types/index";
export const handleValidate = async (
options: CommandOptions,
): Promise<void> => {
const { planFile } = options;
if (!planFile) {
errorMessage("Plan file is required");
return;
}
headerMessage("Validating Plan");
console.log(chalk.bold("Plan file:") + ` ${filePath(planFile)}`);
console.log();
startSpinner("Validating plan...");
try {
const fs = await import("fs/promises");
const planData = await fs.readFile(planFile, "utf-8");
const plan = JSON.parse(planData);
await new Promise((resolve) => setTimeout(resolve, 1000));
const config = await getConfig();
const warnings: string[] = [];
const errors: string[] = [];
plan.files?.forEach((file: string) => {
if (config.isProtectedPath(file)) {
warnings.push(`Protected path: ${file}`);
}
});
succeedSpinner("Validation complete");
console.log();
if (errors.length > 0) {
console.log(chalk.red.bold("Errors:"));
errors.forEach((err) => console.log(` - ${err}`));
}
if (warnings.length > 0) {
console.log(chalk.yellow.bold("Warnings:"));
warnings.forEach((warn) => console.log(` - ${warn}`));
}
if (errors.length === 0 && warnings.length === 0) {
successMessage("Plan is valid and safe to execute");
} else if (errors.length > 0) {
errorMessage("Plan has errors and cannot be executed");
process.exit(1);
} else {
warningMessage("Plan has warnings - proceed with caution");
}
} catch (error) {
failSpinner("Validation failed");
throw error;
}
};

319
src/commands/mcp.ts Normal file
View File

@@ -0,0 +1,319 @@
/**
* MCP Command - Manage MCP servers
*
* Usage:
* codetyper mcp list - List configured servers
* codetyper mcp add <name> - Add a new server
* codetyper mcp remove <name> - Remove a server
* codetyper mcp connect [name] - Connect to server(s)
* codetyper mcp disconnect [name] - Disconnect from server(s)
* codetyper mcp status - Show connection status
* codetyper mcp tools - List available tools
*/
import chalk from "chalk";
import { errorMessage, infoMessage, successMessage } from "@utils/terminal";
import {
initializeMCP,
getMCPConfig,
addServer,
removeServer,
connectServer,
disconnectServer,
connectAllServers,
disconnectAllServers,
getServerInstances,
getAllTools,
} from "@services/mcp/index";
/**
* MCP command handler
*/
export const mcpCommand = async (args: string[]): Promise<void> => {
const subcommand = args[0] || "status";
const handlers: Record<string, (args: string[]) => Promise<void>> = {
list: handleList,
add: handleAdd,
remove: handleRemove,
connect: handleConnect,
disconnect: handleDisconnect,
status: handleStatus,
tools: handleTools,
help: handleHelp,
};
const handler = handlers[subcommand];
if (!handler) {
errorMessage(`Unknown subcommand: ${subcommand}`);
await handleHelp([]);
return;
}
await handler(args.slice(1));
};
/**
* List configured servers
*/
const handleList = async (_args: string[]): Promise<void> => {
await initializeMCP();
const config = await getMCPConfig();
const servers = Object.entries(config.servers);
if (servers.length === 0) {
infoMessage("No MCP servers configured.");
infoMessage("Add a server with: codetyper mcp add <name>");
return;
}
console.log(chalk.bold("\nConfigured MCP Servers:\n"));
for (const [name, server] of servers) {
const enabled =
server.enabled !== false ? chalk.green("✓") : chalk.gray("○");
console.log(` ${enabled} ${chalk.cyan(name)}`);
console.log(
` Command: ${server.command} ${(server.args || []).join(" ")}`,
);
if (server.transport && server.transport !== "stdio") {
console.log(` Transport: ${server.transport}`);
}
console.log();
}
};
/**
* Add a new server
*/
const handleAdd = async (args: string[]): Promise<void> => {
const name = args[0];
if (!name) {
errorMessage("Server name required");
infoMessage(
"Usage: codetyper mcp add <name> --command <cmd> [--args <args>]",
);
return;
}
// Parse options
let command = "";
const serverArgs: string[] = [];
let isGlobal = false;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg === "--command" || arg === "-c") {
command = args[++i] || "";
} else if (arg === "--args" || arg === "-a") {
// Collect remaining args
while (args[i + 1] && !args[i + 1].startsWith("--")) {
serverArgs.push(args[++i]);
}
} else if (arg === "--global" || arg === "-g") {
isGlobal = true;
}
}
if (!command) {
// Interactive mode - ask for command
infoMessage("Adding MCP server interactively...");
infoMessage("Example: npx @modelcontextprotocol/server-sqlite");
// For now, require command flag
errorMessage("Command required. Use --command <cmd>");
return;
}
try {
await addServer(
name,
{
command,
args: serverArgs.length > 0 ? serverArgs : undefined,
enabled: true,
},
isGlobal,
);
successMessage(`Added MCP server: ${name}`);
infoMessage(`Connect with: codetyper mcp connect ${name}`);
} catch (err) {
errorMessage(`Failed to add server: ${err}`);
}
};
/**
* Remove a server
*/
const handleRemove = async (args: string[]): Promise<void> => {
const name = args[0];
if (!name) {
errorMessage("Server name required");
return;
}
const isGlobal = args.includes("--global") || args.includes("-g");
try {
await removeServer(name, isGlobal);
successMessage(`Removed MCP server: ${name}`);
} catch (err) {
errorMessage(`Failed to remove server: ${err}`);
}
};
/**
* Connect to server(s)
*/
const handleConnect = async (args: string[]): Promise<void> => {
const name = args[0];
if (name) {
// Connect to specific server
try {
infoMessage(`Connecting to ${name}...`);
const instance = await connectServer(name);
successMessage(`Connected to ${name}`);
console.log(` Tools: ${instance.tools.length}`);
console.log(` Resources: ${instance.resources.length}`);
} catch (err) {
errorMessage(`Failed to connect: ${err}`);
}
} else {
// Connect to all servers
infoMessage("Connecting to all servers...");
const results = await connectAllServers();
for (const [serverName, instance] of results) {
if (instance.state === "connected") {
successMessage(
`${serverName}: Connected (${instance.tools.length} tools)`,
);
} else {
errorMessage(`${serverName}: ${instance.error || "Failed"}`);
}
}
}
};
/**
* Disconnect from server(s)
*/
const handleDisconnect = async (args: string[]): Promise<void> => {
const name = args[0];
if (name) {
await disconnectServer(name);
successMessage(`Disconnected from ${name}`);
} else {
await disconnectAllServers();
successMessage("Disconnected from all servers");
}
};
/**
* Show connection status
*/
const handleStatus = async (_args: string[]): Promise<void> => {
await initializeMCP();
const instances = getServerInstances();
if (instances.size === 0) {
infoMessage("No MCP servers configured.");
return;
}
console.log(chalk.bold("\nMCP Server Status:\n"));
for (const [name, instance] of instances) {
const stateColors: Record<string, (s: string) => string> = {
connected: chalk.green,
connecting: chalk.yellow,
disconnected: chalk.gray,
error: chalk.red,
};
const colorFn = stateColors[instance.state] || chalk.white;
const status = colorFn(instance.state.toUpperCase());
console.log(` ${chalk.cyan(name)}: ${status}`);
if (instance.state === "connected") {
console.log(` Tools: ${instance.tools.length}`);
console.log(` Resources: ${instance.resources.length}`);
}
if (instance.error) {
console.log(` Error: ${chalk.red(instance.error)}`);
}
console.log();
}
};
/**
* List available tools
*/
const handleTools = async (_args: string[]): Promise<void> => {
await connectAllServers();
const tools = getAllTools();
if (tools.length === 0) {
infoMessage("No tools available. Connect to MCP servers first.");
return;
}
console.log(chalk.bold("\nAvailable MCP Tools:\n"));
// Group by server
const byServer = new Map<string, typeof tools>();
for (const item of tools) {
const existing = byServer.get(item.server) || [];
existing.push(item);
byServer.set(item.server, existing);
}
for (const [server, serverTools] of byServer) {
console.log(chalk.cyan(` ${server}:`));
for (const { tool } of serverTools) {
console.log(` - ${chalk.white(tool.name)}`);
if (tool.description) {
console.log(` ${chalk.gray(tool.description)}`);
}
}
console.log();
}
};
/**
* Show help
*/
const handleHelp = async (_args: string[]): Promise<void> => {
console.log(`
${chalk.bold("MCP (Model Context Protocol) Management")}
${chalk.cyan("Usage:")}
codetyper mcp <command> [options]
${chalk.cyan("Commands:")}
list List configured servers
add <name> Add a new server
--command, -c <cmd> Command to run
--args, -a <args> Arguments for command
--global, -g Add to global config
remove <name> Remove a server
--global, -g Remove from global config
connect [name] Connect to server(s)
disconnect [name] Disconnect from server(s)
status Show connection status
tools List available tools from connected servers
${chalk.cyan("Examples:")}
codetyper mcp add sqlite -c npx -a @modelcontextprotocol/server-sqlite
codetyper mcp connect sqlite
codetyper mcp tools
`);
};
export default mcpCommand;

10
src/commands/runner.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Autonomous task runner - executes agent tasks
*/
export { execute } from "@commands/runner/execute";
export { createPlan } from "@commands/runner/create-plan";
export { executePlan } from "@commands/runner/execute-plan";
export { displayPlan, getStepIcon } from "@commands/runner/display-plan";
export { displayHeader } from "@commands/runner/display-header";
export { delay } from "@commands/runner/utils";

View File

@@ -0,0 +1,45 @@
/**
* Plan creation utilities
*/
import {
RUNNER_DELAYS,
MOCK_STEPS,
DEFAULT_FILE,
ESTIMATED_TIME_PER_STEP,
} from "@constants/runner";
import { delay } from "@commands/runner/utils";
import type { AgentType, ExecutionPlan, PlanStep } from "@/types/index";
export const createPlan = async (
task: string,
_agent: AgentType,
files: string[],
): Promise<ExecutionPlan> => {
await delay(RUNNER_DELAYS.PLANNING);
const targetFile = files[0] ?? DEFAULT_FILE;
const steps: PlanStep[] = [
{
...MOCK_STEPS.READ,
file: targetFile,
},
{
...MOCK_STEPS.EDIT,
file: targetFile,
dependencies: [...MOCK_STEPS.EDIT.dependencies],
},
{
...MOCK_STEPS.EXECUTE,
dependencies: [...MOCK_STEPS.EXECUTE.dependencies],
},
];
return {
steps,
intent: "code",
summary: task,
estimatedTime: steps.length * ESTIMATED_TIME_PER_STEP,
};
};

View File

@@ -0,0 +1,27 @@
/**
* Runner header display utilities
*/
import chalk from "chalk";
import { headerMessage, filePath } from "@utils/terminal";
import type { RunnerOptions } from "@/types/runner";
export const displayHeader = (options: RunnerOptions): void => {
const { task, agent, files, dryRun } = options;
headerMessage("Running Task");
console.log(chalk.bold("Agent:") + ` ${chalk.cyan(agent)}`);
console.log(chalk.bold("Task:") + ` ${task}`);
if (files.length > 0) {
console.log(
chalk.bold("Files:") + ` ${files.map((f) => filePath(f)).join(", ")}`,
);
}
console.log(
chalk.bold("Mode:") +
` ${dryRun ? chalk.yellow("Dry Run") : chalk.green("Execute")}`,
);
console.log();
};

View File

@@ -0,0 +1,34 @@
/**
* Plan display utilities
*/
import chalk from "chalk";
import { filePath } from "@utils/terminal";
import { STEP_ICONS, DEFAULT_STEP_ICON } from "@constants/runner";
import type { ExecutionPlan, PlanStep } from "@/types/index";
export const getStepIcon = (type: PlanStep["type"]): string =>
STEP_ICONS[type] ?? DEFAULT_STEP_ICON;
export const displayPlan = (plan: ExecutionPlan): void => {
console.log("\n" + chalk.bold.underline("Execution Plan:"));
console.log(chalk.gray(`${plan.summary}`));
console.log();
plan.steps.forEach((step, index) => {
const icon = getStepIcon(step.type);
const deps = step.dependencies
? chalk.gray(` (depends on: ${step.dependencies.join(", ")})`)
: "";
console.log(
`${icon} ${chalk.bold(`Step ${index + 1}:`)} ${step.description}${deps}`,
);
if (step.file) {
console.log(` ${filePath(step.file)}`);
}
if (step.tool) {
console.log(` ${chalk.gray(`Tool: ${step.tool}`)}`);
}
});
console.log();
};

View File

@@ -0,0 +1,35 @@
/**
* Plan execution utilities
*/
import { failSpinner, succeedSpinner, startSpinner } from "@utils/terminal";
import { RUNNER_DELAYS } from "@constants/runner";
import { getStepIcon } from "@commands/runner/display-plan";
import { delay } from "@commands/runner/utils";
import type { ExecutionPlan, PlanStep } from "@/types/index";
import type { StepContext } from "@/types/runner";
const executeStep = async (context: StepContext): Promise<void> => {
const { step, current, total } = context;
const icon = getStepIcon(step.type);
const message = `${icon} Step ${current}/${total}: ${step.description}`;
startSpinner(message);
try {
await delay(RUNNER_DELAYS.STEP_EXECUTION);
succeedSpinner(message);
} catch (error) {
failSpinner(message);
throw error;
}
};
export const executePlan = async (plan: ExecutionPlan): Promise<void> => {
const total = plan.steps.length;
for (let i = 0; i < plan.steps.length; i++) {
const step: PlanStep = plan.steps[i];
await executeStep({ step, current: i + 1, total });
}
};

View File

@@ -0,0 +1,118 @@
/**
* Main runner execution function
*/
import {
askConfirm,
failSpinner,
successMessage,
succeedSpinner,
headerMessage,
startSpinner,
infoMessage,
errorMessage,
warningMessage,
} from "@utils/terminal";
import { RUNNER_DELAYS, RUNNER_MESSAGES } from "@constants/runner";
import { displayHeader } from "@commands/runner/display-header";
import { displayPlan } from "@commands/runner/display-plan";
import { createPlan } from "@commands/runner/create-plan";
import { executePlan } from "@commands/runner/execute-plan";
import { delay } from "@commands/runner/utils";
import type { CommandOptions, AgentType } from "@/types/index";
import type { RunnerOptions } from "@/types/runner";
const parseOptions = (options: CommandOptions): RunnerOptions | null => {
const {
task,
agent = "coder",
files = [],
dryRun = false,
autoApprove = false,
} = options;
if (!task) {
errorMessage(RUNNER_MESSAGES.TASK_REQUIRED);
return null;
}
return {
task,
agent: agent as AgentType,
files,
dryRun,
autoApprove,
};
};
const runDiscoveryPhase = async (): Promise<void> => {
startSpinner(RUNNER_MESSAGES.DISCOVERY_START);
await delay(RUNNER_DELAYS.DISCOVERY);
succeedSpinner(RUNNER_MESSAGES.DISCOVERY_COMPLETE);
};
const runPlanningPhase = async (
task: string,
agent: AgentType,
files: string[],
) => {
startSpinner(RUNNER_MESSAGES.PLANNING_START);
const plan = await createPlan(task, agent, files);
succeedSpinner(`Plan created with ${plan.steps.length} steps`);
return plan;
};
const confirmExecution = async (autoApprove: boolean): Promise<boolean> => {
if (autoApprove) {
return true;
}
const approved = await askConfirm(RUNNER_MESSAGES.CONFIRM_EXECUTE);
if (!approved) {
warningMessage(RUNNER_MESSAGES.EXECUTION_CANCELLED);
return false;
}
return true;
};
export const execute = async (options: CommandOptions): Promise<void> => {
const runnerOptions = parseOptions(options);
if (!runnerOptions) {
return;
}
const { task, agent, files, dryRun, autoApprove } = runnerOptions;
displayHeader(runnerOptions);
try {
await runDiscoveryPhase();
const plan = await runPlanningPhase(task, agent, files);
displayPlan(plan);
if (dryRun) {
infoMessage(RUNNER_MESSAGES.DRY_RUN_INFO);
return;
}
const shouldExecute = await confirmExecution(autoApprove);
if (!shouldExecute) {
return;
}
headerMessage("Executing Plan");
await executePlan(plan);
successMessage(`\n${RUNNER_MESSAGES.TASK_COMPLETE}`);
} catch (error) {
failSpinner(RUNNER_MESSAGES.TASK_FAILED);
errorMessage(`Error: ${error}`);
throw error;
}
};

View File

@@ -0,0 +1,6 @@
/**
* Runner utility functions
*/
export const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));