Skip to content

Conversation

@katspaugh
Copy link
Member

Summary

  • When a CLI command is not recognized, asks an AI tool to suggest the correct command
  • Uses cascading fallback through AI tools: ollama → claude → copilot
  • Masks 0x addresses with placeholders before sending to AI (privacy protection)
  • Auto-detects available ollama models (prefers 2-5GB models for speed/quality balance)
  • Dynamically extracts available commands from Commander.js (no hardcoding)

Example

$ safe walletlist
error: unknown command 'walletlist'

Asking AI for suggestions...

AI suggestion:
The user probably meant:

safe wallet list

The correct command is `safe wallet list` (two words), not `safe walletlist` (one word).

AI Tool Configurations

Tool Command Model
ollama ollama run <auto-detect> --quiet Auto-detects 2-5GB model
claude claude --print --model haiku Haiku
copilot copilot --prompt --model claude-haiku-4.5 Claude Haiku 4.5

Test plan

  • Unit tests for address masking/unmasking (15 tests)
  • Manual testing with ollama, claude, and copilot
  • All 946 existing tests pass

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings December 1, 2025 01:47
@katspaugh katspaugh force-pushed the feat/ai-command-suggestions branch from d4a31e1 to 5ad25d1 Compare December 1, 2025 01:50
@katspaugh katspaugh force-pushed the feat/ai-command-suggestions branch from 5ad25d1 to e733ee3 Compare December 1, 2025 01:52
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds AI-powered command suggestions for unrecognized CLI commands, enhancing the user experience by automatically suggesting corrections when a command is not recognized. The implementation uses a cascading fallback strategy through multiple AI tools (ollama → claude → copilot) and includes privacy protection by masking Ethereum addresses before sending to external AI services.

Key Changes:

  • Implements AISuggestionService with address masking/unmasking for privacy protection
  • Adds cascading AI tool fallback with auto-detection of ollama models
  • Integrates unknown command handler in CLI that triggers AI suggestions

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 12 comments.

File Description
src/services/ai-suggestion-service.ts New service implementing AI-powered command suggestions with address privacy masking, ollama model auto-detection, and cascading fallback through multiple AI tools
src/tests/unit/services/ai-suggestion-service.test.ts Test coverage for address masking/unmasking functionality with 15 test cases covering various edge cases and roundtrip scenarios
src/cli.ts CLI integration adding unknown command handler that extracts available commands and triggers AI suggestions when commands are not recognized

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 43 to 80
private async detectOllamaModel(): Promise<string | null> {
if (this.ollamaModel !== null) {
return this.ollamaModel || null
}

try {
const output = await this.runCommand('ollama', ['list'], 5000)
const lines = output.split('\n').slice(1) // Skip header

// Parse model names and sizes, prefer smaller capable models
const models: { name: string; sizeGB: number }[] = []
for (const line of lines) {
const match = line.match(/^(\S+)\s+\S+\s+([\d.]+)\s*GB/)
if (match) {
models.push({ name: match[1], sizeGB: parseFloat(match[2]) })
}
}

if (models.length === 0) {
this.ollamaModel = ''
return null
}

// Sort by size (prefer 2-5GB models, then larger ones)
models.sort((a, b) => {
const aScore = a.sizeGB >= 2 && a.sizeGB <= 5 ? 0 : a.sizeGB < 2 ? 1 : 2
const bScore = b.sizeGB >= 2 && b.sizeGB <= 5 ? 0 : b.sizeGB < 2 ? 1 : 2
if (aScore !== bScore) return aScore - bScore
return a.sizeGB - b.sizeGB
})

this.ollamaModel = models[0].name
return this.ollamaModel
} catch {
this.ollamaModel = ''
return null
}
}
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The detectOllamaModel method lacks test coverage. Given the repository's comprehensive testing standards, consider adding tests that mock the ollama command execution and verify the model selection logic, especially the preference for 2-5GB models and the fallback behavior when no models are available.

Copilot uses AI. Check for mistakes.
src/cli.ts Outdated
Comment on lines 104 to 106
console.error('\nAI suggestion:')
console.error(suggestion)
} else {
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The catch block silently swallows all errors without logging them. For debugging purposes, consider logging the error (e.g., using a debug logger) while still showing the user-friendly fallback message. This would help troubleshoot issues with the AI suggestion feature in production.

Copilot uses AI. Check for mistakes.
*/
private async detectOllamaModel(): Promise<string | null> {
if (this.ollamaModel !== null) {
return this.ollamaModel || null
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The condition this.ollamaModel !== null followed by return this.ollamaModel || null is unnecessarily complex. When this.ollamaModel is an empty string (set at lines 62 and 77), the expression this.ollamaModel || null converts it to null, which is correct. However, this could be simplified to just return this.ollamaModel if ollamaModel is typed as string | null (which it already is). Alternatively, document why the || null coercion is needed.

Suggested change
return this.ollamaModel || null
return this.ollamaModel

Copilot uses AI. Check for mistakes.
}

/**
* Adds an unknown command handler with AI suggestions to a command.
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The async handler for 'command:*' event could potentially throw unhandled promise rejections. While there is a try-catch block inside, consider wrapping the entire handler body in a try-catch or adding .catch() to handle any unexpected errors that might occur before the try block. Alternatively, ensure Commander.js properly handles async event handlers.

Copilot uses AI. Check for mistakes.
Comment on lines 100 to 101
// Match Ethereum addresses (0x followed by 40 hex characters) or shorter hex strings starting with 0x
const addressRegex = /0x[a-fA-F0-9]+/g
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The regex /0x[a-fA-F0-9]+/g will match any hex string starting with 0x, including short values like 0x1, 0x00, or transaction data like 0xaabbccdd. This is overly broad and could mask data that isn't an address. Consider making the regex more specific (e.g., requiring at least 40 characters for Ethereum addresses) or adding validation to ensure only actual addresses are masked, not arbitrary hex data.

Suggested change
// Match Ethereum addresses (0x followed by 40 hex characters) or shorter hex strings starting with 0x
const addressRegex = /0x[a-fA-F0-9]+/g
// Match Ethereum addresses (0x followed by exactly 40 hex characters)
const addressRegex = /0x[a-fA-F0-9]{40}/g

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +108
return input.replace(addressRegex, (match) => {
// Check if we already have a mapping for this address
const existing = this.addressMappings.find((m) => m.original === match)
if (existing) {
return existing.placeholder
}
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

Using find on every regex match results in O(n²) complexity where n is the number of unique addresses. For better performance, consider using a Map<string, string> to store the address-to-placeholder mappings instead of an array, which would make lookups O(1).

Copilot uses AI. Check for mistakes.
private ollamaModel: string | null = null

/**
* Detects the best available ollama model (prefers 3B-8B models for speed/quality balance).
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The code comment says "prefers 3B-8B models" but the implementation actually prefers 2-5GB models. The comment should be corrected to match the implementation logic in lines 66-72.

Suggested change
* Detects the best available ollama model (prefers 3B-8B models for speed/quality balance).
* Detects the best available ollama model (prefers 2-5GB models for speed/quality balance).

Copilot uses AI. Check for mistakes.
*/
private async commandExists(command: string): Promise<boolean> {
return new Promise((resolve) => {
const check = spawn('which', [command], {
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The which command is not available on Windows by default. This will cause the command existence check to fail on Windows platforms. Consider using a cross-platform solution such as checking where on Windows or using a library like which from npm that handles platform differences automatically.

Copilot uses AI. Check for mistakes.
let result = response
for (const mapping of this.addressMappings) {
// Replace all occurrences of the placeholder (case-insensitive)
const regex = new RegExp(mapping.placeholder, 'gi')
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The placeholder is used directly in a RegExp constructor without escaping special regex characters. While the current implementation with placeholders like "0xAAAA" is safe, this could cause issues if the placeholder format changes. Consider escaping the placeholder using mapping.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') or a similar escaping function for safety.

Copilot uses AI. Check for mistakes.
Comment on lines 206 to 256
async getSuggestion(
invalidCommand: string,
args: string[],
availableCommands: string[]
): Promise<string | null> {
const fullCommand = [invalidCommand, ...args].join(' ')
const maskedCommand = this.maskAddresses(fullCommand)

const prompt = `I'm using a CLI tool called "safe" for managing Safe Smart Account (multisig wallets). The user tried to run this command but it was not recognized:
safe ${maskedCommand}
Available commands are:
${availableCommands.map((cmd) => `- safe ${cmd}`).join('\n')}
Based on the invalid command, suggest what the user probably meant to type. Be very concise - just provide the corrected command and a one-line explanation. If you can't determine what they meant, say so briefly.`

for (const tool of this.aiTools) {
try {
const exists = await this.commandExists(tool.command)
if (!exists) {
continue
}

let toolArgs = tool.args(prompt)

// Handle ollama model auto-detection
if (tool.name === 'ollama' && toolArgs.includes('__AUTO_DETECT__')) {
const model = await this.detectOllamaModel()
if (!model) {
continue
}
toolArgs = toolArgs.map((arg) => (arg === '__AUTO_DETECT__' ? model : arg))
}

// Show which tool is being used
process.stderr.write(`Trying ${tool.name}... `)

// Use shorter timeout for faster fallback (15s)
let response = await this.runCommand(tool.command, toolArgs, 15000)

if (response) {
// Strip ANSI escape codes (e.g., from ollama's spinner)
response = this.stripAnsi(response).trim()
const unmaskedResponse = this.unmaskAddresses(response)
process.stderr.write('done\n')
return unmaskedResponse
}
} catch {
// Tool failed, try next one
process.stderr.write('failed\n')
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The getSuggestion method, which is the core functionality of this service, lacks test coverage. Given the repository's comprehensive testing standards, consider adding tests that mock the command execution and verify the cascading fallback behavior, prompt construction, and address masking/unmasking integration.

Copilot uses AI. Check for mistakes.
@katspaugh katspaugh force-pushed the feat/ai-command-suggestions branch from e733ee3 to ec2d1f7 Compare December 1, 2025 01:56
Copilot AI review requested due to automatic review settings December 1, 2025 01:58
@katspaugh katspaugh force-pushed the feat/ai-command-suggestions branch from ec2d1f7 to 8d50cc1 Compare December 1, 2025 01:58
@katspaugh katspaugh force-pushed the feat/ai-command-suggestions branch from 8d50cc1 to 1a27a6e Compare December 1, 2025 02:00
@katspaugh katspaugh force-pushed the feat/ai-command-suggestions branch from 1a27a6e to d6059cf Compare December 1, 2025 02:02
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +44 to +46
if (this.ollamaModel !== null) {
return this.ollamaModel || null
}
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

This condition is problematic. When this.ollamaModel is an empty string (set on line 62 or 77), the condition this.ollamaModel !== null will be true, but this.ollamaModel || null will return null. This creates inconsistent behavior where the function will return null instead of properly checking if detection has already been attempted. Consider using a sentinel value or boolean flag to track whether detection has been attempted.

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +137
const letter = letters[this.placeholderIndex % letters.length]
const repeat = Math.floor(this.placeholderIndex / letters.length) + 1
this.placeholderIndex++
return `0x${letter.repeat(4 * repeat)}`
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The placeholder generation logic has a bug. For placeholderIndex >= 26, it will generate placeholders like 0xAAAAAAAA (8 characters), 0xAAAAAAAAAAAAAA (12 characters), etc. This creates variable-length placeholders. For example, when placeholderIndex = 26, repeat = 2, resulting in 0xAAAAAAAA. This might cause issues with regex matching in unmaskAddresses since the placeholders won't be consistent with the expected 4-character format (like 0xAAAA).

Suggested change
const letter = letters[this.placeholderIndex % letters.length]
const repeat = Math.floor(this.placeholderIndex / letters.length) + 1
this.placeholderIndex++
return `0x${letter.repeat(4 * repeat)}`
let n = this.placeholderIndex++
let placeholder = ''
for (let i = 0; i < 4; i++) {
placeholder = letters[n % 26] + placeholder
n = Math.floor(n / 26)
}
return `0x${placeholder}`

Copilot uses AI. Check for mistakes.
Comment on lines 89 to 118
cmd.on('command:*', async (operands: string[]) => {
const unknownCommand = operands[0]
const args = operands.slice(1)
const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand

console.error(`error: unknown command '${fullUnknown}'`)

// Try to get AI suggestion
const aiService = getAISuggestionService()
console.error('\nAsking AI for suggestions...\n')

try {
const availableCommands = getAvailableCommands(program)
const suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands)
if (suggestion) {
console.error(suggestion)
} else {
console.error(`No AI tools available. Run 'safe --help' to see available commands.`)
}
} catch {
console.error(`Run 'safe --help' to see available commands.`)
}

process.exit(1)
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The async event handler is registered but errors thrown in the catch block at line 110 won't be properly handled. The process.exit(1) at line 114 will execute regardless of whether the AI suggestion succeeds or fails. Additionally, since this is an async callback in an event handler, unhandled promise rejections could occur. Consider making this handler more robust or using .catch() on the promise.

Suggested change
cmd.on('command:*', async (operands: string[]) => {
const unknownCommand = operands[0]
const args = operands.slice(1)
const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand
console.error(`error: unknown command '${fullUnknown}'`)
// Try to get AI suggestion
const aiService = getAISuggestionService()
console.error('\nAsking AI for suggestions...\n')
try {
const availableCommands = getAvailableCommands(program)
const suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands)
if (suggestion) {
console.error(suggestion)
} else {
console.error(`No AI tools available. Run 'safe --help' to see available commands.`)
}
} catch {
console.error(`Run 'safe --help' to see available commands.`)
}
process.exit(1)
cmd.on('command:*', (operands: string[]) => {
(async () => {
const unknownCommand = operands[0]
const args = operands.slice(1)
const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand
console.error(`error: unknown command '${fullUnknown}'`)
// Try to get AI suggestion
const aiService = getAISuggestionService()
console.error('\nAsking AI for suggestions...\n')
try {
const availableCommands = getAvailableCommands(program)
const suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands)
if (suggestion) {
console.error(suggestion)
} else {
console.error(`No AI tools available. Run 'safe --help' to see available commands.`)
}
} catch {
console.error(`Run 'safe --help' to see available commands.`)
}
process.exit(1)
})().catch((err) => {
console.error('An unexpected error occurred while handling unknown command:', err)
process.exit(1)
})

Copilot uses AI. Check for mistakes.
- Use isolated directories with `XDG_CONFIG_HOME` and `XDG_DATA_HOME`
- Clean up in `afterEach` hooks
- Use `mkdtempSync()` for temporary directories
Read @CLAUDE.md
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The reference format @CLAUDE.md is non-standard. The @ symbol is typically used for mentions or npm packages, not file references. Consider using a clearer reference like See CLAUDE.md or a proper markdown link [Development Guide](CLAUDE.md).

Suggested change
Read @CLAUDE.md
Read [CLAUDE.md](CLAUDE.md)

Copilot uses AI. Check for mistakes.
Comment on lines +256 to +257
}

Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The error message is truncated to 50 characters which might cut off important diagnostic information in the middle of a word. Consider using a word boundary or adding ellipsis to indicate truncation (e.g., ${errMsg.slice(0, 50)}...). This would make it clearer to users that the message is incomplete.

Copilot uses AI. Check for mistakes.
Comment on lines +145 to +154
const check = spawn('which', [command], {
stdio: ['pipe', 'pipe', 'pipe'],
})
check.on('close', (code) => {
resolve(code === 0)
})
check.on('error', () => {
resolve(false)
})
})
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

Using which command is not cross-platform. This will fail on Windows systems where which is not available by default. Consider using where on Windows or a cross-platform solution like the which npm package, or checking for command availability by attempting to run it with a benign flag.

Suggested change
const check = spawn('which', [command], {
stdio: ['pipe', 'pipe', 'pipe'],
})
check.on('close', (code) => {
resolve(code === 0)
})
check.on('error', () => {
resolve(false)
})
})
const isWindows = process.platform === 'win32';
const cmd = isWindows ? 'where' : 'which';
const check = spawn(cmd, [command], {
stdio: ['pipe', 'pipe', 'pipe'],
});
check.on('close', (code) => {
resolve(code === 0);
});
check.on('error', () => {
resolve(false);
});
});

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +58
it('should handle many addresses and cycle through placeholders', () => {
const addresses = Array.from(
{ length: 5 },
(_, i) => `0x${(i + 1).toString().repeat(40).slice(0, 40)}`
)
const input = addresses.join(' ')
const result = service.maskAddresses(input)
expect(result).toBe('0xAAAA 0xBBBB 0xCCCC 0xDDDD 0xEEEE')
})
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The test expects 5 unique placeholders (0xAAAA through 0xEEEE), but there's no test coverage for what happens when more than 26 addresses need to be masked. Based on the generatePlaceholder implementation, the 27th address would create an 8-character placeholder (0xAAAAAAAA), which would fail the unmask logic. Consider adding a test case for this edge case or documenting the limitation.

Copilot uses AI. Check for mistakes.
src/cli.ts Outdated
Comment on lines 89 to 93
cmd.on('command:*', async (operands: string[]) => {
const unknownCommand = operands[0]
const args = operands.slice(1)
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The variable name operands is unclear in this context. Commander.js uses this term internally, but for command handlers, a more descriptive name like unknownArgs or commandParts would better convey that this contains the unrecognized command name and its arguments.

Suggested change
cmd.on('command:*', async (operands: string[]) => {
const unknownCommand = operands[0]
const args = operands.slice(1)
cmd.on('command:*', async (commandParts: string[]) => {
const unknownCommand = commandParts[0]
const args = commandParts.slice(1)

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +98
this.addressMappings = []
this.placeholderIndex = 0
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The maskAddresses method resets instance state (addressMappings and placeholderIndex) every time it's called. This makes the service stateful and not thread-safe if used concurrently. If getSuggestion is called multiple times simultaneously (though unlikely in CLI context), the mappings could be corrupted. Consider making the masking logic return both the masked string and the mappings, rather than storing them as instance state.

Copilot uses AI. Check for mistakes.
Comment on lines 120 to 126
let result = response
for (const mapping of this.addressMappings) {
// Replace all occurrences of the placeholder (case-insensitive)
const regex = new RegExp(mapping.placeholder, 'gi')
result = result.replace(regex, mapping.original)
}
return result
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The unmaskAddresses method creates a new regex and performs a string replacement for each mapping sequentially. For a large number of addresses, this could be inefficient. Consider building a single regex pattern from all placeholders and doing one pass with a replacer function, or at minimum compile the regex once per mapping if the case-insensitive flag is needed.

Suggested change
let result = response
for (const mapping of this.addressMappings) {
// Replace all occurrences of the placeholder (case-insensitive)
const regex = new RegExp(mapping.placeholder, 'gi')
result = result.replace(regex, mapping.original)
}
return result
if (!this.addressMappings || this.addressMappings.length === 0) {
return response
}
// Build a single regex that matches any placeholder, escaping special regex characters
const placeholders = this.addressMappings.map(m => m.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const regex = new RegExp(placeholders.join('|'), 'gi')
// Map for quick lookup (case-insensitive)
const mappingMap = new Map<string, string>()
for (const m of this.addressMappings) {
mappingMap.set(m.placeholder.toLowerCase(), m.original)
}
return response.replace(regex, (matched) => {
return mappingMap.get(matched.toLowerCase()) ?? matched
})

Copilot uses AI. Check for mistakes.
@katspaugh katspaugh force-pushed the feat/ai-command-suggestions branch from d6059cf to f193278 Compare December 1, 2025 02:04
Copilot AI review requested due to automatic review settings December 1, 2025 02:05
@katspaugh katspaugh force-pushed the feat/ai-command-suggestions branch from f193278 to 942959b Compare December 1, 2025 02:05
@katspaugh katspaugh force-pushed the feat/ai-command-suggestions branch 2 times, most recently from f5ea51c to b0085d2 Compare December 1, 2025 02:07
When a CLI command is not recognized, the CLI now asks an AI tool to
suggest the correct command. Features:

- Cascading fallback through AI tools: ollama → claude → copilot
- Address masking: 0x addresses are replaced with placeholders (0xAAAA,
  0xBBBB, etc.) before sending to AI and restored in the response
- Auto-detection of ollama models (prefers 2-5GB models)
- Dynamic extraction of available commands from Commander.js

AI tool configurations:
- ollama: auto-detects available model, uses --quiet flag
- claude: uses haiku model with --print flag
- copilot: uses claude-haiku-4.5 model with --prompt flag

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

src/ui/screens/AISuggestionScreen.tsx:43

  • Using array index as React key is an anti-pattern that can cause rendering issues. Since the suggestion text is static, consider using a stable key like key={line-${index}} or if lines are unique, use the line content as the key. Using index alone can lead to incorrect re-renders if the suggestion changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

private ollamaModel: string | null = null

/**
* Detects the best available ollama model (prefers 3B-8B models for speed/quality balance).
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The comment states '3B-8B models' but the actual code at line 68 uses a range of 2-5GB. This inconsistency between the comment and implementation could confuse future developers. Please update the comment to match the actual implementation: 'prefers 2-5GB models for speed/quality balance'.

Suggested change
* Detects the best available ollama model (prefers 3B-8B models for speed/quality balance).
* Detects the best available ollama model (prefers 2-5GB models for speed/quality balance).

Copilot uses AI. Check for mistakes.
let result = response
for (const mapping of this.addressMappings) {
// Replace all occurrences of the placeholder (case-insensitive)
const regex = new RegExp(mapping.placeholder, 'gi')
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The placeholder string is not escaped before being used in a RegExp constructor, which could cause issues if the placeholder contains special regex characters. While current placeholders like '0xAAAA' are safe, the 'x' character has special meaning in regex. Use new RegExp(mapping.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&'), 'gi') or a library function to escape regex special characters.

Suggested change
const regex = new RegExp(mapping.placeholder, 'gi')
const regex = new RegExp(mapping.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +22
if (onExit) {
onExit()
}
}, [onExit])
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The useEffect calls onExit immediately on render, which triggers the screen to unmount before the user can see the content. The screen displays 'Asking AI for suggestions...' but the AI suggestion has already been fetched (passed as a prop). This screen should either remove the premature onExit call or wait for user input/timeout before exiting. Compare with other success screens which correctly display content before calling onExit.

Suggested change
if (onExit) {
onExit()
}
}, [onExit])
if (!onExit) return;
const timeout = setTimeout(() => {
onExit();
}, 2000); // Show the suggestion for 2 seconds before exiting
return () => clearTimeout(timeout);
}, [onExit]);

Copilot uses AI. Check for mistakes.
Comment on lines +147 to +152
describe('getAvailableTools', () => {
it('should return an array', async () => {
const tools = await service.getAvailableTools()
expect(Array.isArray(tools)).toBe(true)
})
})
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The test for getAvailableTools only verifies that it returns an array, but doesn't test the actual functionality of detecting available AI tools. Consider adding tests that mock commandExists to verify the function correctly identifies which tools are available vs. unavailable.

Copilot uses AI. Check for mistakes.
katspaugh and others added 3 commits December 1, 2025 03:30
- Add outer try-catch wrapper around async command handler to prevent
  unhandled promise rejections
- Add clarifying comment for ollamaModel caching logic (empty string
  means no models available)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Fix comment "3B-8B models" to "2-5GB models" to match implementation
- Change address regex to only match full 40-char Ethereum addresses
  (was matching any hex string like 0x1, 0xabc which could be tx data)
- Escape placeholder string in RegExp constructor for safety
- Update test to reflect new address matching behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add `-o raw=true` to claude CLI args for clean output
- Remove stripAnsi method (no longer needed with --quiet and raw flags)
- Keep NO_COLOR env var as additional fallback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings December 1, 2025 07:55
Move ollama to the top of the AI tools fallback list since it:
- Works offline without API keys
- Has faster response times when running locally
- Avoids network latency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 12 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +200 to +217
async getSuggestion(
invalidCommand: string,
args: string[],
availableCommands: string[]
): Promise<string | null> {
const fullCommand = [invalidCommand, ...args].join(' ')
const maskedCommand = this.maskAddresses(fullCommand)

const prompt = `CLI "safe" command not recognized: safe ${maskedCommand}
Available: ${availableCommands.join(', ')}
Reply in this exact format (plain text, no markdown/backticks):
Did you mean:
safe <suggested command here>
<one line explanation>
Keep it to 2-3 lines total.`
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The prompt construction uses string interpolation with unsanitized user input (invalidCommand and args). While the AI tools are spawned with separate arguments (preventing shell injection), the user input could contain special characters or newlines that could manipulate the AI's response format.

Consider sanitizing the input or at least documenting this as a potential concern:

const fullCommand = [invalidCommand, ...args].join(' ').replace(/[\r\n]/g, ' ')
const maskedCommand = this.maskAddresses(fullCommand)

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +36
private aiTools: AITool[] = [
{
name: 'ollama',
command: 'ollama',
args: (prompt: string) => ['run', '__AUTO_DETECT__', '--quiet', prompt],
},
{
name: 'claude',
command: 'claude',
args: (prompt: string) => ['--print', '--model', 'haiku', '-o', 'raw=true', prompt],
},
{
name: 'copilot',
command: 'copilot',
args: (prompt: string) => ['--prompt', prompt, '--model', 'claude-haiku-4.5'],
},
]
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The PR description states the fallback order is "ollama → claude → copilot", but the aiTools array has them in the order claude, copilot, ollama. Since the getSuggestion method iterates through this array in order, the actual fallback order is claude → copilot → ollama, which contradicts the documented behavior.

Reorder the array to match the documented behavior:

private aiTools: AITool[] = [
  {
    name: 'ollama',
    command: 'ollama',
    args: (prompt: string) => ['run', '__AUTO_DETECT__', '--quiet', prompt],
  },
  {
    name: 'claude',
    command: 'claude',
    args: (prompt: string) => ['--print', '--model', 'haiku', '-o', 'raw=true', prompt],
  },
  {
    name: 'copilot',
    command: 'copilot',
    args: (prompt: string) => ['--prompt', prompt, '--model', 'claude-haiku-4.5'],
  },
]

Copilot uses AI. Check for mistakes.
- Use isolated directories with `XDG_CONFIG_HOME` and `XDG_DATA_HOME`
- Clean up in `afterEach` hooks
- Use `mkdtempSync()` for temporary directories
Read @CLAUDE.md
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The AGENTS.md file has been replaced with a single line redirecting to CLAUDE.md. This removes all the valuable development documentation that was previously in AGENTS.md.

If the intention is to consolidate documentation into CLAUDE.md, consider:

  1. Keeping both files with different purposes (e.g., AGENTS.md for AI agent instructions, CLAUDE.md for human developer guidance)
  2. Adding a more informative redirect message like "See CLAUDE.md for development guidelines"
  3. Documenting the reason for this change

The current change makes it unclear why AGENTS.md exists at all if it only contains a redirect.

Suggested change
Read @CLAUDE.md
# AGENTS.md Deprecated
The documentation previously found in this file has been consolidated into [CLAUDE.md](CLAUDE.md) to streamline development guidelines and reduce duplication.
Please refer to [CLAUDE.md](CLAUDE.md) for all current development documentation and agent instructions.
_Note: AGENTS.md previously contained AI agent instructions. For historical versions, see the project history._

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +72
// Sort by size (prefer 2-5GB models, then larger ones)
models.sort((a, b) => {
const aScore = a.sizeGB >= 2 && a.sizeGB <= 5 ? 0 : a.sizeGB < 2 ? 1 : 2
const bScore = b.sizeGB >= 2 && b.sizeGB <= 5 ? 0 : b.sizeGB < 2 ? 1 : 2
if (aScore !== bScore) return aScore - bScore
return a.sizeGB - b.sizeGB
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The model sorting logic is hard to understand. The scoring system (0 for 2-5GB, 1 for <2GB, 2 for >5GB) and the comment "prefer 2-5GB models, then larger ones" don't align - the code actually prefers 2-5GB models, then smaller ones (<2GB), then larger ones (>5GB).

Consider adding inline comments to clarify the logic or extracting it into a helper function with a descriptive name:

const getModelPreferenceScore = (sizeGB: number): number => {
  if (sizeGB >= 2 && sizeGB <= 5) return 0 // Most preferred: medium-sized models
  if (sizeGB < 2) return 1 // Second: smaller models
  return 2 // Least preferred: large models
}

models.sort((a, b) => {
  const scoreDiff = getModelPreferenceScore(a.sizeGB) - getModelPreferenceScore(b.sizeGB)
  return scoreDiff !== 0 ? scoreDiff : a.sizeGB - b.sizeGB
})
Suggested change
// Sort by size (prefer 2-5GB models, then larger ones)
models.sort((a, b) => {
const aScore = a.sizeGB >= 2 && a.sizeGB <= 5 ? 0 : a.sizeGB < 2 ? 1 : 2
const bScore = b.sizeGB >= 2 && b.sizeGB <= 5 ? 0 : b.sizeGB < 2 ? 1 : 2
if (aScore !== bScore) return aScore - bScore
return a.sizeGB - b.sizeGB
// Sort by preference: 2-5GB models (best), then smaller (<2GB), then larger (>5GB)
const getModelPreferenceScore = (sizeGB: number): number => {
if (sizeGB >= 2 && sizeGB <= 5) return 0 // Most preferred: medium-sized models
if (sizeGB < 2) return 1 // Next: smaller models
return 2 // Least preferred: large models
}
models.sort((a, b) => {
const scoreDiff = getModelPreferenceScore(a.sizeGB) - getModelPreferenceScore(b.sizeGB)
return scoreDiff !== 0 ? scoreDiff : a.sizeGB - b.sizeGB

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +106
return input.replace(addressRegex, (match) => {
// Check if we already have a mapping for this address
const existing = this.addressMappings.find((m) => m.original === match)
if (existing) {
return existing.placeholder
}

const placeholder = this.generatePlaceholder()
this.addressMappings.push({ placeholder, original: match })
return placeholder
})
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The address masking logic has a case-sensitivity issue. When the same address appears with different casing (e.g., 0xAbCdEf... and 0xabcdef...), they will be treated as different addresses and assigned different placeholders, even though Ethereum addresses are case-insensitive.

Consider normalizing addresses to lowercase before comparison:

return input.replace(addressRegex, (match) => {
  const normalizedMatch = match.toLowerCase()
  const existing = this.addressMappings.find((m) => m.original.toLowerCase() === normalizedMatch)
  if (existing) {
    return existing.placeholder
  }

  const placeholder = this.generatePlaceholder()
  this.addressMappings.push({ placeholder, original: match })
  return placeholder
})

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 153
import { describe, it, expect, beforeEach } from 'vitest'
import { AISuggestionService } from '../../../services/ai-suggestion-service.js'

describe('AISuggestionService', () => {
let service: AISuggestionService

beforeEach(() => {
service = new AISuggestionService()
})

describe('maskAddresses', () => {
it('should mask a single Ethereum address', () => {
const input = 'send 0x1234567890abcdef1234567890abcdef12345678 100'
const result = service.maskAddresses(input)
expect(result).toBe('send 0xAAAA 100')
})

it('should mask multiple different addresses with unique placeholders', () => {
const input =
'transfer 0x1111111111111111111111111111111111111111 to 0x2222222222222222222222222222222222222222'
const result = service.maskAddresses(input)
expect(result).toBe('transfer 0xAAAA to 0xBBBB')
})

it('should use the same placeholder for repeated addresses', () => {
const input =
'from 0x1234567890abcdef1234567890abcdef12345678 to 0x9999999999999999999999999999999999999999 via 0x1234567890abcdef1234567890abcdef12345678'
const result = service.maskAddresses(input)
expect(result).toBe('from 0xAAAA to 0xBBBB via 0xAAAA')
})

it('should not mask short hex values (only 40-char addresses)', () => {
const input = 'value 0x123 and 0xabc'
const result = service.maskAddresses(input)
expect(result).toBe('value 0x123 and 0xabc')
})

it('should return input unchanged if no addresses', () => {
const input = 'wallet list'
const result = service.maskAddresses(input)
expect(result).toBe('wallet list')
})

it('should handle empty string', () => {
const input = ''
const result = service.maskAddresses(input)
expect(result).toBe('')
})

it('should handle many addresses and cycle through placeholders', () => {
const addresses = Array.from(
{ length: 5 },
(_, i) => `0x${(i + 1).toString().repeat(40).slice(0, 40)}`
)
const input = addresses.join(' ')
const result = service.maskAddresses(input)
expect(result).toBe('0xAAAA 0xBBBB 0xCCCC 0xDDDD 0xEEEE')
})
})

describe('unmaskAddresses', () => {
it('should unmask a single address', () => {
const originalInput = 'send 0x1234567890abcdef1234567890abcdef12345678 100'
service.maskAddresses(originalInput)

const aiResponse = 'You should use: safe tx create --to 0xAAAA --value 100'
const result = service.unmaskAddresses(aiResponse)
expect(result).toBe(
'You should use: safe tx create --to 0x1234567890abcdef1234567890abcdef12345678 --value 100'
)
})

it('should unmask multiple addresses', () => {
const originalInput =
'transfer 0x1111111111111111111111111111111111111111 to 0x2222222222222222222222222222222222222222'
service.maskAddresses(originalInput)

const aiResponse = 'Try: safe tx create from 0xAAAA to 0xBBBB'
const result = service.unmaskAddresses(aiResponse)
expect(result).toBe(
'Try: safe tx create from 0x1111111111111111111111111111111111111111 to 0x2222222222222222222222222222222222222222'
)
})

it('should handle case-insensitive placeholder matching', () => {
const originalInput = 'send 0x1234567890abcdef1234567890abcdef12345678 100'
service.maskAddresses(originalInput)

const aiResponse = 'Use 0xaaaa or 0xAAAA'
const result = service.unmaskAddresses(aiResponse)
expect(result).toBe(
'Use 0x1234567890abcdef1234567890abcdef12345678 or 0x1234567890abcdef1234567890abcdef12345678'
)
})

it('should return response unchanged if no placeholders to unmask', () => {
service.maskAddresses('wallet list') // No addresses masked

const aiResponse = 'Try: safe wallet list'
const result = service.unmaskAddresses(aiResponse)
expect(result).toBe('Try: safe wallet list')
})

it('should handle response with no matching placeholders', () => {
const originalInput = 'send 0x1234567890abcdef1234567890abcdef12345678 100'
service.maskAddresses(originalInput)

const aiResponse = 'Invalid command, try safe --help'
const result = service.unmaskAddresses(aiResponse)
expect(result).toBe('Invalid command, try safe --help')
})
})

describe('mask and unmask roundtrip', () => {
it('should correctly roundtrip a complex command', () => {
const originalInput =
'account add-owner sep:0xAbCdEf1234567890AbCdEf1234567890AbCdEf12 0x9876543210fedcba9876543210fedcba98765432'
const masked = service.maskAddresses(originalInput)

expect(masked).not.toContain('0xAbCdEf1234567890AbCdEf1234567890AbCdEf12')
expect(masked).not.toContain('0x9876543210fedcba9876543210fedcba98765432')
expect(masked).toContain('0xAAAA')
expect(masked).toContain('0xBBBB')

const aiResponse = `It looks like you want to add an owner. Try:
safe account add-owner sep:0xAAAA 0xBBBB --threshold 2`

const unmasked = service.unmaskAddresses(aiResponse)
expect(unmasked).toContain('0xAbCdEf1234567890AbCdEf1234567890AbCdEf12')
expect(unmasked).toContain('0x9876543210fedcba9876543210fedcba98765432')
})

it('should preserve non-address parts of the command', () => {
const originalInput = 'tx create --to 0x1234567890123456789012345678901234567890 --value 1000'
const masked = service.maskAddresses(originalInput)

expect(masked).toBe('tx create --to 0xAAAA --value 1000')

const aiResponse = 'Use: safe tx create --to 0xAAAA --value 1000 --data 0x'
const unmasked = service.unmaskAddresses(aiResponse)

expect(unmasked).toContain('0x1234567890123456789012345678901234567890')
expect(unmasked).toContain('--value 1000')
})
})

describe('getAvailableTools', () => {
it('should return an array', async () => {
const tools = await service.getAvailableTools()
expect(Array.isArray(tools)).toBe(true)
})
})
})
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The test suite is missing tests for the main getSuggestion method, which is the core functionality of this service. While address masking/unmasking is well-tested, the actual AI tool invocation, fallback logic, and error handling are not covered.

Consider adding tests for:

  • Successful suggestion from the first available tool
  • Fallback to the next tool when one fails
  • Handling when all tools fail (returns null)
  • Ollama model auto-detection
  • Timeout handling
  • Command existence checking

These tests could use mocks to avoid depending on actual AI tools being installed.

Copilot uses AI. Check for mistakes.
Comment on lines +91 to +122
cmd.on('command:*', async (operands: string[]) => {
try {
const unknownCommand = operands[0]
const args = operands.slice(1)
const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand

// Show error immediately (use write for instant flush)
process.stderr.write(`error: unknown command '${fullUnknown}'\n\n`)
process.stderr.write('Asking AI for suggestions...\n\n')

// Try to get AI suggestion
const aiService = getAISuggestionService()
let suggestion: string | null = null

try {
const availableCommands = getAvailableCommands(program)
suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands)
} catch {
// AI suggestion failed, will show null
}

// Render the suggestion
if (suggestion) {
await renderScreen(AISuggestionScreen, { suggestion })
} else {
console.error(`No AI tools available. Run 'safe --help' to see available commands.`)
}
} catch (error) {
console.error(`Unexpected error: ${error instanceof Error ? error.message : error}`)
}

process.exit(1)
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The command:* event handler is marked as async, but Commander.js does not await async event handlers. The event handler returns a Promise that is never awaited, which means the async operations inside (fetching AI suggestions and rendering) may not complete before the process exits at line 122.

To fix this, you should ensure the async operations complete before calling process.exit(). One approach is to not make the handler async but wrap the logic in an immediately-invoked async function:

cmd.on('command:*', (operands: string[]) => {
  void (async () => {
    try {
      // ... existing async logic including process.exit(1)
    } catch (error) {
      console.error(`Unexpected error: ${error instanceof Error ? error.message : error}`)
      process.exit(1)
    }
  })()
})

This ensures Node.js doesn't exit prematurely while waiting for the async operations.

Suggested change
cmd.on('command:*', async (operands: string[]) => {
try {
const unknownCommand = operands[0]
const args = operands.slice(1)
const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand
// Show error immediately (use write for instant flush)
process.stderr.write(`error: unknown command '${fullUnknown}'\n\n`)
process.stderr.write('Asking AI for suggestions...\n\n')
// Try to get AI suggestion
const aiService = getAISuggestionService()
let suggestion: string | null = null
try {
const availableCommands = getAvailableCommands(program)
suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands)
} catch {
// AI suggestion failed, will show null
}
// Render the suggestion
if (suggestion) {
await renderScreen(AISuggestionScreen, { suggestion })
} else {
console.error(`No AI tools available. Run 'safe --help' to see available commands.`)
}
} catch (error) {
console.error(`Unexpected error: ${error instanceof Error ? error.message : error}`)
}
process.exit(1)
cmd.on('command:*', (operands: string[]) => {
void (async () => {
try {
const unknownCommand = operands[0]
const args = operands.slice(1)
const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand
// Show error immediately (use write for instant flush)
process.stderr.write(`error: unknown command '${fullUnknown}'\n\n`)
process.stderr.write('Asking AI for suggestions...\n\n')
// Try to get AI suggestion
const aiService = getAISuggestionService()
let suggestion: string | null = null
try {
const availableCommands = getAvailableCommands(program)
suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands)
} catch {
// AI suggestion failed, will show null
}
// Render the suggestion
if (suggestion) {
await renderScreen(AISuggestionScreen, { suggestion })
} else {
console.error(`No AI tools available. Run 'safe --help' to see available commands.`)
}
} catch (error) {
console.error(`Unexpected error: ${error instanceof Error ? error.message : error}`)
}
process.exit(1)
})()

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +58
const match = line.match(/^(\S+)\s+\S+\s+([\d.]+)\s*GB/)
if (match) {
models.push({ name: match[1], sizeGB: parseFloat(match[2]) })
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The regex /^(\S+)\s+\S+\s+([\d.]+)\s*GB/ for parsing ollama model list output assumes a specific format with exactly two whitespace-separated fields before the size. This may be fragile if ollama changes its output format or if model names/IDs contain special characters.

Consider making the regex more flexible and adding error handling:

const match = line.match(/(\S+)\s+.*?([\d.]+)\s*GB/)

Additionally, consider validating that parseFloat(match[2]) returns a valid number before using it.

Suggested change
const match = line.match(/^(\S+)\s+\S+\s+([\d.]+)\s*GB/)
if (match) {
models.push({ name: match[1], sizeGB: parseFloat(match[2]) })
const match = line.match(/(\S+)\s+.*?([\d.]+)\s*GB/)
if (match) {
const size = parseFloat(match[2])
if (!isNaN(size)) {
models.push({ name: match[1], sizeGB: size })
}

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +37
<Box key={index}>
<Text> </Text>
<Text color={theme.colors.primary}>{line.trim()}</Text>
</Box>
)
}
// Other lines (Did you mean, explanation, etc.)
return <Text key={index}>{line}</Text>
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

Using array index as the React key is an anti-pattern when rendering dynamic content. While it works here since the suggestion lines are static, it's better practice to use a more stable key or combine the index with the line content to ensure uniqueness.

Consider using a combination of index and content:

{suggestion.split('\n').map((line, index) => {
  const key = `${index}-${line.slice(0, 20)}`
  // ... rest of the code

Or simply use the line content itself if lines are guaranteed to be unique:

{suggestion.split('\n').map((line, index) => {
  // ... render logic with key={line}
Suggested change
<Box key={index}>
<Text> </Text>
<Text color={theme.colors.primary}>{line.trim()}</Text>
</Box>
)
}
// Other lines (Did you mean, explanation, etc.)
return <Text key={index}>{line}</Text>
<Box key={`${index}-${line.slice(0, 20)}`}>
<Text> </Text>
<Text color={theme.colors.primary}>{line.trim()}</Text>
</Box>
)
}
// Other lines (Did you mean, explanation, etc.)
return <Text key={`${index}-${line.slice(0, 20)}`}>{line}</Text>

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +148
private async commandExists(command: string): Promise<boolean> {
return new Promise((resolve) => {
const check = spawn('which', [command], {
stdio: ['pipe', 'pipe', 'pipe'],
})
check.on('close', (code) => {
resolve(code === 0)
})
check.on('error', () => {
resolve(false)
})
})
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The commandExists function uses which, which is not available on Windows systems. This will cause the AI suggestion feature to fail silently on Windows.

Consider using a cross-platform approach:

private async commandExists(command: string): Promise<boolean> {
  return new Promise((resolve) => {
    const isWindows = process.platform === 'win32'
    const checkCommand = isWindows ? 'where' : 'which'
    const check = spawn(checkCommand, [command], {
      stdio: ['pipe', 'pipe', 'pipe'],
    })
    check.on('close', (code) => {
      resolve(code === 0)
    })
    check.on('error', () => {
      resolve(false)
    })
  })
}

Copilot uses AI. Check for mistakes.
Keep masking any hex string starting with 0x (not just 40-char addresses)
since we want to mask transaction hashes, calldata, and other hex values
for privacy, not just Ethereum addresses.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@katspaugh katspaugh merged commit 5b00942 into main Dec 1, 2025
4 checks passed
@katspaugh katspaugh deleted the feat/ai-command-suggestions branch December 1, 2025 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants