diff --git a/README.md b/README.md index 0d69371..c6cf0c7 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,7 @@ Nanocoder automatically saves your preferences to remember your choices across s - `/debug` - Toggle logging levels (silent/normal/verbose) - `/custom-commands` - List all custom commands - `/exit` - Exit the application +- `/export` - Export current session to markdown file - `!command` - Execute bash commands directly without leaving Nanocoder (output becomes context for the LLM) #### Custom Commands diff --git a/package.json b/package.json index ee93688..7edc1d1 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@modelcontextprotocol/sdk": "^1.17.3", "@nanostores/react": "^1.0.0", "cli-highlight": "^2.1.11", - "ink": "^6.0.0", + "ink": "^6.3.1", "ink-big-text": "^2.0.0", "ink-gradient": "^3.0.0", "ink-select-input": "^6.2.0", diff --git a/source/app.tsx b/source/app.tsx index 80ddce2..0c283a7 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -26,8 +26,8 @@ import {useModeHandlers} from './app/hooks/useModeHandlers.js'; import {useAppInitialization} from './app/hooks/useAppInitialization.js'; import {useDirectoryTrust} from './app/hooks/useDirectoryTrust.js'; import { - handleMessageSubmission, createClearMessagesHandler, + handleMessageSubmission, } from './app/utils/appUtils.js'; export default function App() { @@ -168,6 +168,9 @@ export default function App() { messages: appState.messages, setIsBashExecuting: appState.setIsBashExecuting, setCurrentBashCommand: appState.setCurrentBashCommand, + provider: appState.currentProvider, + model: appState.currentModel, + getMessageTokens: appState.getMessageTokens, }); }, [ diff --git a/source/app/hooks/useAppInitialization.tsx b/source/app/hooks/useAppInitialization.tsx index 5e03311..117db73 100644 --- a/source/app/hooks/useAppInitialization.tsx +++ b/source/app/hooks/useAppInitialization.tsx @@ -1,4 +1,4 @@ -import {useEffect} from 'react'; +import React, {useEffect} from 'react'; import {LLMClient, ProviderType} from '../../types/core.js'; import {ToolManager} from '../../tools/tool-manager.js'; import {CustomCommandLoader} from '../../custom-commands/loader.js'; @@ -9,30 +9,30 @@ import { loadPreferences, updateLastUsed, } from '../../config/preferences.js'; -import type {UserPreferences, MCPInitResult} from '../../types/index.js'; +import type {MCPInitResult, UserPreferences} from '../../types/index.js'; import { - setToolRegistryGetter, setToolManagerGetter, + setToolRegistryGetter, } from '../../message-handler.js'; import {commandRegistry} from '../../commands.js'; import {shouldLog} from '../../config/logging.js'; import {appConfig} from '../../config/index.js'; import { - helpCommand, - exitCommand, clearCommand, - modelCommand, - providerCommand, commandsCommand, debugCommand, - mcpCommand, + exitCommand, + exportCommand, + helpCommand, initCommand, + mcpCommand, + modelCommand, + providerCommand, themeCommand, } from '../../commands/index.js'; import SuccessMessage from '../../components/success-message.js'; import ErrorMessage from '../../components/error-message.js'; import InfoMessage from '../../components/info-message.js'; -import React from 'react'; interface UseAppInitializationProps { setClient: (client: LLMClient | null) => void; @@ -247,6 +247,7 @@ export function useAppInitialization({ mcpCommand, initCommand, themeCommand, + exportCommand, ]); // Now start with the properly initialized objects (excluding MCP) diff --git a/source/app/utils/appUtils.ts b/source/app/utils/appUtils.ts index 9797ad4..66b142a 100644 --- a/source/app/utils/appUtils.ts +++ b/source/app/utils/appUtils.ts @@ -148,7 +148,15 @@ ${result.fullOutput || '(No output)'}`; } // Execute built-in command - const result = await commandRegistry.execute(message.slice(1)); // Remove the leading '/' + const totalTokens = messages.reduce( + (sum, msg) => sum + options.getMessageTokens(msg), + 0, + ); + const result = await commandRegistry.execute(message.slice(1), messages, { + provider: options.provider, + model: options.model, + tokens: totalTokens, + }); if (result) { // Check if result is JSX (React element) if (React.isValidElement(result)) { diff --git a/source/commands.ts b/source/commands.ts index ba4f34f..9bab14d 100644 --- a/source/commands.ts +++ b/source/commands.ts @@ -27,7 +27,11 @@ export class CommandRegistry { .sort(); } - async execute(input: string): Promise { + async execute( + input: string, + messages: import('./types/index.js').Message[], + metadata: {provider: string; model: string; tokens: number}, + ): Promise { const parts = input.trim().split(/\s+/); const commandName = parts[0]; if (!commandName) { @@ -49,7 +53,7 @@ export class CommandRegistry { }); } - return await command.handler(args); + return await command.handler(args, messages, metadata); } } diff --git a/source/commands/debug.tsx b/source/commands/debug.tsx index f1ae0a4..717b0fa 100644 --- a/source/commands/debug.tsx +++ b/source/commands/debug.tsx @@ -2,7 +2,7 @@ import type {Command, LogLevel} from '../types/index.js'; import {getLogLevel, setLogLevel} from '../config/logging.js'; import React from 'react'; import {TitledBox, titleStyles} from '@mishieck/ink-titled-box'; -import {Text, Box} from 'ink'; +import {Box, Text} from 'ink'; import {useTerminalWidth} from '../hooks/useTerminalWidth.js'; import {useTheme} from '../hooks/useTheme.js'; import {getColors} from '../config/index.js'; @@ -135,7 +135,7 @@ function Debug({currentLevel, newLevel, action, invalidArg}: DebugProps) { export const debugCommand: Command = { name: 'debug', description: 'Toggle debug/verbose logging output', - handler: async (args: string[]) => { + handler: async (args: string[], _messages, _metadata) => { const currentLevel = getLogLevel(); // If no argument provided, cycle through levels diff --git a/source/commands/exit.ts b/source/commands/exit.ts index 2e56ab7..7d0872e 100644 --- a/source/commands/exit.ts +++ b/source/commands/exit.ts @@ -5,7 +5,7 @@ import React from 'react'; export const exitCommand: Command = { name: 'exit', description: 'Exit the application', - handler: async (_args: string[]) => { + handler: async (_args: string[], _messages, _metadata) => { // Return InfoMessage component first, then exit after a short delay setTimeout(() => { process.exit(0); diff --git a/source/commands/export.tsx b/source/commands/export.tsx new file mode 100644 index 0000000..610bded --- /dev/null +++ b/source/commands/export.tsx @@ -0,0 +1,78 @@ +import {Command, Message} from '../types/index.js'; +import React from 'react'; +import SuccessMessage from '../components/success-message.js'; +import fs from 'fs/promises'; +import path from 'path'; + +const formatMessageContent = (message: Message) => { + let content = ''; + switch (message.role) { + case 'user': + content += `## User\n${message.content}`; + break; + case 'assistant': + content += `## Assistant\n${message.content || ''}`; + if (message.tool_calls) { + content += `\n\n[tool_use: ${message.tool_calls + .map(tc => tc.function.name) + .join(', ')}]`; + } + break; + case 'tool': + content += + `## Tool Output: ${message.name}\n` + + '```\n' + + `${message.content}\n` + + '```\n'; + break; + case 'system': + // For now, we don't include system messages in the export + return ''; + default: + return ''; + } + return content + '\n\n'; +}; + +function Export({filename}: {filename: string}) { + return ( + + ); +} + +export const exportCommand: Command = { + name: 'export', + description: 'Export the chat history to a markdown file', + handler: async ( + args: string[], + messages: Message[], + {provider, model, tokens}, + ) => { + const filename = + args[0] || + `nanocoder-chat-${new Date().toISOString().replace(/:/g, '-')}.md`; + const filepath = path.resolve(process.cwd(), filename); + + const frontmatter = `--- +session_date: ${new Date().toISOString()} +provider: ${provider} +model: ${model} +total_tokens: ${tokens} +--- + +# Nanocoder Chat Export + +`; + + const markdownContent = messages.map(formatMessageContent).join(''); + + await fs.writeFile(filepath, frontmatter + markdownContent); + + return React.createElement(Export, { + key: `export-${Date.now()}`, + filename, + }); + }, +}; diff --git a/source/commands/help.tsx b/source/commands/help.tsx index 6500369..362f602 100644 --- a/source/commands/help.tsx +++ b/source/commands/help.tsx @@ -5,7 +5,7 @@ import path from 'path'; import {fileURLToPath} from 'url'; import React from 'react'; import {TitledBox, titleStyles} from '@mishieck/ink-titled-box'; -import {Text, Box} from 'ink'; +import {Box, Text} from 'ink'; import {useTerminalWidth} from '../hooks/useTerminalWidth.js'; import {useTheme} from '../hooks/useTheme.js'; @@ -92,7 +92,7 @@ function Help({ export const helpCommand: Command = { name: 'help', description: 'Show available commands', - handler: async (_args: string[]) => { + handler: async (_args: string[], _messages, _metadata) => { const commands = commandRegistry.getAll(); return React.createElement(Help, { diff --git a/source/commands/index.ts b/source/commands/index.ts index ad7650b..f814765 100644 --- a/source/commands/index.ts +++ b/source/commands/index.ts @@ -8,3 +8,4 @@ export * from './debug.js'; export * from './custom-commands.js'; export * from './init.js'; export * from './theme.js'; +export * from './export.js'; diff --git a/source/commands/init.tsx b/source/commands/init.tsx index 95b1dcc..b5e4705 100644 --- a/source/commands/init.tsx +++ b/source/commands/init.tsx @@ -1,7 +1,7 @@ import {Command} from '../types/index.js'; import React from 'react'; import {TitledBox, titleStyles} from '@mishieck/ink-titled-box'; -import {Text, Box} from 'ink'; +import {Box, Text} from 'ink'; import {colors} from '../config/index.js'; import {useTerminalWidth} from '../hooks/useTerminalWidth.js'; import ErrorMessage from '../components/error-message.js'; @@ -213,7 +213,7 @@ export const initCommand: Command = { name: 'init', description: 'Initialize nanocoder configuration and analyze project structure', - handler: async (_args: string[]) => { + handler: async (_args: string[], _messages, _metadata) => { const cwd = process.cwd(); const created: string[] = []; diff --git a/source/commands/mcp.tsx b/source/commands/mcp.tsx index 8991642..2922e50 100644 --- a/source/commands/mcp.tsx +++ b/source/commands/mcp.tsx @@ -3,7 +3,7 @@ import {ToolManager} from '../tools/tool-manager.js'; import {getToolManager} from '../message-handler.js'; import React from 'react'; import {TitledBox, titleStyles} from '@mishieck/ink-titled-box'; -import {Text, Box} from 'ink'; +import {Box, Text} from 'ink'; import {useTerminalWidth} from '../hooks/useTerminalWidth.js'; import {useTheme} from '../hooks/useTheme.js'; @@ -97,7 +97,7 @@ export function MCP({toolManager}: MCPProps) { export const mcpCommand: Command = { name: 'mcp', description: 'Show connected MCP servers and their tools', - handler: async (_args: string[]) => { + handler: async (_args: string[], _messages, _metadata) => { const toolManager = getToolManager(); return React.createElement(MCP, { diff --git a/source/commands/model.ts b/source/commands/model.ts index d2ac942..e284187 100644 --- a/source/commands/model.ts +++ b/source/commands/model.ts @@ -1,11 +1,12 @@ +import React from 'react'; import {Command} from '../types/index.js'; export const modelCommand: Command = { name: 'model', description: 'Select a model for the current provider', - handler: async (_args: string[]) => { + handler: async (_args: string[], _messages, _metadata) => { // This command is handled specially in app.tsx // This handler exists only for registration purposes - return undefined; + return React.createElement(React.Fragment); }, -}; \ No newline at end of file +}; diff --git a/source/commands/provider.ts b/source/commands/provider.ts index f1c9db8..6dbefe9 100644 --- a/source/commands/provider.ts +++ b/source/commands/provider.ts @@ -1,11 +1,12 @@ +import React from 'react'; import {Command} from '../types/index.js'; export const providerCommand: Command = { name: 'provider', description: 'Switch between AI providers', - handler: async (_args: string[]) => { + handler: async (_args: string[], _messages, _metadata) => { // This command is handled specially in app.tsx // This handler exists only for registration purposes - return undefined; + return React.createElement(React.Fragment); }, }; diff --git a/source/commands/theme.ts b/source/commands/theme.ts index af227bc..d84ed9b 100644 --- a/source/commands/theme.ts +++ b/source/commands/theme.ts @@ -1,11 +1,12 @@ +import React from 'react'; import type {Command} from '../types/index.js'; export const themeCommand: Command = { name: 'theme', description: 'Select a theme for the Nanocoder CLI', - handler: async (_args: string[]) => { + handler: async (_args: string[], _messages, _metadata) => { // This command is handled specially in app.tsx // This handler exists only for registration purposes - return undefined; + return React.createElement(React.Fragment); }, }; diff --git a/source/types/app.ts b/source/types/app.ts index 323c6bb..e26d318 100644 --- a/source/types/app.ts +++ b/source/types/app.ts @@ -17,6 +17,9 @@ export interface MessageSubmissionOptions { messages: any[]; setIsBashExecuting: (executing: boolean) => void; setCurrentBashCommand: (command: string) => void; + provider: string; + model: string; + getMessageTokens: (message: any) => number; } export interface ThinkingStats { @@ -43,4 +46,4 @@ export interface UseAppInitializationProps { updateInfo: any; isLoading: boolean; initError: string | null; -} \ No newline at end of file +} diff --git a/source/types/commands.ts b/source/types/commands.ts index 93bc175..ea7701d 100644 --- a/source/types/commands.ts +++ b/source/types/commands.ts @@ -1,7 +1,9 @@ -export interface Command { +import {Message} from './core.js'; + +export interface Command { name: string; description: string; - handler: (args: string[]) => void | string | Promise; + handler: (args: string[], messages: Message[], metadata: {provider: string, model: string, tokens: number}) => Promise; } export interface ParsedCommand {