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

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

View 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 };
};

View 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);
}
};

View 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));
}
};

View 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;
};

View 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 };
};

View 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;
}
}
}
};

View 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));
}
};

View 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();
};

View 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);
};

View 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);
}
};

View 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");
},
};
};

View 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"));
};

View 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;
};