Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion source/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
});
},
[
Expand Down
19 changes: 10 additions & 9 deletions source/app/hooks/useAppInitialization.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -247,6 +247,7 @@ export function useAppInitialization({
mcpCommand,
initCommand,
themeCommand,
exportCommand,
]);

// Now start with the properly initialized objects (excluding MCP)
Expand Down
10 changes: 9 additions & 1 deletion source/app/utils/appUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
8 changes: 6 additions & 2 deletions source/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ export class CommandRegistry {
.sort();
}

async execute(input: string): Promise<void | string | React.ReactNode> {
async execute(
input: string,
messages: import('./types/index.js').Message[],
metadata: {provider: string; model: string; tokens: number},
): Promise<void | string | React.ReactNode> {
const parts = input.trim().split(/\s+/);
const commandName = parts[0];
if (!commandName) {
Expand All @@ -49,7 +53,7 @@ export class CommandRegistry {
});
}

return await command.handler(args);
return await command.handler(args, messages, metadata);
}
}

Expand Down
4 changes: 2 additions & 2 deletions source/commands/debug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion source/commands/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
78 changes: 78 additions & 0 deletions source/commands/export.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SuccessMessage
message={`✔️ Chat exported to ${filename}`}
></SuccessMessage>
);
}

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`;
Comment on lines +53 to +55
Copy link
Preview

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The filename generation uses a magic string replacement pattern. Consider extracting this to a helper function or using a more robust date formatting approach for better maintainability.

Copilot uses AI. Check for mistakes.

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('');
Copy link
Preview

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using map().join('') creates intermediate arrays. For better performance with large message arrays, consider using a single loop or reduce operation to build the content string directly.

Suggested change
const markdownContent = messages.map(formatMessageContent).join('');
const markdownContent = messages.reduce((acc, msg) => acc + formatMessageContent(msg), '');

Copilot uses AI. Check for mistakes.


await fs.writeFile(filepath, frontmatter + markdownContent);

return React.createElement(Export, {
key: `export-${Date.now()}`,
filename,
});
},
};
4 changes: 2 additions & 2 deletions source/commands/help.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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, {
Expand Down
1 change: 1 addition & 0 deletions source/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 2 additions & 2 deletions source/commands/init.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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[] = [];

Expand Down
4 changes: 2 additions & 2 deletions source/commands/mcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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, {
Expand Down
7 changes: 4 additions & 3 deletions source/commands/model.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
};
5 changes: 3 additions & 2 deletions source/commands/provider.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
5 changes: 3 additions & 2 deletions source/commands/theme.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
5 changes: 4 additions & 1 deletion source/types/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -43,4 +46,4 @@ export interface UseAppInitializationProps {
updateInfo: any;
isLoading: boolean;
initError: string | null;
}
}
6 changes: 4 additions & 2 deletions source/types/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export interface Command {
import {Message} from './core.js';

export interface Command<T = React.ReactElement> {
name: string;
description: string;
handler: (args: string[]) => void | string | Promise<void | string | React.ReactNode>;
handler: (args: string[], messages: Message[], metadata: {provider: string, model: string, tokens: number}) => Promise<T>;
}

export interface ParsedCommand {
Expand Down