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:
72
src/services/chat-tui/agents.ts
Normal file
72
src/services/chat-tui/agents.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Agent commands for TUI
|
||||
*/
|
||||
|
||||
import { agentLoader } from "@services/agent-loader";
|
||||
import type {
|
||||
ChatServiceState,
|
||||
ChatServiceCallbacks,
|
||||
} from "@/types/chat-service";
|
||||
|
||||
export const showAgentsList = async (
|
||||
state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
const agents = await agentLoader.getAvailableAgents(process.cwd());
|
||||
const currentAgent =
|
||||
(state as ChatServiceState & { currentAgent?: string }).currentAgent ??
|
||||
"coder";
|
||||
|
||||
const lines: string[] = ["Available Agents", ""];
|
||||
|
||||
for (const agent of agents) {
|
||||
const isCurrent = agent.id === currentAgent;
|
||||
const marker = isCurrent ? "→ " : " ";
|
||||
const nameDisplay = isCurrent ? `[${agent.name}]` : agent.name;
|
||||
|
||||
lines.push(`${marker}${nameDisplay}`);
|
||||
|
||||
if (agent.description) {
|
||||
lines.push(` ${agent.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Use /agent <name> to switch agents");
|
||||
|
||||
callbacks.onLog("system", lines.join("\n"));
|
||||
};
|
||||
|
||||
export const switchAgentById = async (
|
||||
agentId: string,
|
||||
state: ChatServiceState,
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const agents = await agentLoader.getAvailableAgents(process.cwd());
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
|
||||
if (!agent) {
|
||||
return { success: false, message: `Agent not found: ${agentId}` };
|
||||
}
|
||||
|
||||
// Store current agent on state
|
||||
(state as ChatServiceState & { currentAgent?: string }).currentAgent =
|
||||
agent.id;
|
||||
|
||||
// Update system prompt with agent prompt
|
||||
if (agent.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;
|
||||
}
|
||||
}
|
||||
|
||||
let message = `Switched to agent: ${agent.name}`;
|
||||
if (agent.description) {
|
||||
message += `\n${agent.description}`;
|
||||
}
|
||||
|
||||
return { success: true, message };
|
||||
};
|
||||
161
src/services/chat-tui/auth.ts
Normal file
161
src/services/chat-tui/auth.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Chat TUI authentication handling
|
||||
*/
|
||||
|
||||
import { AUTH_MESSAGES } from "@constants/chat-service";
|
||||
import {
|
||||
getProviderStatus,
|
||||
getCopilotUserInfo,
|
||||
logoutProvider,
|
||||
initiateDeviceFlow,
|
||||
pollForAccessToken,
|
||||
completeCopilotLogin,
|
||||
} from "@providers/index";
|
||||
import { appStore } from "@tui/index";
|
||||
import { loadModels } from "@services/chat-tui/models";
|
||||
import type {
|
||||
ChatServiceState,
|
||||
ChatServiceCallbacks,
|
||||
} from "@/types/chat-service";
|
||||
|
||||
const PROVIDER_AUTH_HANDLERS: Record<
|
||||
string,
|
||||
(state: ChatServiceState, callbacks: ChatServiceCallbacks) => Promise<void>
|
||||
> = {
|
||||
copilot: handleCopilotLogin,
|
||||
ollama: async (_, callbacks) => {
|
||||
callbacks.onLog("system", AUTH_MESSAGES.NO_LOGIN_REQUIRED("ollama"));
|
||||
},
|
||||
};
|
||||
|
||||
const PROVIDER_LOGOUT_HANDLERS: Record<
|
||||
string,
|
||||
(state: ChatServiceState, callbacks: ChatServiceCallbacks) => Promise<void>
|
||||
> = {
|
||||
copilot: handleCopilotLogout,
|
||||
ollama: async (state, callbacks) => {
|
||||
callbacks.onLog("system", AUTH_MESSAGES.NO_LOGOUT_SUPPORT(state.provider));
|
||||
},
|
||||
};
|
||||
|
||||
const PROVIDER_WHOAMI_HANDLERS: Record<
|
||||
string,
|
||||
(state: ChatServiceState, callbacks: ChatServiceCallbacks) => Promise<void>
|
||||
> = {
|
||||
copilot: handleCopilotWhoami,
|
||||
ollama: async (_, callbacks) => {
|
||||
callbacks.onLog("system", AUTH_MESSAGES.OLLAMA_NO_AUTH);
|
||||
},
|
||||
};
|
||||
|
||||
async function handleCopilotLogin(
|
||||
state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> {
|
||||
const status = await getProviderStatus(state.provider);
|
||||
if (status.valid) {
|
||||
callbacks.onLog("system", AUTH_MESSAGES.ALREADY_LOGGED_IN);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceResponse = await initiateDeviceFlow();
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
AUTH_MESSAGES.COPILOT_AUTH_INSTRUCTIONS(
|
||||
deviceResponse.verification_uri,
|
||||
deviceResponse.user_code,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const accessToken = await pollForAccessToken(
|
||||
deviceResponse.device_code,
|
||||
deviceResponse.interval,
|
||||
deviceResponse.expires_in,
|
||||
);
|
||||
|
||||
await completeCopilotLogin(accessToken);
|
||||
|
||||
const models = await loadModels(state.provider);
|
||||
appStore.setAvailableModels(models);
|
||||
|
||||
callbacks.onLog("system", AUTH_MESSAGES.AUTH_SUCCESS);
|
||||
|
||||
const userInfo = await getCopilotUserInfo();
|
||||
if (userInfo) {
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
AUTH_MESSAGES.LOGGED_IN_AS(userInfo.login, userInfo.name),
|
||||
);
|
||||
}
|
||||
} catch (pollError) {
|
||||
callbacks.onLog(
|
||||
"error",
|
||||
AUTH_MESSAGES.AUTH_FAILED((pollError as Error).message),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
callbacks.onLog(
|
||||
"error",
|
||||
AUTH_MESSAGES.AUTH_START_FAILED((error as Error).message),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopilotLogout(
|
||||
_state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> {
|
||||
await logoutProvider("copilot");
|
||||
callbacks.onLog("system", AUTH_MESSAGES.LOGGED_OUT);
|
||||
}
|
||||
|
||||
async function handleCopilotWhoami(
|
||||
_state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> {
|
||||
const userInfo = await getCopilotUserInfo();
|
||||
if (userInfo) {
|
||||
let content = `Logged in as: ${userInfo.login}`;
|
||||
if (userInfo.name) content += `\nName: ${userInfo.name}`;
|
||||
if (userInfo.email) content += `\nEmail: ${userInfo.email}`;
|
||||
callbacks.onLog("system", content);
|
||||
} else {
|
||||
callbacks.onLog("system", AUTH_MESSAGES.NOT_LOGGED_IN);
|
||||
}
|
||||
}
|
||||
|
||||
export const handleLogin = async (
|
||||
state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
const handler = PROVIDER_AUTH_HANDLERS[state.provider];
|
||||
if (handler) {
|
||||
await handler(state, callbacks);
|
||||
} else {
|
||||
callbacks.onLog("system", AUTH_MESSAGES.NO_LOGIN_REQUIRED(state.provider));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleLogout = async (
|
||||
state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
const handler = PROVIDER_LOGOUT_HANDLERS[state.provider];
|
||||
if (handler) {
|
||||
await handler(state, callbacks);
|
||||
} else {
|
||||
callbacks.onLog("system", AUTH_MESSAGES.NO_LOGOUT_SUPPORT(state.provider));
|
||||
}
|
||||
};
|
||||
|
||||
export const showWhoami = async (
|
||||
state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
const handler = PROVIDER_WHOAMI_HANDLERS[state.provider];
|
||||
if (handler) {
|
||||
await handler(state, callbacks);
|
||||
}
|
||||
};
|
||||
155
src/services/chat-tui/commands.ts
Normal file
155
src/services/chat-tui/commands.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Chat TUI command handling
|
||||
*/
|
||||
|
||||
import { saveSession as saveSessionSession } from "@services/session";
|
||||
import { appStore } from "@tui/index";
|
||||
import {
|
||||
CHAT_MESSAGES,
|
||||
HELP_TEXT,
|
||||
type CommandName,
|
||||
} from "@constants/chat-service";
|
||||
import { handleLogin, handleLogout, showWhoami } from "@services/chat-tui/auth";
|
||||
import {
|
||||
handleRememberCommand,
|
||||
handleLearningsCommand,
|
||||
} from "@services/chat-tui/learnings";
|
||||
import { showUsageStats } from "@services/chat-tui/usage";
|
||||
import {
|
||||
checkOllamaAvailability,
|
||||
checkCopilotAvailability,
|
||||
} from "@services/cascading-provider";
|
||||
import { getOverallScore } from "@services/provider-quality";
|
||||
import { PROVIDER_IDS } from "@constants/provider-quality";
|
||||
import type {
|
||||
ChatServiceState,
|
||||
ChatServiceCallbacks,
|
||||
} from "@/types/chat-service";
|
||||
|
||||
type CommandHandler = (
|
||||
state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
) => Promise<void> | void;
|
||||
|
||||
const showHelp: CommandHandler = (_, callbacks) => {
|
||||
callbacks.onLog("system", HELP_TEXT);
|
||||
};
|
||||
|
||||
const clearConversation: CommandHandler = (state, callbacks) => {
|
||||
state.messages = [{ role: "system", content: state.systemPrompt }];
|
||||
appStore.clearLogs();
|
||||
callbacks.onLog("system", CHAT_MESSAGES.CONVERSATION_CLEARED);
|
||||
};
|
||||
|
||||
const saveSession: CommandHandler = async (_, callbacks) => {
|
||||
await saveSessionSession();
|
||||
callbacks.onLog("system", CHAT_MESSAGES.SESSION_SAVED);
|
||||
};
|
||||
|
||||
const showContext: CommandHandler = (state, callbacks) => {
|
||||
const tokenEstimate = state.messages.reduce(
|
||||
(sum, msg) => sum + Math.ceil(msg.content.length / 4),
|
||||
0,
|
||||
);
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`Context: ${state.messages.length} messages, ~${tokenEstimate} tokens`,
|
||||
);
|
||||
};
|
||||
|
||||
const selectModel: CommandHandler = () => {
|
||||
appStore.setMode("model_select");
|
||||
};
|
||||
|
||||
const selectProvider: CommandHandler = () => {
|
||||
appStore.setMode("provider_select");
|
||||
};
|
||||
|
||||
const showStatus: CommandHandler = async (state, callbacks) => {
|
||||
const ollamaStatus = await checkOllamaAvailability();
|
||||
const copilotStatus = await checkCopilotAvailability();
|
||||
const ollamaScore = await getOverallScore(PROVIDER_IDS.OLLAMA);
|
||||
const { cascadeEnabled } = appStore.getState();
|
||||
|
||||
const lines = [
|
||||
"═══ Provider Status ═══",
|
||||
"",
|
||||
`Current Provider: ${state.provider}`,
|
||||
`Cascade Mode: ${cascadeEnabled ? "Enabled" : "Disabled"}`,
|
||||
"",
|
||||
"Ollama:",
|
||||
` Status: ${ollamaStatus.available ? "● Available" : "○ Not Available"}`,
|
||||
ollamaStatus.error ? ` Error: ${ollamaStatus.error}` : null,
|
||||
` Quality Score: ${Math.round(ollamaScore * 100)}%`,
|
||||
"",
|
||||
"Copilot:",
|
||||
` Status: ${copilotStatus.available ? "● Available" : "○ Not Available"}`,
|
||||
copilotStatus.error ? ` Error: ${copilotStatus.error}` : null,
|
||||
"",
|
||||
"Commands:",
|
||||
" /provider - Select provider",
|
||||
" /login - Authenticate with Copilot",
|
||||
"",
|
||||
"Config: ~/.config/codetyper/config.json",
|
||||
].filter(Boolean);
|
||||
|
||||
callbacks.onLog("system", lines.join("\n"));
|
||||
};
|
||||
|
||||
const selectAgent: CommandHandler = () => {
|
||||
appStore.setMode("agent_select");
|
||||
};
|
||||
|
||||
const selectTheme: CommandHandler = () => {
|
||||
appStore.setMode("theme_select");
|
||||
};
|
||||
|
||||
const selectMCP: CommandHandler = () => {
|
||||
appStore.setMode("mcp_select");
|
||||
};
|
||||
|
||||
const selectMode: CommandHandler = () => {
|
||||
appStore.setMode("mode_select");
|
||||
};
|
||||
|
||||
const COMMAND_HANDLERS: Record<CommandName, CommandHandler> = {
|
||||
help: showHelp,
|
||||
h: showHelp,
|
||||
clear: clearConversation,
|
||||
c: clearConversation,
|
||||
save: saveSession,
|
||||
s: saveSession,
|
||||
context: showContext,
|
||||
usage: (state, callbacks) => showUsageStats(state, callbacks),
|
||||
u: (state, callbacks) => showUsageStats(state, callbacks),
|
||||
model: selectModel,
|
||||
models: selectModel,
|
||||
agent: selectAgent,
|
||||
a: selectAgent,
|
||||
theme: selectTheme,
|
||||
mcp: selectMCP,
|
||||
mode: selectMode,
|
||||
whoami: showWhoami,
|
||||
login: handleLogin,
|
||||
logout: handleLogout,
|
||||
provider: selectProvider,
|
||||
p: selectProvider,
|
||||
status: showStatus,
|
||||
remember: handleRememberCommand,
|
||||
learnings: (_, callbacks) => handleLearningsCommand(callbacks),
|
||||
};
|
||||
|
||||
export const executeCommand = async (
|
||||
state: ChatServiceState,
|
||||
command: string,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
const normalizedCommand = command.toLowerCase() as CommandName;
|
||||
const handler = COMMAND_HANDLERS[normalizedCommand];
|
||||
|
||||
if (handler) {
|
||||
await handler(state, callbacks);
|
||||
} else {
|
||||
callbacks.onLog("error", CHAT_MESSAGES.UNKNOWN_COMMAND(command));
|
||||
}
|
||||
};
|
||||
203
src/services/chat-tui/files.ts
Normal file
203
src/services/chat-tui/files.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Chat TUI file handling
|
||||
*/
|
||||
|
||||
import { readFile, stat } from "fs/promises";
|
||||
import { resolve, basename, extname } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import fg from "fast-glob";
|
||||
|
||||
import {
|
||||
FILE_SIZE_LIMITS,
|
||||
GLOB_IGNORE_PATTERNS,
|
||||
CHAT_MESSAGES,
|
||||
} from "@constants/chat-service";
|
||||
import {
|
||||
BINARY_EXTENSIONS,
|
||||
type BinaryExtension,
|
||||
} from "@constants/file-picker";
|
||||
import { appStore } from "@tui/index";
|
||||
import type { ChatServiceState } from "@/types/chat-service";
|
||||
|
||||
const isBinaryFile = (filePath: string): boolean => {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
return BINARY_EXTENSIONS.includes(ext as BinaryExtension);
|
||||
};
|
||||
|
||||
const isExecutableWithoutExtension = async (
|
||||
filePath: string,
|
||||
): Promise<boolean> => {
|
||||
const ext = extname(filePath);
|
||||
if (ext) return false;
|
||||
|
||||
try {
|
||||
const buffer = Buffer.alloc(4);
|
||||
const { open } = await import("fs/promises");
|
||||
const handle = await open(filePath, "r");
|
||||
await handle.read(buffer, 0, 4, 0);
|
||||
await handle.close();
|
||||
|
||||
// Check for common binary signatures
|
||||
// ELF (Linux executables)
|
||||
if (
|
||||
buffer[0] === 0x7f &&
|
||||
buffer[1] === 0x45 &&
|
||||
buffer[2] === 0x4c &&
|
||||
buffer[3] === 0x46
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Mach-O (macOS executables)
|
||||
if (
|
||||
(buffer[0] === 0xfe &&
|
||||
buffer[1] === 0xed &&
|
||||
buffer[2] === 0xfa &&
|
||||
buffer[3] === 0xce) ||
|
||||
(buffer[0] === 0xfe &&
|
||||
buffer[1] === 0xed &&
|
||||
buffer[2] === 0xfa &&
|
||||
buffer[3] === 0xcf) ||
|
||||
(buffer[0] === 0xce &&
|
||||
buffer[1] === 0xfa &&
|
||||
buffer[2] === 0xed &&
|
||||
buffer[3] === 0xfe) ||
|
||||
(buffer[0] === 0xcf &&
|
||||
buffer[1] === 0xfa &&
|
||||
buffer[2] === 0xed &&
|
||||
buffer[3] === 0xfe)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// MZ (Windows executables)
|
||||
if (buffer[0] === 0x4d && buffer[1] === 0x5a) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const loadFile = async (
|
||||
state: ChatServiceState,
|
||||
filePath: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (isBinaryFile(filePath)) {
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: CHAT_MESSAGES.FILE_IS_BINARY(basename(filePath)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await stat(filePath);
|
||||
if (stats.size > FILE_SIZE_LIMITS.MAX_CONTEXT_FILE_SIZE) {
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: CHAT_MESSAGES.FILE_TOO_LARGE(basename(filePath), stats.size),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isExecutableWithoutExtension(filePath)) {
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: CHAT_MESSAGES.FILE_IS_BINARY(basename(filePath)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
state.contextFiles.set(filePath, content);
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: CHAT_MESSAGES.FILE_ADDED(basename(filePath)),
|
||||
});
|
||||
} catch (error) {
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: CHAT_MESSAGES.FILE_READ_FAILED(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const addContextFile = async (
|
||||
state: ChatServiceState,
|
||||
pattern: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const paths = await fg(pattern, {
|
||||
cwd: process.cwd(),
|
||||
absolute: true,
|
||||
ignore: [...GLOB_IGNORE_PATTERNS],
|
||||
});
|
||||
|
||||
if (paths.length === 0) {
|
||||
const absolutePath = resolve(process.cwd(), pattern);
|
||||
if (existsSync(absolutePath)) {
|
||||
await loadFile(state, absolutePath);
|
||||
} else {
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: CHAT_MESSAGES.FILE_NOT_FOUND(pattern),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const filePath of paths) {
|
||||
await loadFile(state, filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: CHAT_MESSAGES.FILE_ADD_FAILED(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const processFileReferences = async (
|
||||
state: ChatServiceState,
|
||||
input: string,
|
||||
): Promise<string> => {
|
||||
const filePattern = /@(?:"([^"]+)"|'([^']+)'|(\S+))/g;
|
||||
let match;
|
||||
const filesToAdd: string[] = [];
|
||||
|
||||
while ((match = filePattern.exec(input)) !== null) {
|
||||
const filePath = match[1] || match[2] || match[3];
|
||||
filesToAdd.push(filePath);
|
||||
}
|
||||
|
||||
for (const filePath of filesToAdd) {
|
||||
await addContextFile(state, filePath);
|
||||
}
|
||||
|
||||
const textOnly = input.replace(filePattern, "").trim();
|
||||
if (!textOnly && filesToAdd.length > 0) {
|
||||
return CHAT_MESSAGES.ANALYZE_FILES;
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
export const buildContextMessage = (
|
||||
state: ChatServiceState,
|
||||
message: string,
|
||||
): string => {
|
||||
if (state.contextFiles.size === 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
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\`\`\``,
|
||||
);
|
||||
}
|
||||
|
||||
state.contextFiles.clear();
|
||||
return contextParts.join("\n\n") + "\n\n" + message;
|
||||
};
|
||||
211
src/services/chat-tui/initialize.ts
Normal file
211
src/services/chat-tui/initialize.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Chat TUI initialization
|
||||
*/
|
||||
|
||||
import { errorMessage, infoMessage } from "@utils/terminal";
|
||||
import {
|
||||
findSession,
|
||||
loadSession,
|
||||
createSession,
|
||||
getMostRecentSession,
|
||||
} from "@services/session";
|
||||
import { getConfig } from "@services/config";
|
||||
import { initializePermissions } from "@services/permissions";
|
||||
import { projectConfig } from "@services/project-config";
|
||||
import { getProviderStatus } from "@providers/index";
|
||||
import { appStore } from "@tui/index";
|
||||
import { themeActions } from "@stores/theme-store";
|
||||
import {
|
||||
AGENTIC_SYSTEM_PROMPT,
|
||||
buildAgenticPrompt,
|
||||
buildSystemPromptWithRules,
|
||||
} from "@prompts/index";
|
||||
import { initSuggestionService } from "@services/command-suggestion-service";
|
||||
import { addContextFile } from "@services/chat-tui/files";
|
||||
import type { ProviderName, Message } from "@/types/providers";
|
||||
import type { ChatSession } from "@/types/index";
|
||||
import type { ChatTUIOptions } from "@interfaces/ChatTUIOptions";
|
||||
import type { ChatServiceState } from "@/types/chat-service";
|
||||
|
||||
const createInitialState = async (
|
||||
options: ChatTUIOptions,
|
||||
): Promise<ChatServiceState> => {
|
||||
const config = await getConfig();
|
||||
|
||||
return {
|
||||
provider: (options.provider || config.get("provider")) as ProviderName,
|
||||
model: options.model || config.get("model") || undefined,
|
||||
messages: [],
|
||||
contextFiles: new Map(),
|
||||
systemPrompt: AGENTIC_SYSTEM_PROMPT,
|
||||
verbose: options.verbose || false,
|
||||
autoApprove: options.autoApprove || false,
|
||||
};
|
||||
};
|
||||
|
||||
const validateProvider = async (state: ChatServiceState): Promise<void> => {
|
||||
const status = await getProviderStatus(state.provider);
|
||||
if (!status.valid) {
|
||||
errorMessage(`Provider ${state.provider} is not configured.`);
|
||||
infoMessage(`Run: codetyper login ${state.provider}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const getGitContext = async (): Promise<{
|
||||
isGitRepo: boolean;
|
||||
branch?: string;
|
||||
status?: string;
|
||||
recentCommits?: string[];
|
||||
}> => {
|
||||
try {
|
||||
const { execSync } = await import("child_process");
|
||||
const branch = execSync("git branch --show-current", { encoding: "utf-8" }).trim();
|
||||
const status = execSync("git status --short", { encoding: "utf-8" }).trim() || "(clean)";
|
||||
const commits = execSync("git log --oneline -5", { encoding: "utf-8" })
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
return { isGitRepo: true, branch, status, recentCommits: commits };
|
||||
} catch {
|
||||
return { isGitRepo: false };
|
||||
}
|
||||
};
|
||||
|
||||
const buildSystemPrompt = async (
|
||||
state: ChatServiceState,
|
||||
options: ChatTUIOptions,
|
||||
): Promise<void> => {
|
||||
if (options.systemPrompt) {
|
||||
state.systemPrompt = options.systemPrompt;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build agentic prompt with environment context
|
||||
const gitContext = await getGitContext();
|
||||
const basePrompt = buildAgenticPrompt({
|
||||
workingDir: process.cwd(),
|
||||
isGitRepo: gitContext.isGitRepo,
|
||||
platform: process.platform,
|
||||
today: new Date().toISOString().split("T")[0],
|
||||
model: state.model,
|
||||
gitBranch: gitContext.branch,
|
||||
gitStatus: gitContext.status,
|
||||
recentCommits: gitContext.recentCommits,
|
||||
});
|
||||
|
||||
// Add project rules
|
||||
const { prompt: promptWithRules, rulesPaths } =
|
||||
await buildSystemPromptWithRules(basePrompt, 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
const learningsContext = await projectConfig.buildLearningsContext();
|
||||
if (learningsContext) {
|
||||
state.systemPrompt = state.systemPrompt + "\n\n" + learningsContext;
|
||||
if (state.verbose) {
|
||||
infoMessage("Loaded project learnings");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.appendSystemPrompt) {
|
||||
state.systemPrompt =
|
||||
state.systemPrompt + "\n\n" + options.appendSystemPrompt;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreMessagesFromSession = (
|
||||
state: ChatServiceState,
|
||||
session: ChatSession,
|
||||
): void => {
|
||||
state.messages = [{ role: "system", content: state.systemPrompt }];
|
||||
|
||||
for (const msg of session.messages) {
|
||||
if (msg.role !== "system") {
|
||||
state.messages.push({
|
||||
role: msg.role as Message["role"],
|
||||
content: msg.content,
|
||||
});
|
||||
|
||||
appStore.addLog({
|
||||
type: msg.role === "user" ? "user" : "assistant",
|
||||
content: msg.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initializeSession = async (
|
||||
state: ChatServiceState,
|
||||
options: ChatTUIOptions,
|
||||
): Promise<ChatSession> => {
|
||||
if (options.continueSession) {
|
||||
const recent = await getMostRecentSession(process.cwd());
|
||||
if (recent) {
|
||||
await loadSession(recent.id);
|
||||
restoreMessagesFromSession(state, recent);
|
||||
return recent;
|
||||
}
|
||||
return createSession("coder");
|
||||
}
|
||||
|
||||
if (options.resumeSession) {
|
||||
const found = await findSession(options.resumeSession);
|
||||
if (found) {
|
||||
await loadSession(found.id);
|
||||
restoreMessagesFromSession(state, found);
|
||||
return found;
|
||||
}
|
||||
errorMessage(`Session not found: ${options.resumeSession}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return createSession("coder");
|
||||
};
|
||||
|
||||
const addInitialContextFiles = async (
|
||||
state: ChatServiceState,
|
||||
files?: string[],
|
||||
): Promise<void> => {
|
||||
if (!files) return;
|
||||
|
||||
for (const file of files) {
|
||||
await addContextFile(state, file);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeTheme = async (): Promise<void> => {
|
||||
const config = await getConfig();
|
||||
const savedTheme = config.get("theme");
|
||||
if (savedTheme) {
|
||||
themeActions.setTheme(savedTheme);
|
||||
}
|
||||
};
|
||||
|
||||
export const initializeChatService = async (
|
||||
options: ChatTUIOptions,
|
||||
): Promise<{ state: ChatServiceState; session: ChatSession }> => {
|
||||
const state = await createInitialState(options);
|
||||
|
||||
await validateProvider(state);
|
||||
await buildSystemPrompt(state, options);
|
||||
await initializeTheme();
|
||||
|
||||
const session = await initializeSession(state, options);
|
||||
|
||||
if (state.messages.length === 0) {
|
||||
state.messages.push({ role: "system", content: state.systemPrompt });
|
||||
}
|
||||
|
||||
await addInitialContextFiles(state, options.files);
|
||||
await initializePermissions();
|
||||
initSuggestionService(process.cwd());
|
||||
|
||||
return { state, session };
|
||||
};
|
||||
118
src/services/chat-tui/learnings.ts
Normal file
118
src/services/chat-tui/learnings.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Chat TUI learnings handling
|
||||
*/
|
||||
|
||||
import {
|
||||
CHAT_MESSAGES,
|
||||
LEARNING_CONFIDENCE_THRESHOLD,
|
||||
MAX_LEARNINGS_DISPLAY,
|
||||
} from "@constants/chat-service";
|
||||
import {
|
||||
detectLearnings,
|
||||
saveLearning,
|
||||
getLearnings,
|
||||
learningExists,
|
||||
} from "@services/learning-service";
|
||||
import type {
|
||||
ChatServiceState,
|
||||
ChatServiceCallbacks,
|
||||
} from "@/types/chat-service";
|
||||
|
||||
export const handleRememberCommand = async (
|
||||
state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
const lastUserMsg = [...state.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "user");
|
||||
const lastAssistantMsg = [...state.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant");
|
||||
|
||||
if (!lastUserMsg || !lastAssistantMsg) {
|
||||
callbacks.onLog("system", CHAT_MESSAGES.NO_CONVERSATION);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = detectLearnings(
|
||||
lastUserMsg.content,
|
||||
lastAssistantMsg.content,
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
callbacks.onLog("system", CHAT_MESSAGES.NO_LEARNINGS_DETECTED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (callbacks.onLearningDetected) {
|
||||
const topCandidate = candidates[0];
|
||||
const response = await callbacks.onLearningDetected(topCandidate);
|
||||
if (response.save && response.scope) {
|
||||
await saveLearning(
|
||||
response.editedContent || topCandidate.content,
|
||||
topCandidate.context,
|
||||
response.scope === "global",
|
||||
);
|
||||
callbacks.onLog("system", CHAT_MESSAGES.LEARNING_SAVED(response.scope));
|
||||
} else {
|
||||
callbacks.onLog("system", CHAT_MESSAGES.LEARNING_SKIPPED);
|
||||
}
|
||||
} else {
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`Detected learnings:\n${candidates.map((c) => `- ${c.content}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleLearningsCommand = async (
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
const learnings = await getLearnings();
|
||||
|
||||
if (learnings.length === 0) {
|
||||
callbacks.onLog("system", CHAT_MESSAGES.NO_LEARNINGS);
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = learnings
|
||||
.slice(0, MAX_LEARNINGS_DISPLAY)
|
||||
.map((l, i) => `${i + 1}. ${l.content}`)
|
||||
.join("\n");
|
||||
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`Saved learnings (${learnings.length} total):\n${formatted}${learnings.length > MAX_LEARNINGS_DISPLAY ? "\n... and more" : ""}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const processLearningsFromExchange = async (
|
||||
userMessage: string,
|
||||
assistantResponse: string,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
if (!callbacks.onLearningDetected) return;
|
||||
|
||||
const candidates = detectLearnings(userMessage, assistantResponse);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.confidence >= LEARNING_CONFIDENCE_THRESHOLD) {
|
||||
const exists = await learningExists(candidate.content);
|
||||
if (!exists) {
|
||||
const response = await callbacks.onLearningDetected(candidate);
|
||||
if (response.save && response.scope) {
|
||||
await saveLearning(
|
||||
response.editedContent || candidate.content,
|
||||
candidate.context,
|
||||
response.scope === "global",
|
||||
);
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
CHAT_MESSAGES.LEARNING_SAVED(response.scope),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
477
src/services/chat-tui/message-handler.ts
Normal file
477
src/services/chat-tui/message-handler.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Chat TUI message handling
|
||||
*/
|
||||
|
||||
import { addMessage, saveSession } from "@services/session";
|
||||
import { createStreamingAgent } from "@services/agent-stream";
|
||||
import { CHAT_MESSAGES } from "@constants/chat-service";
|
||||
import { enrichMessageWithIssues } from "@services/github-issue-service";
|
||||
import {
|
||||
checkGitHubCLI,
|
||||
extractPRUrls,
|
||||
fetchPR,
|
||||
fetchPRComments,
|
||||
formatPRContext,
|
||||
formatPendingComments,
|
||||
} from "@services/github-pr";
|
||||
import {
|
||||
analyzeFileChange,
|
||||
clearSuggestions,
|
||||
getPendingSuggestions,
|
||||
formatSuggestions,
|
||||
} from "@services/command-suggestion-service";
|
||||
import {
|
||||
processFileReferences,
|
||||
buildContextMessage,
|
||||
} from "@services/chat-tui/files";
|
||||
import { getToolDescription } from "@services/chat-tui/utils";
|
||||
import { processLearningsFromExchange } from "@services/chat-tui/learnings";
|
||||
import {
|
||||
compactConversation,
|
||||
createCompactionSummary,
|
||||
getModelCompactionConfig,
|
||||
checkCompactionNeeded,
|
||||
} from "@services/auto-compaction";
|
||||
import {
|
||||
detectTaskType,
|
||||
determineRoute,
|
||||
recordAuditResult,
|
||||
isCorrection,
|
||||
getRoutingExplanation,
|
||||
} from "@services/provider-quality";
|
||||
import {
|
||||
checkOllamaAvailability,
|
||||
checkCopilotAvailability,
|
||||
} from "@services/cascading-provider";
|
||||
import { chat } from "@providers/chat";
|
||||
import { AUDIT_SYSTEM_PROMPT, createAuditPrompt, parseAuditResponse } from "@prompts/audit-prompt";
|
||||
import { PROVIDER_IDS } from "@constants/provider-quality";
|
||||
import { appStore } from "@tui/index";
|
||||
import type { StreamCallbacks } from "@/types/streaming";
|
||||
import type { TaskType } from "@/types/provider-quality";
|
||||
import type {
|
||||
ChatServiceState,
|
||||
ChatServiceCallbacks,
|
||||
ToolCallInfo,
|
||||
} from "@/types/chat-service";
|
||||
|
||||
// Track last response for feedback learning
|
||||
let lastResponseContext: {
|
||||
taskType: TaskType;
|
||||
provider: string;
|
||||
response: string;
|
||||
} | null = null;
|
||||
|
||||
const FILE_MODIFYING_TOOLS = ["write", "edit"];
|
||||
|
||||
const createToolCallHandler =
|
||||
(
|
||||
callbacks: ChatServiceCallbacks,
|
||||
toolCallRef: { current: ToolCallInfo | null },
|
||||
) =>
|
||||
(call: { id: string; name: string; arguments?: Record<string, unknown> }) => {
|
||||
const args = call.arguments;
|
||||
if (FILE_MODIFYING_TOOLS.includes(call.name) && args?.path) {
|
||||
toolCallRef.current = { name: call.name, path: String(args.path) };
|
||||
} else {
|
||||
toolCallRef.current = { name: call.name };
|
||||
}
|
||||
|
||||
callbacks.onModeChange("tool_execution");
|
||||
callbacks.onToolCall({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
description: getToolDescription(call),
|
||||
args,
|
||||
});
|
||||
};
|
||||
|
||||
const createToolResultHandler =
|
||||
(
|
||||
callbacks: ChatServiceCallbacks,
|
||||
toolCallRef: { current: ToolCallInfo | null },
|
||||
) =>
|
||||
(
|
||||
_callId: string,
|
||||
result: {
|
||||
success: boolean;
|
||||
title: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
},
|
||||
) => {
|
||||
if (result.success && toolCallRef.current?.path) {
|
||||
analyzeFileChange(toolCallRef.current.path);
|
||||
}
|
||||
|
||||
callbacks.onToolResult(
|
||||
result.success,
|
||||
result.title,
|
||||
result.success ? result.output : undefined,
|
||||
result.error,
|
||||
);
|
||||
callbacks.onModeChange("thinking");
|
||||
};
|
||||
|
||||
/**
|
||||
* Create streaming callbacks for TUI integration
|
||||
*/
|
||||
const createStreamCallbacks = (): StreamCallbacks => ({
|
||||
onContentChunk: (content: string) => {
|
||||
appStore.appendStreamContent(content);
|
||||
},
|
||||
|
||||
onToolCallStart: (toolCall) => {
|
||||
appStore.setCurrentToolCall({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
description: `Calling ${toolCall.name}...`,
|
||||
status: "pending",
|
||||
});
|
||||
},
|
||||
|
||||
onToolCallComplete: (toolCall) => {
|
||||
appStore.updateToolCall({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
status: "running",
|
||||
});
|
||||
},
|
||||
|
||||
onModelSwitch: (info) => {
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: `Model switched: ${info.from} → ${info.to} (${info.reason})`,
|
||||
});
|
||||
},
|
||||
|
||||
onComplete: () => {
|
||||
appStore.completeStreaming();
|
||||
},
|
||||
|
||||
onError: (error: string) => {
|
||||
appStore.cancelStreaming();
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: error,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Run audit with Copilot on Ollama's response
|
||||
*/
|
||||
const runAudit = async (
|
||||
userPrompt: string,
|
||||
ollamaResponse: string,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<{ approved: boolean; hasMajorIssues: boolean; correctedResponse?: string }> => {
|
||||
try {
|
||||
callbacks.onLog("system", "Auditing response with Copilot...");
|
||||
|
||||
const auditMessages = [
|
||||
{ role: "system" as const, content: AUDIT_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: createAuditPrompt(userPrompt, ollamaResponse) },
|
||||
];
|
||||
|
||||
const auditResponse = await chat("copilot", auditMessages, {});
|
||||
const parsed = parseAuditResponse(auditResponse.content ?? "");
|
||||
|
||||
if (parsed.approved) {
|
||||
callbacks.onLog("system", "✓ Audit passed - response approved");
|
||||
} else {
|
||||
const issueCount = parsed.issues.length;
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`⚠ Audit found ${issueCount} issue(s): ${parsed.issues.slice(0, 2).join(", ")}${issueCount > 2 ? "..." : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
approved: parsed.approved,
|
||||
hasMajorIssues: parsed.severity === "major" || parsed.severity === "critical",
|
||||
correctedResponse: parsed.correctedResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
callbacks.onLog("system", "Audit skipped due to error");
|
||||
return { approved: true, hasMajorIssues: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check for user feedback on previous response and update quality scores
|
||||
*/
|
||||
const checkUserFeedback = async (
|
||||
message: string,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
if (!lastResponseContext) return;
|
||||
|
||||
if (isCorrection(message)) {
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`Learning: Recording correction feedback for ${lastResponseContext.provider}`,
|
||||
);
|
||||
|
||||
await recordAuditResult(
|
||||
lastResponseContext.provider,
|
||||
lastResponseContext.taskType,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Clear context after checking
|
||||
lastResponseContext = null;
|
||||
};
|
||||
|
||||
export const handleMessage = async (
|
||||
state: ChatServiceState,
|
||||
message: string,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
// Check for feedback on previous response
|
||||
await checkUserFeedback(message, callbacks);
|
||||
|
||||
// Get interaction mode and cascade setting from app store
|
||||
const { interactionMode, cascadeEnabled } = appStore.getState();
|
||||
const isReadOnlyMode = interactionMode === "ask" || interactionMode === "code-review";
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
const modeLabel = interactionMode === "ask" ? "Ask" : "Code Review";
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`${modeLabel} mode: Read-only responses (Ctrl+Tab to switch modes)`,
|
||||
);
|
||||
}
|
||||
|
||||
let processedMessage = await processFileReferences(state, message);
|
||||
|
||||
// In code-review mode, check for PR URLs and enrich with PR context
|
||||
if (interactionMode === "code-review") {
|
||||
const prUrls = extractPRUrls(message);
|
||||
|
||||
if (prUrls.length > 0) {
|
||||
const ghStatus = await checkGitHubCLI();
|
||||
|
||||
if (!ghStatus.installed) {
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
"GitHub CLI (gh) is not installed. Install it to enable PR review features: https://cli.github.com/",
|
||||
);
|
||||
} else if (!ghStatus.authenticated) {
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
"GitHub CLI is not authenticated. Run 'gh auth login' to enable PR review features.",
|
||||
);
|
||||
} else {
|
||||
// Fetch PR details for each URL
|
||||
for (const prUrl of prUrls) {
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`Fetching PR #${prUrl.prNumber} from ${prUrl.owner}/${prUrl.repo}...`,
|
||||
);
|
||||
|
||||
const pr = await fetchPR(prUrl);
|
||||
if (pr) {
|
||||
const prContext = formatPRContext(pr);
|
||||
const comments = await fetchPRComments(prUrl);
|
||||
const commentsContext =
|
||||
comments.length > 0 ? formatPendingComments(comments) : "";
|
||||
|
||||
processedMessage = `${processedMessage}\n\n---\n\n${prContext}${commentsContext ? `\n\n${commentsContext}` : ""}`;
|
||||
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`Loaded PR #${pr.number}: ${pr.title} (+${pr.additions} -${pr.deletions}, ${comments.length} comment(s))`,
|
||||
);
|
||||
} else {
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`Could not fetch PR #${prUrl.prNumber}. Make sure you have access to the repository.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { enrichedMessage, issues } =
|
||||
await enrichMessageWithIssues(processedMessage);
|
||||
|
||||
if (issues.length > 0) {
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
CHAT_MESSAGES.GITHUB_ISSUES_FOUND(
|
||||
issues.length,
|
||||
issues.map((i) => `#${i.number}`).join(", "),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const userMessage = buildContextMessage(state, enrichedMessage);
|
||||
|
||||
state.messages.push({ role: "user", content: userMessage });
|
||||
|
||||
clearSuggestions();
|
||||
|
||||
// Get model-specific compaction config based on context window size
|
||||
const config = getModelCompactionConfig(state.model);
|
||||
const { needsCompaction } = checkCompactionNeeded(state.messages, config);
|
||||
|
||||
if (needsCompaction) {
|
||||
appStore.setIsCompacting(true);
|
||||
callbacks.onLog("system", CHAT_MESSAGES.COMPACTION_STARTING);
|
||||
|
||||
const { messages: compactedMessages, result: compactionResult } =
|
||||
compactConversation(state.messages, config);
|
||||
|
||||
if (compactionResult.compacted) {
|
||||
const summary = createCompactionSummary(compactionResult);
|
||||
callbacks.onLog("system", summary);
|
||||
state.messages = compactedMessages;
|
||||
callbacks.onLog("system", CHAT_MESSAGES.COMPACTION_CONTINUING);
|
||||
}
|
||||
|
||||
appStore.setIsCompacting(false);
|
||||
}
|
||||
|
||||
const toolCallRef: { current: ToolCallInfo | null } = { current: null };
|
||||
|
||||
// Determine routing for cascade mode
|
||||
const taskType = detectTaskType(message);
|
||||
let effectiveProvider = state.provider;
|
||||
let shouldAudit = false;
|
||||
|
||||
if (cascadeEnabled && !isReadOnlyMode) {
|
||||
const ollamaStatus = await checkOllamaAvailability();
|
||||
const copilotStatus = await checkCopilotAvailability();
|
||||
|
||||
// If Ollama not available, fallback to Copilot with a message
|
||||
if (!ollamaStatus.available) {
|
||||
effectiveProvider = "copilot";
|
||||
shouldAudit = false;
|
||||
callbacks.onLog(
|
||||
"system",
|
||||
`Ollama not available (${ollamaStatus.error ?? "not running"}). Using Copilot.`,
|
||||
);
|
||||
} else if (!copilotStatus.available) {
|
||||
// If Copilot not available, use Ollama only
|
||||
effectiveProvider = "ollama";
|
||||
shouldAudit = false;
|
||||
callbacks.onLog("system", "Copilot not available. Using Ollama only.");
|
||||
} else {
|
||||
// Both available - use routing logic
|
||||
const routingDecision = await determineRoute({
|
||||
taskType,
|
||||
ollamaAvailable: ollamaStatus.available,
|
||||
copilotAvailable: copilotStatus.available,
|
||||
cascadeEnabled: true,
|
||||
});
|
||||
|
||||
const explanation = await getRoutingExplanation(routingDecision, taskType);
|
||||
callbacks.onLog("system", explanation);
|
||||
|
||||
if (routingDecision === "ollama_only") {
|
||||
effectiveProvider = "ollama";
|
||||
shouldAudit = false;
|
||||
} else if (routingDecision === "copilot_only") {
|
||||
effectiveProvider = "copilot";
|
||||
shouldAudit = false;
|
||||
} else if (routingDecision === "cascade") {
|
||||
effectiveProvider = "ollama";
|
||||
shouldAudit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start streaming UI
|
||||
appStore.setMode("thinking");
|
||||
appStore.startThinking();
|
||||
appStore.startStreaming();
|
||||
|
||||
const streamCallbacks = createStreamCallbacks();
|
||||
const agent = createStreamingAgent(
|
||||
process.cwd(),
|
||||
{
|
||||
provider: effectiveProvider,
|
||||
model: state.model,
|
||||
verbose: state.verbose,
|
||||
autoApprove: state.autoApprove,
|
||||
chatMode: isReadOnlyMode,
|
||||
onToolCall: createToolCallHandler(callbacks, toolCallRef),
|
||||
onToolResult: createToolResultHandler(callbacks, toolCallRef),
|
||||
onError: (error) => {
|
||||
callbacks.onLog("error", error);
|
||||
},
|
||||
onWarning: (warning) => {
|
||||
callbacks.onLog("system", warning);
|
||||
},
|
||||
},
|
||||
streamCallbacks,
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await agent.run(state.messages);
|
||||
|
||||
// Stop thinking timer
|
||||
appStore.stopThinking();
|
||||
|
||||
if (result.finalResponse) {
|
||||
let finalResponse = result.finalResponse;
|
||||
|
||||
// Run audit if cascade mode with Ollama
|
||||
if (shouldAudit && effectiveProvider === "ollama") {
|
||||
const auditResult = await runAudit(message, result.finalResponse, callbacks);
|
||||
|
||||
// Record quality score based on audit
|
||||
await recordAuditResult(
|
||||
PROVIDER_IDS.OLLAMA,
|
||||
taskType,
|
||||
auditResult.approved,
|
||||
auditResult.hasMajorIssues,
|
||||
);
|
||||
|
||||
// Use corrected response if provided
|
||||
if (!auditResult.approved && auditResult.correctedResponse) {
|
||||
finalResponse = auditResult.correctedResponse;
|
||||
callbacks.onLog("system", "Using corrected response from audit");
|
||||
}
|
||||
}
|
||||
|
||||
// Store context for feedback learning
|
||||
lastResponseContext = {
|
||||
taskType,
|
||||
provider: effectiveProvider,
|
||||
response: finalResponse,
|
||||
};
|
||||
|
||||
state.messages.push({
|
||||
role: "assistant",
|
||||
content: finalResponse,
|
||||
});
|
||||
// Note: Don't call callbacks.onLog here - streaming already added the log entry
|
||||
// via appendStreamContent/completeStreaming
|
||||
|
||||
addMessage("user", message);
|
||||
addMessage("assistant", finalResponse);
|
||||
await saveSession();
|
||||
|
||||
await processLearningsFromExchange(
|
||||
message,
|
||||
finalResponse,
|
||||
callbacks,
|
||||
);
|
||||
|
||||
const suggestions = getPendingSuggestions();
|
||||
if (suggestions.length > 0) {
|
||||
const formatted = formatSuggestions(suggestions);
|
||||
callbacks.onLog("system", formatted);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.cancelStreaming();
|
||||
appStore.stopThinking();
|
||||
callbacks.onLog("error", String(error));
|
||||
}
|
||||
};
|
||||
55
src/services/chat-tui/models.ts
Normal file
55
src/services/chat-tui/models.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Chat TUI model handling
|
||||
*/
|
||||
|
||||
import { MODEL_MESSAGES } from "@constants/chat-service";
|
||||
import { getConfig } from "@services/config";
|
||||
import {
|
||||
getProvider,
|
||||
getDefaultModel,
|
||||
getModels as getProviderModels,
|
||||
} from "@providers/index";
|
||||
import { appStore } from "@tui/index";
|
||||
import type { ProviderName, ProviderModel } from "@/types/providers";
|
||||
import type {
|
||||
ChatServiceState,
|
||||
ChatServiceCallbacks,
|
||||
ProviderDisplayInfo,
|
||||
} from "@/types/chat-service";
|
||||
|
||||
export const getProviderInfo = (
|
||||
providerName: ProviderName,
|
||||
): ProviderDisplayInfo => {
|
||||
const provider = getProvider(providerName);
|
||||
const model = getDefaultModel(providerName);
|
||||
return { displayName: provider.displayName, model };
|
||||
};
|
||||
|
||||
export const loadModels = async (
|
||||
providerName: ProviderName,
|
||||
): Promise<ProviderModel[]> => {
|
||||
try {
|
||||
return await getProviderModels(providerName);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const handleModelSelect = async (
|
||||
state: ChatServiceState,
|
||||
model: string,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
if (model === "auto") {
|
||||
state.model = undefined;
|
||||
callbacks.onLog("system", MODEL_MESSAGES.MODEL_AUTO);
|
||||
} else {
|
||||
state.model = model;
|
||||
callbacks.onLog("system", MODEL_MESSAGES.MODEL_CHANGED(model));
|
||||
}
|
||||
appStore.setModel(model);
|
||||
|
||||
const config = await getConfig();
|
||||
config.set("model", model === "auto" ? undefined : model);
|
||||
await config.save();
|
||||
};
|
||||
45
src/services/chat-tui/permissions.ts
Normal file
45
src/services/chat-tui/permissions.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Chat TUI permission handling
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { setPermissionHandler } from "@services/permissions";
|
||||
import type {
|
||||
PermissionPromptRequest,
|
||||
PermissionPromptResponse,
|
||||
} from "@/types/permissions";
|
||||
import { appStore } from "@tui/index";
|
||||
|
||||
export const createPermissionHandler = (): ((
|
||||
request: PermissionPromptRequest,
|
||||
) => Promise<PermissionPromptResponse>) => {
|
||||
return (
|
||||
request: PermissionPromptRequest,
|
||||
): Promise<PermissionPromptResponse> => {
|
||||
return new Promise((resolve) => {
|
||||
appStore.setMode("permission_prompt");
|
||||
|
||||
appStore.setPermissionRequest({
|
||||
id: uuidv4(),
|
||||
type: request.type,
|
||||
description: request.description,
|
||||
command: request.command,
|
||||
path: request.path,
|
||||
resolve: (response) => {
|
||||
appStore.setPermissionRequest(null);
|
||||
appStore.setMode("tool_execution");
|
||||
resolve(response);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const setupPermissionHandler = (): void => {
|
||||
setPermissionHandler(createPermissionHandler());
|
||||
};
|
||||
|
||||
export const cleanupPermissionHandler = (): void => {
|
||||
setPermissionHandler(null);
|
||||
};
|
||||
51
src/services/chat-tui/print-mode.ts
Normal file
51
src/services/chat-tui/print-mode.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Chat TUI print mode (non-interactive)
|
||||
*/
|
||||
|
||||
import { createAgent } from "@services/agent";
|
||||
import { initializePermissions } from "@services/permissions";
|
||||
import {
|
||||
processFileReferences,
|
||||
buildContextMessage,
|
||||
} from "@services/chat-tui/files";
|
||||
import type { ChatServiceState } from "@/types/chat-service";
|
||||
|
||||
export const executePrintMode = async (
|
||||
state: ChatServiceState,
|
||||
prompt: string,
|
||||
): Promise<void> => {
|
||||
const processedPrompt = await processFileReferences(state, prompt);
|
||||
const userMessage = buildContextMessage(state, processedPrompt);
|
||||
|
||||
state.messages.push({ role: "user", content: userMessage });
|
||||
|
||||
await initializePermissions();
|
||||
|
||||
const agent = createAgent(process.cwd(), {
|
||||
provider: state.provider,
|
||||
model: state.model,
|
||||
verbose: state.verbose,
|
||||
autoApprove: state.autoApprove,
|
||||
onToolCall: (call) => {
|
||||
console.error(`[Tool: ${call.name}]`);
|
||||
},
|
||||
onToolResult: (_callId, result) => {
|
||||
if (result.success) {
|
||||
console.error(`✓ ${result.title}`);
|
||||
} else {
|
||||
console.error(`✗ ${result.title}: ${result.error}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await agent.run(state.messages);
|
||||
|
||||
if (result.finalResponse) {
|
||||
console.log(result.finalResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
261
src/services/chat-tui/streaming.ts
Normal file
261
src/services/chat-tui/streaming.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Streaming Chat TUI Integration
|
||||
*
|
||||
* Connects the streaming agent loop to the TUI store for real-time updates.
|
||||
*/
|
||||
|
||||
import type { Message } from "@/types/providers";
|
||||
import type { AgentOptions } from "@interfaces/AgentOptions";
|
||||
import type { AgentResult } from "@interfaces/AgentResult";
|
||||
import type {
|
||||
StreamCallbacks,
|
||||
PartialToolCall,
|
||||
ModelSwitchInfo,
|
||||
} from "@/types/streaming";
|
||||
import type { ToolCall, ToolResult } from "@/types/tools";
|
||||
import { createStreamingAgent } from "@services/agent-stream";
|
||||
import { appStore } from "@tui/index";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface StreamingChatOptions extends AgentOptions {
|
||||
onModelSwitch?: (info: ModelSwitchInfo) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TUI Streaming Callbacks
|
||||
// =============================================================================
|
||||
|
||||
const createTUIStreamCallbacks = (
|
||||
options?: Partial<StreamingChatOptions>,
|
||||
): StreamCallbacks => ({
|
||||
onContentChunk: (content: string) => {
|
||||
appStore.appendStreamContent(content);
|
||||
},
|
||||
|
||||
onToolCallStart: (toolCall: PartialToolCall) => {
|
||||
appStore.setCurrentToolCall({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
description: `Calling ${toolCall.name}...`,
|
||||
status: "pending",
|
||||
});
|
||||
},
|
||||
|
||||
onToolCallComplete: (toolCall: ToolCall) => {
|
||||
appStore.updateToolCall({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
status: "running",
|
||||
});
|
||||
},
|
||||
|
||||
onModelSwitch: (info: ModelSwitchInfo) => {
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: `Model switched: ${info.from} → ${info.to} (${info.reason})`,
|
||||
});
|
||||
options?.onModelSwitch?.(info);
|
||||
},
|
||||
|
||||
onComplete: () => {
|
||||
appStore.completeStreaming();
|
||||
},
|
||||
|
||||
onError: (error: string) => {
|
||||
appStore.cancelStreaming();
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: error,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Agent Options with TUI Integration
|
||||
// =============================================================================
|
||||
|
||||
const createAgentOptionsWithTUI = (
|
||||
options: StreamingChatOptions,
|
||||
): AgentOptions => ({
|
||||
...options,
|
||||
|
||||
onText: (text: string) => {
|
||||
// Text is handled by streaming callbacks, but we may want to notify
|
||||
options.onText?.(text);
|
||||
},
|
||||
|
||||
onToolCall: (toolCall: ToolCall) => {
|
||||
appStore.setMode("tool_execution");
|
||||
appStore.setCurrentToolCall({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
description: `Executing ${toolCall.name}...`,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
appStore.addLog({
|
||||
type: "tool",
|
||||
content: `${toolCall.name}`,
|
||||
metadata: {
|
||||
toolName: toolCall.name,
|
||||
toolStatus: "running",
|
||||
toolDescription: JSON.stringify(toolCall.arguments),
|
||||
},
|
||||
});
|
||||
|
||||
options.onToolCall?.(toolCall);
|
||||
},
|
||||
|
||||
onToolResult: (toolCallId: string, result: ToolResult) => {
|
||||
appStore.updateToolCall({
|
||||
status: result.success ? "success" : "error",
|
||||
result: result.output,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
appStore.addLog({
|
||||
type: "tool",
|
||||
content: result.output || result.error || "",
|
||||
metadata: {
|
||||
toolName: appStore.getState().currentToolCall?.name,
|
||||
toolStatus: result.success ? "success" : "error",
|
||||
toolDescription: result.title,
|
||||
},
|
||||
});
|
||||
|
||||
appStore.setCurrentToolCall(null);
|
||||
appStore.setMode("thinking");
|
||||
|
||||
options.onToolResult?.(toolCallId, result);
|
||||
},
|
||||
|
||||
onError: (error: string) => {
|
||||
appStore.setMode("idle");
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: error,
|
||||
});
|
||||
options.onError?.(error);
|
||||
},
|
||||
|
||||
onWarning: (warning: string) => {
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: warning,
|
||||
});
|
||||
options.onWarning?.(warning);
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Main API
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Run a streaming chat session with TUI integration
|
||||
*/
|
||||
export const runStreamingChat = async (
|
||||
messages: Message[],
|
||||
options: StreamingChatOptions,
|
||||
): Promise<AgentResult> => {
|
||||
// Set up TUI state
|
||||
appStore.setMode("thinking");
|
||||
appStore.startThinking();
|
||||
appStore.startStreaming();
|
||||
|
||||
// Create callbacks that update the TUI
|
||||
const streamCallbacks = createTUIStreamCallbacks(options);
|
||||
const agentOptions = createAgentOptionsWithTUI(options);
|
||||
|
||||
// Create and run the streaming agent
|
||||
const agent = createStreamingAgent(
|
||||
process.cwd(),
|
||||
agentOptions,
|
||||
streamCallbacks,
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await agent.run(messages);
|
||||
|
||||
appStore.stopThinking();
|
||||
appStore.setMode("idle");
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
appStore.cancelStreaming();
|
||||
appStore.stopThinking();
|
||||
appStore.setMode("idle");
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
appStore.addLog({
|
||||
type: "error",
|
||||
content: errorMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
finalResponse: errorMessage,
|
||||
iterations: 0,
|
||||
toolCalls: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a streaming chat instance with stop capability
|
||||
*/
|
||||
export const createStreamingChat = (
|
||||
options: StreamingChatOptions,
|
||||
): {
|
||||
run: (messages: Message[]) => Promise<AgentResult>;
|
||||
stop: () => void;
|
||||
} => {
|
||||
const streamCallbacks = createTUIStreamCallbacks(options);
|
||||
const agentOptions = createAgentOptionsWithTUI(options);
|
||||
|
||||
const agent = createStreamingAgent(
|
||||
process.cwd(),
|
||||
agentOptions,
|
||||
streamCallbacks,
|
||||
);
|
||||
|
||||
return {
|
||||
run: async (messages: Message[]) => {
|
||||
appStore.setMode("thinking");
|
||||
appStore.startThinking();
|
||||
appStore.startStreaming();
|
||||
|
||||
try {
|
||||
const result = await agent.run(messages);
|
||||
|
||||
appStore.stopThinking();
|
||||
appStore.setMode("idle");
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
appStore.cancelStreaming();
|
||||
appStore.stopThinking();
|
||||
appStore.setMode("idle");
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
finalResponse: errorMessage,
|
||||
iterations: 0,
|
||||
toolCalls: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
stop: () => {
|
||||
agent.stop();
|
||||
appStore.cancelStreaming();
|
||||
appStore.stopThinking();
|
||||
appStore.setMode("idle");
|
||||
},
|
||||
};
|
||||
};
|
||||
146
src/services/chat-tui/usage.ts
Normal file
146
src/services/chat-tui/usage.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Usage statistics display for TUI
|
||||
*/
|
||||
|
||||
import { usageStore } from "@stores/usage-store";
|
||||
import { getUserInfo } from "@providers/copilot/credentials";
|
||||
import { getCopilotUsage } from "@providers/copilot/usage";
|
||||
import type {
|
||||
ChatServiceState,
|
||||
ChatServiceCallbacks,
|
||||
} from "@/types/chat-service";
|
||||
import type { CopilotQuotaDetail } from "@/types/copilot-usage";
|
||||
|
||||
const BAR_WIDTH = 40;
|
||||
const FILLED_CHAR = "█";
|
||||
const EMPTY_CHAR = "░";
|
||||
|
||||
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 renderBar = (percent: number): string => {
|
||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||
const filledWidth = Math.round((clampedPercent / 100) * BAR_WIDTH);
|
||||
const emptyWidth = BAR_WIDTH - filledWidth;
|
||||
return FILLED_CHAR.repeat(filledWidth) + EMPTY_CHAR.repeat(emptyWidth);
|
||||
};
|
||||
|
||||
const formatQuotaBar = (
|
||||
name: string,
|
||||
quota: CopilotQuotaDetail | undefined,
|
||||
resetInfo?: string,
|
||||
): string[] => {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (!quota) {
|
||||
lines.push(name);
|
||||
lines.push("N/A");
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (quota.unlimited) {
|
||||
lines.push(name);
|
||||
lines.push(FILLED_CHAR.repeat(BAR_WIDTH) + " Unlimited");
|
||||
return lines;
|
||||
}
|
||||
|
||||
const used = quota.entitlement - quota.remaining;
|
||||
const percentUsed =
|
||||
quota.entitlement > 0 ? (used / quota.entitlement) * 100 : 0;
|
||||
|
||||
lines.push(name);
|
||||
lines.push(`${renderBar(percentUsed)} ${percentUsed.toFixed(0)}% used`);
|
||||
if (resetInfo) {
|
||||
lines.push(resetInfo);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
export const showUsageStats = async (
|
||||
state: ChatServiceState,
|
||||
callbacks: ChatServiceCallbacks,
|
||||
): Promise<void> => {
|
||||
const stats = usageStore.getStats();
|
||||
const sessionDuration = Date.now() - stats.sessionStartTime;
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (state.provider === "copilot") {
|
||||
const userInfo = await getUserInfo();
|
||||
const copilotUsage = await getCopilotUsage();
|
||||
|
||||
if (copilotUsage) {
|
||||
const resetInfo = `Resets ${copilotUsage.quota_reset_date}`;
|
||||
|
||||
lines.push(
|
||||
...formatQuotaBar(
|
||||
"Premium Requests",
|
||||
copilotUsage.quota_snapshots.premium_interactions,
|
||||
resetInfo,
|
||||
),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push(
|
||||
...formatQuotaBar("Chat", copilotUsage.quota_snapshots.chat, resetInfo),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push(
|
||||
...formatQuotaBar(
|
||||
"Completions",
|
||||
copilotUsage.quota_snapshots.completions,
|
||||
resetInfo,
|
||||
),
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("Account");
|
||||
lines.push(`Provider: ${state.provider}`);
|
||||
lines.push(`Model: ${state.model ?? "auto"}`);
|
||||
if (userInfo) {
|
||||
lines.push(`User: ${userInfo.login}`);
|
||||
}
|
||||
if (copilotUsage) {
|
||||
lines.push(`Plan: ${copilotUsage.copilot_plan}`);
|
||||
}
|
||||
lines.push("");
|
||||
} else {
|
||||
lines.push("Provider");
|
||||
lines.push(`Name: ${state.provider}`);
|
||||
lines.push(`Model: ${state.model ?? "auto"}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("Current Session");
|
||||
lines.push(
|
||||
`Tokens: ${formatNumber(stats.totalTokens)} (${formatNumber(stats.promptTokens)} prompt + ${formatNumber(stats.completionTokens)} completion)`,
|
||||
);
|
||||
lines.push(`Requests: ${formatNumber(stats.requestCount)}`);
|
||||
lines.push(`Duration: ${formatDuration(sessionDuration)}`);
|
||||
if (stats.requestCount > 0) {
|
||||
const avgTokensPerRequest = Math.round(
|
||||
stats.totalTokens / stats.requestCount,
|
||||
);
|
||||
lines.push(`Avg tokens/request: ${formatNumber(avgTokensPerRequest)}`);
|
||||
}
|
||||
|
||||
callbacks.onLog("system", lines.join("\n"));
|
||||
};
|
||||
78
src/services/chat-tui/utils.ts
Normal file
78
src/services/chat-tui/utils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Chat TUI utility functions
|
||||
*/
|
||||
|
||||
import { CHAT_TRUNCATE_DEFAULTS, DIFF_PATTERNS } from "@constants/chat-service";
|
||||
import type { DiffResult } from "@/types/chat-service";
|
||||
|
||||
export const stripAnsi = (str: string): string =>
|
||||
str.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
|
||||
export const truncateOutput = (
|
||||
output: string,
|
||||
maxLines = CHAT_TRUNCATE_DEFAULTS.MAX_LINES,
|
||||
maxLength = CHAT_TRUNCATE_DEFAULTS.MAX_LENGTH,
|
||||
): string => {
|
||||
if (!output) return "";
|
||||
|
||||
const lines = output.split("\n");
|
||||
let truncated = lines.slice(0, maxLines).join("\n");
|
||||
|
||||
if (truncated.length > maxLength) {
|
||||
truncated = truncated.slice(0, maxLength) + "...";
|
||||
} else if (lines.length > maxLines) {
|
||||
truncated += `\n... (${lines.length - maxLines} more lines)`;
|
||||
}
|
||||
|
||||
return truncated;
|
||||
};
|
||||
|
||||
export const detectDiffContent = (content: string): DiffResult => {
|
||||
if (!content) {
|
||||
return { isDiff: false, additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const cleanContent = stripAnsi(content);
|
||||
|
||||
const isDiff = DIFF_PATTERNS.some((pattern) => pattern.test(cleanContent));
|
||||
|
||||
if (!isDiff) {
|
||||
return { isDiff: false, additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const fileMatch = cleanContent.match(/\+\+\+\s+[ab]?\/(.+?)(?:\s|$)/m);
|
||||
const filePath = fileMatch?.[1]?.trim();
|
||||
|
||||
const lines = cleanContent.split("\n");
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^\+[^+]/.test(line) || line === "+") {
|
||||
additions++;
|
||||
} else if (/^-[^-]/.test(line) || line === "-") {
|
||||
deletions++;
|
||||
}
|
||||
}
|
||||
|
||||
return { isDiff, filePath, additions, deletions };
|
||||
};
|
||||
|
||||
const TOOL_DESCRIPTIONS: Record<
|
||||
string,
|
||||
(args: Record<string, unknown>) => string
|
||||
> = {
|
||||
bash: (args) => String(args.command || "command"),
|
||||
read: (args) => String(args.path || "file"),
|
||||
write: (args) => String(args.path || "file"),
|
||||
edit: (args) => String(args.path || "file"),
|
||||
};
|
||||
|
||||
export const getToolDescription = (call: {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
}): string => {
|
||||
const args = call.arguments || {};
|
||||
const describer = TOOL_DESCRIPTIONS[call.name];
|
||||
return describer ? describer(args) : call.name;
|
||||
};
|
||||
Reference in New Issue
Block a user