@alia-codea/cli 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { ToolExecution } from '../utils/conversation.js';
4
+
5
+ interface ApprovalPromptProps {
6
+ execution: ToolExecution;
7
+ onResolve: (approved: boolean) => void;
8
+ }
9
+
10
+ function formatArgs(tool: string, args: Record<string, any>): string {
11
+ switch (tool) {
12
+ case 'write_file':
13
+ return `Write to ${args.path}`;
14
+ case 'edit_file':
15
+ return `Edit ${args.path}`;
16
+ case 'apply_patch':
17
+ return `Apply patch`;
18
+ case 'run_command':
19
+ return `Run: ${args.command}`;
20
+ default:
21
+ return `${tool}: ${JSON.stringify(args).slice(0, 80)}`;
22
+ }
23
+ }
24
+
25
+ export function ApprovalPrompt({ execution, onResolve }: ApprovalPromptProps) {
26
+ useInput((input, key) => {
27
+ if (input === 'y' || input === 'Y') {
28
+ onResolve(true);
29
+ } else if (input === 'n' || input === 'N' || key.escape) {
30
+ onResolve(false);
31
+ }
32
+ });
33
+
34
+ const description = formatArgs(execution.tool, execution.args);
35
+
36
+ return (
37
+ <Box flexDirection="column" paddingLeft={2} paddingY={0}>
38
+ <Box gap={1}>
39
+ <Text color="yellow">{'⚠'}</Text>
40
+ <Text bold>{description}</Text>
41
+ </Box>
42
+ {execution.tool === 'run_command' && execution.args.command && (
43
+ <Box paddingLeft={2}>
44
+ <Text color="gray">$ {execution.args.command}</Text>
45
+ </Box>
46
+ )}
47
+ {execution.tool === 'write_file' && execution.args.content && (
48
+ <Box paddingLeft={2}>
49
+ <Text color="gray">{execution.args.content.split('\n').length} lines</Text>
50
+ </Box>
51
+ )}
52
+ <Box paddingLeft={2} gap={1}>
53
+ <Text color="green">[y]</Text>
54
+ <Text>approve</Text>
55
+ <Text color="red">[n]</Text>
56
+ <Text>deny</Text>
57
+ </Box>
58
+ </Box>
59
+ );
60
+ }
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { ApprovalMode } from '../utils/approval.js';
4
+
5
+ interface HeaderProps {
6
+ cwd: string;
7
+ model: string;
8
+ approvalMode: ApprovalMode;
9
+ contextPercent: number;
10
+ }
11
+
12
+ function shortenPath(p: string): string {
13
+ const home = process.env.HOME || '';
14
+ if (home && p.startsWith(home)) {
15
+ return '~' + p.slice(home.length);
16
+ }
17
+ return p;
18
+ }
19
+
20
+ const MODE_COLORS: Record<ApprovalMode, string> = {
21
+ 'suggest': 'yellow',
22
+ 'auto-edit': 'cyan',
23
+ 'full-auto': 'green',
24
+ };
25
+
26
+ export function Header({ cwd, model, approvalMode, contextPercent }: HeaderProps) {
27
+ return (
28
+ <Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
29
+ <Text color="cyan">{shortenPath(cwd)}</Text>
30
+ <Box gap={1}>
31
+ <Text color="magenta">{model}</Text>
32
+ <Text color="gray">|</Text>
33
+ <Text color={MODE_COLORS[approvalMode]}>{approvalMode}</Text>
34
+ <Text color="gray">|</Text>
35
+ <Text color={contextPercent < 20 ? 'red' : 'gray'}>{contextPercent}% left</Text>
36
+ </Box>
37
+ </Box>
38
+ );
39
+ }
@@ -0,0 +1,36 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { ThinkingIndicator } from './ThinkingIndicator.js';
5
+
6
+ interface InputBarProps {
7
+ onSubmit: (value: string) => void;
8
+ isProcessing: boolean;
9
+ thinkingLabel?: string;
10
+ }
11
+
12
+ export function InputBar({ onSubmit, isProcessing, thinkingLabel }: InputBarProps) {
13
+ const [value, setValue] = useState('');
14
+
15
+ const handleSubmit = (text: string) => {
16
+ const trimmed = text.trim();
17
+ if (!trimmed) return;
18
+ setValue('');
19
+ onSubmit(trimmed);
20
+ };
21
+
22
+ if (isProcessing) {
23
+ return (
24
+ <Box paddingX={1}>
25
+ <ThinkingIndicator label={thinkingLabel || 'Thinking'} />
26
+ </Box>
27
+ );
28
+ }
29
+
30
+ return (
31
+ <Box paddingX={1}>
32
+ <Text color="cyan">{'❯ '}</Text>
33
+ <TextInput value={value} onChange={setValue} onSubmit={handleSubmit} />
34
+ </Box>
35
+ );
36
+ }
@@ -0,0 +1,81 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { Marked } from 'marked';
4
+ import { markedTerminal } from 'marked-terminal';
5
+ import { ToolCallCard } from './ToolCallCard.js';
6
+ import { ToolExecution } from '../utils/conversation.js';
7
+
8
+ export interface DisplayMessage {
9
+ id: string;
10
+ type: 'user' | 'assistant' | 'tool' | 'info';
11
+ content: string;
12
+ toolExecution?: ToolExecution;
13
+ streaming?: boolean;
14
+ }
15
+
16
+ interface MessageListProps {
17
+ messages: DisplayMessage[];
18
+ }
19
+
20
+ const marked = new Marked(markedTerminal() as any);
21
+
22
+ function renderMarkdown(text: string): string {
23
+ try {
24
+ const rendered = marked.parse(text);
25
+ if (typeof rendered === 'string') {
26
+ return rendered.trimEnd();
27
+ }
28
+ return text;
29
+ } catch {
30
+ return text;
31
+ }
32
+ }
33
+
34
+ function MessageBlock({ message }: { message: DisplayMessage }) {
35
+ switch (message.type) {
36
+ case 'user':
37
+ return (
38
+ <Box paddingLeft={1} paddingY={0}>
39
+ <Text color="cyan">{'❯ '}</Text>
40
+ <Text>{message.content}</Text>
41
+ </Box>
42
+ );
43
+
44
+ case 'assistant':
45
+ return (
46
+ <Box flexDirection="column" paddingLeft={1}>
47
+ <Box>
48
+ <Text color="magenta">{'✦ '}</Text>
49
+ <Text>{message.streaming ? message.content : renderMarkdown(message.content)}</Text>
50
+ </Box>
51
+ </Box>
52
+ );
53
+
54
+ case 'tool':
55
+ if (message.toolExecution) {
56
+ return <ToolCallCard execution={message.toolExecution} />;
57
+ }
58
+ return null;
59
+
60
+ case 'info':
61
+ return (
62
+ <Box paddingLeft={1}>
63
+ <Text color="blue">{'ℹ '}</Text>
64
+ <Text color="gray">{message.content}</Text>
65
+ </Box>
66
+ );
67
+
68
+ default:
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export function MessageList({ messages }: MessageListProps) {
74
+ return (
75
+ <Box flexDirection="column" flexGrow={1} gap={0}>
76
+ {messages.map((msg) => (
77
+ <MessageBlock key={msg.id} message={msg} />
78
+ ))}
79
+ </Box>
80
+ );
81
+ }
@@ -0,0 +1,28 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+
5
+ interface ThinkingIndicatorProps {
6
+ label?: string;
7
+ }
8
+
9
+ export function ThinkingIndicator({ label = 'Thinking' }: ThinkingIndicatorProps) {
10
+ const [elapsed, setElapsed] = useState(0);
11
+
12
+ useEffect(() => {
13
+ const interval = setInterval(() => {
14
+ setElapsed((e) => e + 1);
15
+ }, 1000);
16
+ return () => clearInterval(interval);
17
+ }, []);
18
+
19
+ return (
20
+ <Box gap={1}>
21
+ <Text color="cyan">
22
+ <Spinner type="dots" />
23
+ </Text>
24
+ <Text bold>{label}</Text>
25
+ <Text color="gray">({elapsed}s)</Text>
26
+ </Box>
27
+ );
28
+ }
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { ToolExecution } from '../utils/conversation.js';
4
+
5
+ interface ToolCallCardProps {
6
+ execution: ToolExecution;
7
+ }
8
+
9
+ function formatArgs(tool: string, args: Record<string, any>): string {
10
+ switch (tool) {
11
+ case 'read_file':
12
+ case 'write_file':
13
+ case 'edit_file':
14
+ return args.path || '';
15
+ case 'apply_patch':
16
+ return 'applying patch...';
17
+ case 'list_files':
18
+ return args.path || '.';
19
+ case 'search_files':
20
+ return `"${args.pattern}" in ${args.path || '.'}`;
21
+ case 'run_command':
22
+ return args.command || '';
23
+ default:
24
+ return JSON.stringify(args).slice(0, 60);
25
+ }
26
+ }
27
+
28
+ export function ToolCallCard({ execution }: ToolCallCardProps) {
29
+ const { tool, args, result, success, approved } = execution;
30
+ const argStr = formatArgs(tool, args);
31
+ const isDone = result !== undefined;
32
+
33
+ if (approved === false) {
34
+ return (
35
+ <Box flexDirection="column" paddingLeft={2}>
36
+ <Box gap={1}>
37
+ <Text color="red">{'✗'}</Text>
38
+ <Text bold color="gray" strikethrough>{tool}</Text>
39
+ <Text color="gray">{argStr}</Text>
40
+ </Box>
41
+ <Box paddingLeft={2}>
42
+ <Text color="yellow" dimColor>Declined by user</Text>
43
+ </Box>
44
+ </Box>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <Box flexDirection="column" paddingLeft={2}>
50
+ <Box gap={1}>
51
+ {isDone ? (
52
+ <Text color={success ? 'green' : 'red'}>{success ? '✓' : '✗'}</Text>
53
+ ) : (
54
+ <Text color="cyan">{'→'}</Text>
55
+ )}
56
+ <Text bold>{tool}</Text>
57
+ <Text color="gray">{argStr}</Text>
58
+ </Box>
59
+ {isDone && result && (
60
+ <Box paddingLeft={2}>
61
+ <Text color="gray" wrap="truncate-end">
62
+ {result.slice(0, 120).replace(/\n/g, ' ')}{result.length > 120 ? '...' : ''}
63
+ </Text>
64
+ </Box>
65
+ )}
66
+ </Box>
67
+ );
68
+ }
package/src/index.ts CHANGED
@@ -8,15 +8,14 @@ import { login } from './commands/auth.js';
8
8
  import { listSessions, resumeSession } from './commands/sessions.js';
9
9
  import chalk from 'chalk';
10
10
 
11
- const VERSION = '1.0.0';
11
+ const VERSION = '2.0.0';
12
12
 
13
13
  const program = new Command();
14
14
 
15
- // ASCII art banner
16
15
  const banner = `
17
16
  ${chalk.cyan(' ____ _ ')}
18
17
  ${chalk.cyan(' / ___|___ __| | ___ __ _ ')}
19
- ${chalk.cyan(' | | / _ \\ / _\` |/ _ \\/ _\` |')}
18
+ ${chalk.cyan(' | | / _ \\ / _` |/ _ \\/ _` |')}
20
19
  ${chalk.cyan(' | |__| (_) | (_| | __/ (_| |')}
21
20
  ${chalk.cyan(' \\____\\___/ \\__,_|\\___|\\__,_|')}
22
21
  ${chalk.gray(' AI Coding Assistant by Alia')}
@@ -26,23 +25,30 @@ program
26
25
  .name('codea')
27
26
  .description('Codea CLI - AI coding assistant for your terminal')
28
27
  .version(VERSION)
29
- .hook('preAction', () => {
30
- // Check for API key before running commands (except login)
28
+ .hook('preAction', async () => {
31
29
  const command = program.args[0];
32
- if (command !== 'login' && command !== 'help' && !config.get('apiKey')) {
33
- console.log(chalk.yellow('\nNo API key found. Please run `codea login` first.\n'));
34
- process.exit(1);
30
+ if (command === 'login' || command === 'help') return;
31
+
32
+ if (!config.get('apiKey')) {
33
+ console.log(banner);
34
+ console.log(chalk.yellow('No API key found. Let\'s get you logged in.\n'));
35
+ const success = await login();
36
+ if (!success) {
37
+ process.exit(1);
38
+ }
39
+ console.log();
35
40
  }
36
41
  });
37
42
 
38
- // Default command - start REPL
43
+ // Default command - start REPL with Ink TUI
39
44
  program
40
45
  .command('chat', { isDefault: true })
41
46
  .description('Start an interactive chat session')
42
47
  .option('-m, --model <model>', 'Model to use (codea, codea-pro, codea-thinking)', 'alia-v1-codea')
48
+ .option('-a, --approval-mode <mode>', 'Approval mode: suggest, auto-edit, full-auto', 'suggest')
43
49
  .option('--no-context', 'Disable automatic codebase context')
50
+ .option('--no-instructions', 'Disable CODEA.md project instructions')
44
51
  .action(async (options) => {
45
- console.log(banner);
46
52
  await startRepl(options);
47
53
  });
48
54
 
@@ -52,12 +58,26 @@ program
52
58
  .alias('r')
53
59
  .description('Run a single prompt and exit')
54
60
  .option('-m, --model <model>', 'Model to use', 'alia-v1-codea')
55
- .option('-y, --yes', 'Auto-approve all file changes')
61
+ .option('-y, --yes', 'Auto-approve all actions (full-auto mode)')
62
+ .option('-a, --approval-mode <mode>', 'Approval mode: suggest, auto-edit, full-auto', 'suggest')
63
+ .option('-q, --quiet', 'Suppress UI, output only response text')
64
+ .option('--json', 'Output structured JSON')
56
65
  .option('--no-context', 'Disable automatic codebase context')
57
66
  .action(async (prompt, options) => {
58
67
  await runPrompt(prompt, options);
59
68
  });
60
69
 
70
+ // Exec command - shorthand for run --json --quiet --yes
71
+ program
72
+ .command('exec <prompt>')
73
+ .alias('x')
74
+ .description('Execute a prompt in full-auto mode with JSON output')
75
+ .option('-m, --model <model>', 'Model to use', 'alia-v1-codea')
76
+ .option('--no-context', 'Disable automatic codebase context')
77
+ .action(async (prompt, options) => {
78
+ await runPrompt(prompt, { ...options, yes: true, quiet: false, json: true });
79
+ });
80
+
61
81
  // Login/configure
62
82
  program
63
83
  .command('login')
@@ -2,8 +2,8 @@ import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
3
  import { exec } from 'child_process';
4
4
  import { promisify } from 'util';
5
- import { glob } from 'fs/promises';
6
5
  import chalk from 'chalk';
6
+ import { applyPatch } from './patch.js';
7
7
 
8
8
  const execAsync = promisify(exec);
9
9
 
@@ -21,10 +21,12 @@ export async function executeTool(name: string, args: Record<string, any>): Prom
21
21
  return await writeFile(args.path, args.content);
22
22
  case 'edit_file':
23
23
  return await editFile(args.path, args.old_text, args.new_text);
24
+ case 'apply_patch':
25
+ return await applyPatchTool(args.patch);
24
26
  case 'list_files':
25
27
  return await listFiles(args.path, args.recursive);
26
28
  case 'search_files':
27
- return await searchFiles(args.pattern, args.path, args.file_pattern);
29
+ return await searchFiles(args.pattern, args.path, args.file_pattern, args.context_lines, args.max_results);
28
30
  case 'run_command':
29
31
  return await runCommand(args.command, args.cwd);
30
32
  default:
@@ -55,14 +57,46 @@ async function editFile(filePath: string, oldText: string, newText: string): Pro
55
57
  const absolutePath = path.resolve(process.cwd(), filePath);
56
58
  const content = await fs.readFile(absolutePath, 'utf-8');
57
59
 
58
- if (!content.includes(oldText)) {
59
- return { success: false, result: `Text not found in file: "${oldText.slice(0, 50)}..."` };
60
+ // Try exact match first
61
+ if (content.includes(oldText)) {
62
+ const newContent = content.replace(oldText, newText);
63
+ await fs.writeFile(absolutePath, newContent, 'utf-8');
64
+ return { success: true, result: `File edited: ${filePath}` };
60
65
  }
61
66
 
62
- const newContent = content.replace(oldText, newText);
63
- await fs.writeFile(absolutePath, newContent, 'utf-8');
67
+ // Try whitespace-normalized match
68
+ const normalizedOld = oldText.replace(/\s+/g, ' ').trim();
69
+ const lines = content.split('\n');
70
+ let matchStart = -1;
71
+ let matchEnd = -1;
64
72
 
65
- return { success: true, result: `File edited: ${filePath}` };
73
+ for (let i = 0; i < lines.length; i++) {
74
+ for (let j = i; j < lines.length; j++) {
75
+ const block = lines.slice(i, j + 1).join('\n');
76
+ if (block.replace(/\s+/g, ' ').trim() === normalizedOld) {
77
+ matchStart = i;
78
+ matchEnd = j;
79
+ break;
80
+ }
81
+ }
82
+ if (matchStart >= 0) break;
83
+ }
84
+
85
+ if (matchStart >= 0) {
86
+ const newLines = [...lines.slice(0, matchStart), ...newText.split('\n'), ...lines.slice(matchEnd + 1)];
87
+ await fs.writeFile(absolutePath, newLines.join('\n'), 'utf-8');
88
+ return { success: true, result: `File edited (fuzzy match): ${filePath}` };
89
+ }
90
+
91
+ return { success: false, result: `Text not found in file: "${oldText.slice(0, 50)}..."` };
92
+ }
93
+
94
+ async function applyPatchTool(patchText: string): Promise<ToolResult> {
95
+ const result = await applyPatch(patchText, process.cwd());
96
+ const summary = result.results
97
+ .map((r) => `${r.success ? '✓' : '✗'} ${r.file}: ${r.message}`)
98
+ .join('\n');
99
+ return { success: result.success, result: summary };
66
100
  }
67
101
 
68
102
  async function listFiles(dirPath: string = '.', recursive: boolean = false): Promise<ToolResult> {
@@ -98,10 +132,71 @@ async function listFiles(dirPath: string = '.', recursive: boolean = false): Pro
98
132
  }
99
133
  }
100
134
 
101
- async function searchFiles(pattern: string, dirPath: string = '.', filePattern?: string): Promise<ToolResult> {
135
+ async function searchFiles(
136
+ pattern: string,
137
+ dirPath: string = '.',
138
+ filePattern?: string,
139
+ contextLines: number = 2,
140
+ maxResults: number = 50
141
+ ): Promise<ToolResult> {
102
142
  const absolutePath = path.resolve(process.cwd(), dirPath);
143
+
144
+ // Try ripgrep first
145
+ try {
146
+ const rgArgs = [
147
+ '--json',
148
+ '-C', String(contextLines),
149
+ '-m', String(maxResults),
150
+ '--no-heading',
151
+ ];
152
+
153
+ if (filePattern) {
154
+ rgArgs.push('-g', filePattern);
155
+ }
156
+
157
+ rgArgs.push('--', pattern, absolutePath);
158
+
159
+ const { stdout } = await execAsync(`rg ${rgArgs.map(a => `'${a}'`).join(' ')}`, {
160
+ maxBuffer: 2 * 1024 * 1024,
161
+ timeout: 30000,
162
+ });
163
+
164
+ // Parse rg --json output
165
+ const results: string[] = [];
166
+ const lines = stdout.trim().split('\n');
167
+
168
+ for (const line of lines) {
169
+ try {
170
+ const data = JSON.parse(line);
171
+ if (data.type === 'match') {
172
+ const relPath = path.relative(absolutePath, data.data.path.text);
173
+ const lineNum = data.data.line_number;
174
+ const text = data.data.lines.text.trimEnd();
175
+ results.push(`${relPath}:${lineNum}: ${text}`);
176
+ } else if (data.type === 'context') {
177
+ const relPath = path.relative(absolutePath, data.data.path.text);
178
+ const lineNum = data.data.line_number;
179
+ const text = data.data.lines.text.trimEnd();
180
+ results.push(`${relPath}:${lineNum} ${text}`);
181
+ }
182
+ } catch {
183
+ // Skip malformed JSON lines
184
+ }
185
+ }
186
+
187
+ if (results.length === 0) {
188
+ return { success: true, result: 'No matches found.' };
189
+ }
190
+
191
+ return { success: true, result: results.join('\n') };
192
+ } catch {
193
+ // ripgrep not available or failed, use built-in
194
+ }
195
+
196
+ // Built-in fallback
103
197
  const regex = new RegExp(pattern, 'gi');
104
- const results: string[] = [];
198
+ const results: Array<{ file: string; line: number; text: string; isMatch: boolean }> = [];
199
+ const fileMatchCounts = new Map<string, number>();
105
200
 
106
201
  async function searchDir(dir: string) {
107
202
  const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -109,7 +204,6 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
109
204
  for (const entry of entries) {
110
205
  const fullPath = path.join(dir, entry.name);
111
206
 
112
- // Skip common ignored directories
113
207
  if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist') {
114
208
  continue;
115
209
  }
@@ -117,7 +211,6 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
117
211
  if (entry.isDirectory()) {
118
212
  await searchDir(fullPath);
119
213
  } else {
120
- // Check file pattern
121
214
  if (filePattern) {
122
215
  const ext = path.extname(entry.name);
123
216
  const patternExt = filePattern.replace('*', '');
@@ -128,14 +221,38 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
128
221
 
129
222
  try {
130
223
  const content = await fs.readFile(fullPath, 'utf-8');
224
+
225
+ // Skip binary files
226
+ if (content.includes('\0')) continue;
227
+
131
228
  const lines = content.split('\n');
229
+ const matchIndices: number[] = [];
132
230
 
133
231
  lines.forEach((line, index) => {
232
+ regex.lastIndex = 0;
134
233
  if (regex.test(line)) {
135
- const relativePath = path.relative(absolutePath, fullPath);
136
- results.push(`${relativePath}:${index + 1}: ${line.trim()}`);
234
+ matchIndices.push(index);
137
235
  }
138
236
  });
237
+
238
+ if (matchIndices.length > 0) {
239
+ const relativePath = path.relative(absolutePath, fullPath);
240
+ fileMatchCounts.set(relativePath, matchIndices.length);
241
+
242
+ for (const idx of matchIndices) {
243
+ const start = Math.max(0, idx - contextLines);
244
+ const end = Math.min(lines.length - 1, idx + contextLines);
245
+
246
+ for (let i = start; i <= end; i++) {
247
+ results.push({
248
+ file: relativePath,
249
+ line: i + 1,
250
+ text: lines[i].trimEnd(),
251
+ isMatch: i === idx,
252
+ });
253
+ }
254
+ }
255
+ }
139
256
  } catch {
140
257
  // Skip unreadable files
141
258
  }
@@ -149,7 +266,15 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
149
266
  return { success: true, result: 'No matches found.' };
150
267
  }
151
268
 
152
- return { success: true, result: results.slice(0, 100).join('\n') + (results.length > 100 ? `\n... and ${results.length - 100} more` : '') };
269
+ const formatted = results
270
+ .slice(0, maxResults * (1 + contextLines * 2))
271
+ .map((r) => `${r.file}:${r.line}${r.isMatch ? ':' : ' '} ${r.text}`)
272
+ .join('\n');
273
+
274
+ const totalMatches = Array.from(fileMatchCounts.values()).reduce((a, b) => a + b, 0);
275
+ const footer = `\n(${totalMatches} matches in ${fileMatchCounts.size} files)`;
276
+
277
+ return { success: true, result: formatted + footer };
153
278
  }
154
279
 
155
280
  async function runCommand(command: string, cwd?: string): Promise<ToolResult> {
@@ -177,6 +302,7 @@ export function formatToolCall(name: string, args: Record<string, any>): string
177
302
  read_file: 'Reading file',
178
303
  write_file: 'Writing file',
179
304
  edit_file: 'Editing file',
305
+ apply_patch: 'Applying patch',
180
306
  list_files: 'Listing files',
181
307
  search_files: 'Searching files',
182
308
  run_command: 'Running command'