Compare commits

..

3 Commits

Author SHA1 Message Date
imfozilbek
9c94335729 feat(ipuaro): add commands configuration
- Add CommandsConfigSchema with timeout option
- Integrate timeout configuration in RunCommandTool
- Add 22 new unit tests (19 schema + 3 integration)
- Complete v0.22.0 Extended Configuration milestone
2025-12-02 03:03:57 +05:00
imfozilbek
c34d57c231 chore(ipuaro): release v0.22.4 2025-12-02 02:29:56 +05:00
imfozilbek
60052c0db9 feat(ipuaro): add autocomplete configuration
- Add AutocompleteConfigSchema with enabled, source, maxSuggestions
- Update useAutocomplete hook to read from config
- Add 27 unit tests for autocomplete config
- Fix unused variable in Chat component
- Update ROADMAP and CHANGELOG
2025-12-02 02:26:36 +05:00
10 changed files with 512 additions and 24 deletions

View File

@@ -5,6 +5,95 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.22.5] - 2025-12-02 - Commands Configuration
### Added
- **CommandsConfigSchema (0.22.5)**
- New configuration schema for command settings in `src/shared/constants/config.ts`
- `timeout: number | null` (default: null) - global timeout for shell commands in milliseconds
- Integrated into main ConfigSchema with `.default({})`
- Exported `CommandsConfig` type from config module
### Changed
- **RunCommandTool**
- Added optional `config?: CommandsConfig` parameter to constructor
- Timeout priority: `params.timeout``config.timeout``DEFAULT_TIMEOUT (30000)`
- Updated parameter description to reflect configuration support
- Config-based timeout enables global command timeout without per-call specification
### Technical Details
- Total tests: 1679 passed (was 1657, +22 new tests)
- New test file: `commands-config.test.ts` with 19 tests
- Default values validation (timeout: null)
- `timeout` nullable positive integer validation (including edge cases: zero, negative, float rejection)
- Partial and full config merging tests
- Updated RunCommandTool tests: 3 new tests for configuration integration
- Config timeout behavior
- Null config timeout fallback to default
- Param timeout priority over config timeout
- Coverage: 97.64% lines, 91.36% branches, 98.77% functions, 97.64% statements
- 0 ESLint errors, 5 warnings (acceptable TUI component warnings)
- Build successful with no TypeScript errors
### Notes
This release completes the v0.22.0 Extended Configuration milestone. All items for v0.22.0 are now complete:
- ✅ 0.22.1 - Display Configuration
- ✅ 0.22.2 - Session Configuration
- ✅ 0.22.3 - Context Configuration
- ✅ 0.22.4 - Autocomplete Configuration
- ✅ 0.22.5 - Commands Configuration
---
## [0.22.4] - 2025-12-02 - Autocomplete Configuration
### Added
- **AutocompleteConfigSchema (0.22.4)**
- New configuration schema for autocomplete settings in `src/shared/constants/config.ts`
- `enabled: boolean` (default: true) - toggle autocomplete feature
- `source: "redis-index" | "filesystem" | "both"` (default: "redis-index") - autocomplete source
- `maxSuggestions: number` (default: 10) - maximum number of suggestions to display
- Integrated into main ConfigSchema with `.default({})`
- Exported `AutocompleteConfig` type from config module
### Changed
- **useAutocomplete Hook**
- Added optional `config?: AutocompleteConfig` parameter to `UseAutocompleteOptions`
- Config priority: `config``props``defaults`
- Reads `enabled` and `maxSuggestions` from config if provided
- Falls back to prop values, then to defaults
- Internal variables renamed: `enabled``isEnabled`, `maxSuggestions``maxSuggestionsCount`
- **Chat Component**
- Fixed ESLint error: removed unused `roleColor` variable in `ToolMessage` component
- Removed unused `theme` parameter from `ToolMessage` function signature
### Technical Details
- Total tests: 1657 passed (was 1630, +27 new tests)
- New test file: `autocomplete-config.test.ts` with 27 tests
- Default values validation (enabled, source, maxSuggestions)
- `enabled` boolean validation
- `source` enum validation ("redis-index", "filesystem", "both")
- `maxSuggestions` positive integer validation (including edge cases: zero, negative, float rejection)
- Partial and full config merging tests
- Coverage: 97.59% lines, 91.23% branches, 98.77% functions, 97.59% statements
- 0 ESLint errors, 5 warnings (acceptable TUI component warnings)
- Build successful with no TypeScript errors
### Notes
This release completes the fourth item (0.22.4) of the v0.22.0 Extended Configuration milestone. Remaining item for v0.22.0:
- 0.22.5 - Commands Configuration
---
## [0.22.3] - 2025-12-02 - Context Configuration
### Added

View File

@@ -1648,7 +1648,7 @@ interface DiffViewProps {
## Version 0.22.0 - Extended Configuration ⚙️
**Priority:** MEDIUM
**Status:** In Progress (3/5 complete)
**Status:** Complete (5/5 complete)
### 0.22.1 - Display Configuration ✅
@@ -1705,7 +1705,7 @@ export const ContextConfigSchema = z.object({
- [x] Configurable compression threshold
- [x] Unit tests (40 new tests: 32 schema, 8 ContextManager integration)
### 0.22.4 - Autocomplete Configuration
### 0.22.4 - Autocomplete Configuration
```typescript
// src/shared/constants/config.ts additions
@@ -1717,11 +1717,11 @@ export const AutocompleteConfigSchema = z.object({
```
**Deliverables:**
- [ ] AutocompleteConfigSchema in config.ts
- [ ] useAutocomplete reads from config
- [ ] Unit tests
- [x] AutocompleteConfigSchema in config.ts
- [x] useAutocomplete reads from config
- [x] Unit tests (27 tests)
### 0.22.5 - Commands Configuration
### 0.22.5 - Commands Configuration
```typescript
// src/shared/constants/config.ts additions
@@ -1731,13 +1731,13 @@ export const CommandsConfigSchema = z.object({
```
**Deliverables:**
- [ ] CommandsConfigSchema in config.ts
- [ ] Timeout support for run_command tool
- [ ] Unit tests
- [x] CommandsConfigSchema in config.ts
- [x] Timeout support for run_command tool
- [x] Unit tests (19 schema tests + 3 RunCommandTool integration tests)
**Tests:**
- [ ] Unit tests for all new config schemas
- [ ] Integration tests for config loading
- [x] Unit tests for CommandsConfigSchema (19 tests)
- [x] Integration tests for RunCommandTool with config (3 tests)
---

View File

@@ -1,6 +1,6 @@
{
"name": "@samiyev/ipuaro",
"version": "0.22.2",
"version": "0.22.4",
"description": "Local AI agent for codebase operations with infinite context feeling",
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
"license": "MIT",

View File

@@ -6,6 +6,7 @@ import {
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
import type { CommandsConfig } from "../../../shared/constants/config.js"
import { CommandSecurity } from "./CommandSecurity.js"
const execAsync = promisify(exec)
@@ -60,7 +61,7 @@ export class RunCommandTool implements ITool {
{
name: "timeout",
type: "number",
description: "Timeout in milliseconds (default: 30000)",
description: "Timeout in milliseconds (default: from config or 30000, max: 600000)",
required: false,
},
]
@@ -69,10 +70,12 @@ export class RunCommandTool implements ITool {
private readonly security: CommandSecurity
private readonly execFn: typeof execAsync
private readonly configTimeout: number | null
constructor(security?: CommandSecurity, execFn?: typeof execAsync) {
constructor(security?: CommandSecurity, execFn?: typeof execAsync, config?: CommandsConfig) {
this.security = security ?? new CommandSecurity()
this.execFn = execFn ?? execAsync
this.configTimeout = config?.timeout ?? null
}
validateParams(params: Record<string, unknown>): string | null {
@@ -104,7 +107,7 @@ export class RunCommandTool implements ITool {
const callId = `${this.name}-${String(startTime)}`
const command = params.command as string
const timeout = (params.timeout as number) ?? DEFAULT_TIMEOUT
const timeout = (params.timeout as number) ?? this.configTimeout ?? DEFAULT_TIMEOUT
const securityCheck = this.security.check(command)

View File

@@ -116,6 +116,22 @@ export const ContextConfigSchema = z.object({
compressionMethod: z.enum(["llm-summary", "truncate"]).default("llm-summary"),
})
/**
* Autocomplete configuration schema.
*/
export const AutocompleteConfigSchema = z.object({
enabled: z.boolean().default(true),
source: z.enum(["redis-index", "filesystem", "both"]).default("redis-index"),
maxSuggestions: z.number().int().positive().default(10),
})
/**
* Commands configuration schema.
*/
export const CommandsConfigSchema = z.object({
timeout: z.number().int().positive().nullable().default(null),
})
/**
* Full configuration schema.
*/
@@ -130,6 +146,8 @@ export const ConfigSchema = z.object({
display: DisplayConfigSchema.default({}),
session: SessionConfigSchema.default({}),
context: ContextConfigSchema.default({}),
autocomplete: AutocompleteConfigSchema.default({}),
commands: CommandsConfigSchema.default({}),
})
/**
@@ -146,6 +164,8 @@ export type InputConfig = z.infer<typeof InputConfigSchema>
export type DisplayConfig = z.infer<typeof DisplayConfigSchema>
export type SessionConfig = z.infer<typeof SessionConfigSchema>
export type ContextConfig = z.infer<typeof ContextConfigSchema>
export type AutocompleteConfig = z.infer<typeof AutocompleteConfigSchema>
export type CommandsConfig = z.infer<typeof CommandsConfigSchema>
/**
* Default configuration.

View File

@@ -120,9 +120,7 @@ function AssistantMessage({
)
}
function ToolMessage({ message, theme }: MessageComponentProps): React.JSX.Element {
const roleColor = getRoleColor("tool", theme)
function ToolMessage({ message }: MessageComponentProps): React.JSX.Element {
return (
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
{message.toolResults?.map((result) => (

View File

@@ -5,6 +5,7 @@
import { useCallback, useEffect, useState } from "react"
import type { IStorage } from "../../domain/services/IStorage.js"
import type { AutocompleteConfig } from "../../shared/constants/config.js"
import path from "node:path"
export interface UseAutocompleteOptions {
@@ -12,6 +13,7 @@ export interface UseAutocompleteOptions {
projectRoot: string
enabled?: boolean
maxSuggestions?: number
config?: AutocompleteConfig
}
export interface UseAutocompleteReturn {
@@ -107,13 +109,18 @@ function getCommonPrefix(suggestions: string[]): string {
}
export function useAutocomplete(options: UseAutocompleteOptions): UseAutocompleteReturn {
const { storage, projectRoot, enabled = true, maxSuggestions = 10 } = options
const { storage, projectRoot, enabled, maxSuggestions, config } = options
// Read from config if provided, otherwise use options, otherwise use defaults
const isEnabled = config?.enabled ?? enabled ?? true
const maxSuggestionsCount = config?.maxSuggestions ?? maxSuggestions ?? 10
const [filePaths, setFilePaths] = useState<string[]>([])
const [suggestions, setSuggestions] = useState<string[]>([])
// Load file paths from storage
useEffect(() => {
if (!enabled) {
if (!isEnabled) {
return
}
@@ -135,11 +142,11 @@ export function useAutocomplete(options: UseAutocompleteOptions): UseAutocomplet
loadPaths().catch(() => {
// Ignore errors
})
}, [storage, projectRoot, enabled])
}, [storage, projectRoot, isEnabled])
const complete = useCallback(
(partial: string): string[] => {
if (!enabled || !partial.trim()) {
if (!isEnabled || !partial.trim()) {
setSuggestions([])
return []
}
@@ -154,13 +161,13 @@ export function useAutocomplete(options: UseAutocompleteOptions): UseAutocomplet
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxSuggestions)
.slice(0, maxSuggestionsCount)
.map((item) => item.path)
setSuggestions(scored)
return scored
},
[enabled, filePaths, maxSuggestions],
[isEnabled, filePaths, maxSuggestionsCount],
)
const accept = useCallback(

View File

@@ -354,6 +354,36 @@ describe("RunCommandTool", () => {
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 5000 }))
})
it("should use config timeout", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn, { timeout: 45000 })
const ctx = createMockContext()
await toolWithMock.execute({ command: "ls" }, ctx)
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 45000 }))
})
it("should use null config timeout as default", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn, { timeout: null })
const ctx = createMockContext()
await toolWithMock.execute({ command: "ls" }, ctx)
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 30000 }))
})
it("should prefer param timeout over config timeout", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn, { timeout: 45000 })
const ctx = createMockContext()
await toolWithMock.execute({ command: "ls", timeout: 5000 }, ctx)
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 5000 }))
})
it("should execute in project root", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)

View File

@@ -0,0 +1,204 @@
/**
* Tests for AutocompleteConfigSchema.
*/
import { describe, expect, it } from "vitest"
import { AutocompleteConfigSchema } from "../../../src/shared/constants/config.js"
describe("AutocompleteConfigSchema", () => {
describe("default values", () => {
it("should use defaults when empty object provided", () => {
const result = AutocompleteConfigSchema.parse({})
expect(result).toEqual({
enabled: true,
source: "redis-index",
maxSuggestions: 10,
})
})
it("should use defaults via .default({})", () => {
const result = AutocompleteConfigSchema.default({}).parse({})
expect(result).toEqual({
enabled: true,
source: "redis-index",
maxSuggestions: 10,
})
})
})
describe("enabled", () => {
it("should accept true", () => {
const result = AutocompleteConfigSchema.parse({ enabled: true })
expect(result.enabled).toBe(true)
})
it("should accept false", () => {
const result = AutocompleteConfigSchema.parse({ enabled: false })
expect(result.enabled).toBe(false)
})
it("should reject non-boolean", () => {
expect(() => AutocompleteConfigSchema.parse({ enabled: "true" })).toThrow()
})
it("should reject number", () => {
expect(() => AutocompleteConfigSchema.parse({ enabled: 1 })).toThrow()
})
})
describe("source", () => {
it("should accept redis-index", () => {
const result = AutocompleteConfigSchema.parse({ source: "redis-index" })
expect(result.source).toBe("redis-index")
})
it("should accept filesystem", () => {
const result = AutocompleteConfigSchema.parse({ source: "filesystem" })
expect(result.source).toBe("filesystem")
})
it("should accept both", () => {
const result = AutocompleteConfigSchema.parse({ source: "both" })
expect(result.source).toBe("both")
})
it("should use default redis-index", () => {
const result = AutocompleteConfigSchema.parse({})
expect(result.source).toBe("redis-index")
})
it("should reject invalid source", () => {
expect(() => AutocompleteConfigSchema.parse({ source: "invalid" })).toThrow()
})
it("should reject non-string", () => {
expect(() => AutocompleteConfigSchema.parse({ source: 123 })).toThrow()
})
})
describe("maxSuggestions", () => {
it("should accept valid positive integer", () => {
const result = AutocompleteConfigSchema.parse({ maxSuggestions: 5 })
expect(result.maxSuggestions).toBe(5)
})
it("should accept default value", () => {
const result = AutocompleteConfigSchema.parse({ maxSuggestions: 10 })
expect(result.maxSuggestions).toBe(10)
})
it("should accept large value", () => {
const result = AutocompleteConfigSchema.parse({ maxSuggestions: 100 })
expect(result.maxSuggestions).toBe(100)
})
it("should accept 1", () => {
const result = AutocompleteConfigSchema.parse({ maxSuggestions: 1 })
expect(result.maxSuggestions).toBe(1)
})
it("should reject zero", () => {
expect(() => AutocompleteConfigSchema.parse({ maxSuggestions: 0 })).toThrow()
})
it("should reject negative number", () => {
expect(() => AutocompleteConfigSchema.parse({ maxSuggestions: -5 })).toThrow()
})
it("should reject float", () => {
expect(() => AutocompleteConfigSchema.parse({ maxSuggestions: 10.5 })).toThrow()
})
it("should reject non-number", () => {
expect(() => AutocompleteConfigSchema.parse({ maxSuggestions: "10" })).toThrow()
})
})
describe("partial config", () => {
it("should merge partial config with defaults (enabled only)", () => {
const result = AutocompleteConfigSchema.parse({
enabled: false,
})
expect(result).toEqual({
enabled: false,
source: "redis-index",
maxSuggestions: 10,
})
})
it("should merge partial config with defaults (source only)", () => {
const result = AutocompleteConfigSchema.parse({
source: "filesystem",
})
expect(result).toEqual({
enabled: true,
source: "filesystem",
maxSuggestions: 10,
})
})
it("should merge partial config with defaults (maxSuggestions only)", () => {
const result = AutocompleteConfigSchema.parse({
maxSuggestions: 20,
})
expect(result).toEqual({
enabled: true,
source: "redis-index",
maxSuggestions: 20,
})
})
it("should merge multiple partial fields", () => {
const result = AutocompleteConfigSchema.parse({
enabled: false,
maxSuggestions: 5,
})
expect(result).toEqual({
enabled: false,
source: "redis-index",
maxSuggestions: 5,
})
})
})
describe("full config", () => {
it("should accept valid full config", () => {
const config = {
enabled: false,
source: "both" as const,
maxSuggestions: 15,
}
const result = AutocompleteConfigSchema.parse(config)
expect(result).toEqual(config)
})
it("should accept all defaults explicitly", () => {
const config = {
enabled: true,
source: "redis-index" as const,
maxSuggestions: 10,
}
const result = AutocompleteConfigSchema.parse(config)
expect(result).toEqual(config)
})
it("should accept filesystem as source", () => {
const config = {
enabled: true,
source: "filesystem" as const,
maxSuggestions: 20,
}
const result = AutocompleteConfigSchema.parse(config)
expect(result).toEqual(config)
})
})
})

View File

@@ -0,0 +1,137 @@
/**
* Tests for CommandsConfigSchema.
*/
import { describe, expect, it } from "vitest"
import { CommandsConfigSchema } from "../../../src/shared/constants/config.js"
describe("CommandsConfigSchema", () => {
describe("default values", () => {
it("should use defaults when empty object provided", () => {
const result = CommandsConfigSchema.parse({})
expect(result).toEqual({
timeout: null,
})
})
it("should use defaults via .default({})", () => {
const result = CommandsConfigSchema.default({}).parse({})
expect(result).toEqual({
timeout: null,
})
})
})
describe("timeout", () => {
it("should accept null (default)", () => {
const result = CommandsConfigSchema.parse({ timeout: null })
expect(result.timeout).toBe(null)
})
it("should accept positive integer", () => {
const result = CommandsConfigSchema.parse({ timeout: 5000 })
expect(result.timeout).toBe(5000)
})
it("should accept large timeout", () => {
const result = CommandsConfigSchema.parse({ timeout: 600000 })
expect(result.timeout).toBe(600000)
})
it("should accept 1", () => {
const result = CommandsConfigSchema.parse({ timeout: 1 })
expect(result.timeout).toBe(1)
})
it("should accept small timeout", () => {
const result = CommandsConfigSchema.parse({ timeout: 100 })
expect(result.timeout).toBe(100)
})
it("should reject zero", () => {
expect(() => CommandsConfigSchema.parse({ timeout: 0 })).toThrow()
})
it("should reject negative number", () => {
expect(() => CommandsConfigSchema.parse({ timeout: -5000 })).toThrow()
})
it("should reject float", () => {
expect(() => CommandsConfigSchema.parse({ timeout: 5000.5 })).toThrow()
})
it("should reject string", () => {
expect(() => CommandsConfigSchema.parse({ timeout: "5000" })).toThrow()
})
it("should reject boolean", () => {
expect(() => CommandsConfigSchema.parse({ timeout: true })).toThrow()
})
it("should reject undefined (use null instead)", () => {
const result = CommandsConfigSchema.parse({ timeout: undefined })
expect(result.timeout).toBe(null)
})
})
describe("partial config", () => {
it("should use default null when timeout not provided", () => {
const result = CommandsConfigSchema.parse({})
expect(result).toEqual({
timeout: null,
})
})
it("should accept explicit null", () => {
const result = CommandsConfigSchema.parse({
timeout: null,
})
expect(result).toEqual({
timeout: null,
})
})
it("should accept explicit timeout value", () => {
const result = CommandsConfigSchema.parse({
timeout: 10000,
})
expect(result).toEqual({
timeout: 10000,
})
})
})
describe("full config", () => {
it("should accept valid config with null", () => {
const config = {
timeout: null,
}
const result = CommandsConfigSchema.parse(config)
expect(result).toEqual(config)
})
it("should accept valid config with timeout", () => {
const config = {
timeout: 30000,
}
const result = CommandsConfigSchema.parse(config)
expect(result).toEqual(config)
})
it("should accept default explicitly", () => {
const config = {
timeout: null,
}
const result = CommandsConfigSchema.parse(config)
expect(result).toEqual(config)
})
})
})