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:
36
src/commands/chat-tui.ts
Normal file
36
src/commands/chat-tui.ts
Normal 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
8
src/commands/chat.ts
Normal 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";
|
||||
23
src/commands/components/callbacks/on-learning-detected.ts
Normal file
23
src/commands/components/callbacks/on-learning-detected.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
14
src/commands/components/callbacks/on-log.ts
Normal file
14
src/commands/components/callbacks/on-log.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
5
src/commands/components/callbacks/on-mode-change.ts
Normal file
5
src/commands/components/callbacks/on-mode-change.ts
Normal 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]);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
interface PermissionResponse {
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
export const onPermissionRequest = async (): Promise<PermissionResponse> => {
|
||||
return { allowed: false };
|
||||
};
|
||||
25
src/commands/components/callbacks/on-tool-call.ts
Normal file
25
src/commands/components/callbacks/on-tool-call.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
47
src/commands/components/callbacks/on-tool-result.ts
Normal file
47
src/commands/components/callbacks/on-tool-result.ts
Normal 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());
|
||||
};
|
||||
35
src/commands/components/chat/agents/show-agents.ts
Normal file
35
src/commands/components/chat/agents/show-agents.ts
Normal 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();
|
||||
};
|
||||
60
src/commands/components/chat/agents/switch-agent.ts
Normal file
60
src/commands/components/chat/agents/switch-agent.ts
Normal 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();
|
||||
};
|
||||
12
src/commands/components/chat/cleanup.ts
Normal file
12
src/commands/components/chat/cleanup.ts
Normal 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);
|
||||
};
|
||||
111
src/commands/components/chat/commands/commandsRegistry.ts
Normal file
111
src/commands/components/chat/commands/commandsRegistry.ts
Normal 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;
|
||||
28
src/commands/components/chat/commands/handle-command.ts
Normal file
28
src/commands/components/chat/commands/handle-command.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
25
src/commands/components/chat/commands/show-help.ts
Normal file
25
src/commands/components/chat/commands/show-help.ts
Normal 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");
|
||||
};
|
||||
35
src/commands/components/chat/context/add-context-file.ts
Normal file
35
src/commands/components/chat/context/add-context-file.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
32
src/commands/components/chat/context/load-file.ts
Normal file
32
src/commands/components/chat/context/load-file.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
22
src/commands/components/chat/context/remove-file.ts
Normal file
22
src/commands/components/chat/context/remove-file.ts
Normal 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}`);
|
||||
};
|
||||
32
src/commands/components/chat/context/show-context-files.ts
Normal file
32
src/commands/components/chat/context/show-context-files.ts
Normal 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();
|
||||
};
|
||||
10
src/commands/components/chat/history/clear-conversation.ts
Normal file
10
src/commands/components/chat/history/clear-conversation.ts
Normal 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");
|
||||
};
|
||||
15
src/commands/components/chat/history/compact-history.ts
Normal file
15
src/commands/components/chat/history/compact-history.ts
Normal 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`);
|
||||
};
|
||||
18
src/commands/components/chat/history/show-context.ts
Normal file
18
src/commands/components/chat/history/show-context.ts
Normal 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();
|
||||
};
|
||||
18
src/commands/components/chat/history/show-history.ts
Normal file
18
src/commands/components/chat/history/show-history.ts
Normal 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();
|
||||
};
|
||||
224
src/commands/components/chat/index.ts
Normal file
224
src/commands/components/chat/index.ts
Normal 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";
|
||||
143
src/commands/components/chat/mcp/handle-mcp.ts
Normal file
143
src/commands/components/chat/mcp/handle-mcp.ts
Normal 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");
|
||||
};
|
||||
6
src/commands/components/chat/mcp/index.ts
Normal file
6
src/commands/components/chat/mcp/index.ts
Normal 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";
|
||||
76
src/commands/components/chat/mcp/show-mcp-status.ts
Normal file
76
src/commands/components/chat/mcp/show-mcp-status.ts
Normal 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();
|
||||
};
|
||||
17
src/commands/components/chat/messages/handle-input.ts
Normal file
17
src/commands/components/chat/messages/handle-input.ts
Normal 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);
|
||||
};
|
||||
228
src/commands/components/chat/messages/send-message.ts
Normal file
228
src/commands/components/chat/messages/send-message.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
31
src/commands/components/chat/models/show-models.ts
Normal file
31
src/commands/components/chat/models/show-models.ts
Normal 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();
|
||||
};
|
||||
7
src/commands/components/chat/models/show-providers.ts
Normal file
7
src/commands/components/chat/models/show-providers.ts
Normal 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"));
|
||||
};
|
||||
50
src/commands/components/chat/models/switch-model.ts
Normal file
50
src/commands/components/chat/models/switch-model.ts
Normal 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();
|
||||
};
|
||||
51
src/commands/components/chat/models/switch-provider.ts
Normal file
51
src/commands/components/chat/models/switch-provider.ts
Normal 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}`);
|
||||
};
|
||||
71
src/commands/components/chat/print-mode.ts
Normal file
71
src/commands/components/chat/print-mode.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
38
src/commands/components/chat/session/list-sessions.ts
Normal file
38
src/commands/components/chat/session/list-sessions.ts
Normal 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();
|
||||
};
|
||||
20
src/commands/components/chat/session/restore-messages.ts
Normal file
20
src/commands/components/chat/session/restore-messages.ts
Normal 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;
|
||||
};
|
||||
20
src/commands/components/chat/session/show-session-info.ts
Normal file
20
src/commands/components/chat/session/show-session-info.ts
Normal 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();
|
||||
};
|
||||
34
src/commands/components/chat/state.ts
Normal file
34
src/commands/components/chat/state.ts
Normal 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,
|
||||
});
|
||||
129
src/commands/components/chat/usage/show-usage.ts
Normal file
129
src/commands/components/chat/usage/show-usage.ts
Normal 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();
|
||||
};
|
||||
25
src/commands/components/dashboard/build-config.ts
Normal file
25
src/commands/components/dashboard/build-config.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
33
src/commands/components/dashboard/display.ts
Normal file
33
src/commands/components/dashboard/display.ts
Normal 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);
|
||||
};
|
||||
41
src/commands/components/dashboard/render-content.ts
Normal file
41
src/commands/components/dashboard/render-content.ts
Normal 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");
|
||||
};
|
||||
31
src/commands/components/dashboard/render-footer.ts
Normal file
31
src/commands/components/dashboard/render-footer.ts
Normal 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");
|
||||
};
|
||||
18
src/commands/components/dashboard/render-header.ts
Normal file
18
src/commands/components/dashboard/render-header.ts
Normal 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}`;
|
||||
};
|
||||
24
src/commands/components/dashboard/render-left-content.ts
Normal file
24
src/commands/components/dashboard/render-left-content.ts
Normal 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;
|
||||
};
|
||||
21
src/commands/components/dashboard/render-right-content.ts
Normal file
21
src/commands/components/dashboard/render-right-content.ts
Normal 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;
|
||||
};
|
||||
55
src/commands/components/execute/execute-solid.tsx
Normal file
55
src/commands/components/execute/execute-solid.tsx
Normal 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();
|
||||
};
|
||||
104
src/commands/components/execute/execute.tsx
Normal file
104
src/commands/components/execute/execute.tsx
Normal 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 };
|
||||
195
src/commands/components/execute/index.ts
Normal file
195
src/commands/components/execute/index.ts
Normal 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;
|
||||
8
src/commands/dashboard.ts
Normal file
8
src/commands/dashboard.ts
Normal 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
25
src/commands/handlers.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
10
src/commands/handlers/chat.ts
Normal file
10
src/commands/handlers/chat.ts
Normal 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);
|
||||
};
|
||||
123
src/commands/handlers/classify.ts
Normal file
123
src/commands/handlers/classify.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
113
src/commands/handlers/config.ts
Normal file
113
src/commands/handlers/config.ts
Normal 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);
|
||||
};
|
||||
62
src/commands/handlers/plan.ts
Normal file
62
src/commands/handlers/plan.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
28
src/commands/handlers/registry.ts
Normal file
28
src/commands/handlers/registry.ts
Normal 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;
|
||||
};
|
||||
10
src/commands/handlers/run.ts
Normal file
10
src/commands/handlers/run.ts
Normal 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);
|
||||
};
|
||||
15
src/commands/handlers/serve.ts
Normal file
15
src/commands/handlers/serve.ts
Normal 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",
|
||||
);
|
||||
};
|
||||
78
src/commands/handlers/validate.ts
Normal file
78
src/commands/handlers/validate.ts
Normal 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
319
src/commands/mcp.ts
Normal 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
10
src/commands/runner.ts
Normal 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";
|
||||
45
src/commands/runner/create-plan.ts
Normal file
45
src/commands/runner/create-plan.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
27
src/commands/runner/display-header.ts
Normal file
27
src/commands/runner/display-header.ts
Normal 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();
|
||||
};
|
||||
34
src/commands/runner/display-plan.ts
Normal file
34
src/commands/runner/display-plan.ts
Normal 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();
|
||||
};
|
||||
35
src/commands/runner/execute-plan.ts
Normal file
35
src/commands/runner/execute-plan.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
118
src/commands/runner/execute.ts
Normal file
118
src/commands/runner/execute.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
6
src/commands/runner/utils.ts
Normal file
6
src/commands/runner/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Runner utility functions
|
||||
*/
|
||||
|
||||
export const delay = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
Reference in New Issue
Block a user