From 8e4246745f949c0dad14678f3c81f4557347bf32 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 10 Jul 2026 22:31:05 -0400 Subject: [PATCH 1/7] feat(memories): store memories in markdown Add a local, inspectable memory system for `code-ollama` using Markdown files under `~/.code-ollama/memories/`. Start with manual memory only; no automatic saving, no database, and no semantic retrieval in v1. --- Changes - Add memory storage under: ```text ~/.code-ollama/memories/ global/ MEMORY.md projects/ -/ MEMORY.md metadata.json ``` - Derive project memory ID from the normalized git remote URL when available; fall back to the git root absolute path. Use a readable slug plus a 12-character hash. - Load bounded memory into the system prompt: - global `MEMORY.md` - current project `MEMORY.md` - cap each at first 200 lines or 25KB, whichever comes first. - Treat `MEMORY.md` as a concise startup summary plus table of contents to relative topic files. - Do not automatically load topic files like `debugging.md` or `conventions.md`; the agent may read them with normal file tools when relevant. --- CLI/TUI Behavior - Add a `/memory` command group: - `/memory show` displays loaded global and project memory. - `/memory add ` appends a bullet to the project `MEMORY.md`. - `/memory add --global ` appends a bullet to global `MEMORY.md`. - `/memory edit` opens or prints the project memory path, depending on existing editor behavior in the app. - `/memory path` shows the active memory directory and files. - If memory files do not exist, create them lazily on first write. - Do not save secrets, transient branch facts, command transcripts, or speculative conclusions unless the user explicitly adds them. --- Implementation Notes - Add a `src/utils/memory.ts` module for: - project ID derivation - memory path resolution - bounded Markdown reads - append operations - metadata creation - Update system prompt construction in `src/utils/agents.ts` to include loaded memory after `AGENTS.md` and before skills. - Add memory command handling alongside existing slash-command/menu handling, following current TUI command patterns. - Keep the config shape minimal; no config required for v1 unless memory needs a disable flag already matching existing config style. --- src/components/Chat/Chat.test.tsx | 181 +++++++++++++- src/components/Chat/Chat.tsx | 62 ++++- src/constants/command.ts | 1 + src/utils/agents.test.ts | 17 ++ src/utils/agents.ts | 6 + src/utils/index.ts | 1 + src/utils/memory.test.ts | 389 ++++++++++++++++++++++++++++++ src/utils/memory.ts | 241 ++++++++++++++++++ src/utils/skills.test.ts | 26 ++ 9 files changed, 922 insertions(+), 2 deletions(-) create mode 100644 src/utils/memory.test.ts create mode 100644 src/utils/memory.ts diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index 77f039ae..ed12c792 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -3,7 +3,7 @@ import { Text } from 'ink'; import { prewarmCodeBlocks } from '@/components/CodeBlock'; import { DECISION, MODE, PROMPT, THEME } from '@/constants'; import type { Decision, ToolResult } from '@/types'; -import { ollama, time, tools } from '@/utils'; +import { agents, memory, ollama, time, tools } from '@/utils'; import { renderWithTheme } from '@/utils/testing'; const mockState = vi.hoisted(() => ({ @@ -58,6 +58,15 @@ const toolMocks = vi.hoisted(() => ({ executeTool: vi.fn(), runShell: vi.fn(), })); +const memoryMocks = vi.hoisted(() => ({ + appendMemory: vi.fn(), + getMemoryPathSummary: vi.fn(), + showMemory: vi.fn(), +})); +const agentMocks = vi.hoisted(() => ({ + resetSystemMessage: vi.fn(), + withSystemMessage: vi.fn((messages: unknown[]) => messages), +})); const clearScreen = vi.hoisted(() => vi.fn()); vi.mock('@inkjs/ui', async () => { @@ -100,6 +109,15 @@ vi.mock('@/components/CodeBlock', async () => ({ vi.mock('@/utils', async () => ({ ...(await vi.importActual('@/utils')), + agents: { + resetSystemMessage: agentMocks.resetSystemMessage, + withSystemMessage: agentMocks.withSystemMessage, + }, + memory: { + appendMemory: memoryMocks.appendMemory, + getMemoryPathSummary: memoryMocks.getMemoryPathSummary, + showMemory: memoryMocks.showMemory, + }, ollama: { streamChat: vi.fn().mockImplementation(function* () { yield { type: 'content', content: 'Mocked' }; @@ -237,6 +255,20 @@ function resetChatMocks() { vi.mocked(ollama.hasUncalledToolIntent).mockReturnValue(false); vi.mocked(tools.executeTool).mockReset(); vi.mocked(tools.runShell).mockReset(); + memoryMocks.appendMemory.mockReset(); + memoryMocks.getMemoryPathSummary.mockReset(); + memoryMocks.showMemory.mockReset(); + agentMocks.resetSystemMessage.mockReset(); + agentMocks.withSystemMessage.mockImplementation( + (messages: unknown[]) => messages, + ); + memoryMocks.appendMemory.mockReturnValue( + '/home/user/.code-ollama/memories/projects/repo-abc/MEMORY.md', + ); + memoryMocks.getMemoryPathSummary.mockReturnValue( + 'Project memory: /home/user/.code-ollama/memories/projects/repo-abc/MEMORY.md', + ); + memoryMocks.showMemory.mockReturnValue('Loaded memory:\n- Use Vitest.'); vi.mocked(tools.executeToolCall).mockImplementation((toolCall) => tools.executeTool(toolCall.function.name, toolCall.function.arguments), ); @@ -447,6 +479,153 @@ describe('Chat', () => { expect(onCommand).toHaveBeenCalledWith('/models'); }); + it('shows memory without calling the LLM', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory show'); + rerender(chat); + await time.tick(); + + expect(memory.showMemory).toHaveBeenCalled(); + expect(ollama.streamChat).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('Use Vitest.'); + }); + + it('shows memory paths for /memory path', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory path'); + rerender(chat); + await time.tick(); + + expect(memory.getMemoryPathSummary).toHaveBeenCalled(); + expect(lastFrame()).toContain('Project memory:'); + }); + + it('adds project memory and resets the cached system message', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory add Use Vitest.'); + rerender(chat); + await time.tick(); + + expect(memory.appendMemory).toHaveBeenCalledWith('Use Vitest.', { + scope: 'project', + }); + expect(agents.resetSystemMessage).toHaveBeenCalled(); + expect(lastFrame()).toContain('Memory saved to'); + }); + + it('adds global memory with the --global flag', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory add --global Use Vitest globally.'); + rerender(chat); + await time.tick(); + + expect(memory.appendMemory).toHaveBeenCalledWith('Use Vitest globally.', { + scope: 'global', + }); + expect(lastFrame()).toContain('Memory saved to'); + expect(ollama.streamChat).not.toHaveBeenCalled(); + }); + + it('shows usage for an unknown memory subcommand', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory unknown-cmd'); + rerender(chat); + await time.tick(); + + expect(lastFrame()).toContain('Unknown memory command.'); + expect(ollama.streamChat).not.toHaveBeenCalled(); + }); + + it('shows an error message when the memory command throws an Error', async () => { + memoryMocks.appendMemory.mockImplementation(() => { + throw new Error('Disk full'); + }); + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory add some note'); + rerender(chat); + await time.tick(); + + expect(lastFrame()).toContain('Memory command failed: Disk full'); + expect(ollama.streamChat).not.toHaveBeenCalled(); + }); + + it('shows a stringified error when the memory command throws a non-Error', async () => { + memoryMocks.appendMemory.mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'quota exceeded'; + }); + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory add some note'); + rerender(chat); + await time.tick(); + + expect(lastFrame()).toContain('Memory command failed: quota exceeded'); + expect(ollama.streamChat).not.toHaveBeenCalled(); + }); + it('runs a shell command locally without calling the LLM', async () => { toolMocks.runShell.mockResolvedValue({ content: 'file1.txt\nfile2.txt' }); const onCommand = vi.fn(); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index d282187c..20484e25 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -14,7 +14,7 @@ import { PlanReview } from '@/components/PlanReview'; import { ToolApproval } from '@/components/ToolApproval'; import { DECISION, MODE, ROLE, THEME, UI } from '@/constants'; import type { Decision, Mode, ThemeDefinition } from '@/types'; -import { ollama, tools } from '@/utils'; +import { agents, memory, ollama, tools } from '@/utils'; import { ChatInput, type SubmittedInput } from './ChatInput'; import { ChatActionType, InterruptReason } from './constants'; @@ -33,6 +33,40 @@ interface Props { theme?: ThemeDefinition; } +function getMemoryCommandResult(command: string): string { + const [, subcommand = 'show', ...args] = command.split(/\s+/); + + switch (subcommand) { + case 'show': + return memory.showMemory(); + + case 'path': + case 'edit': + return memory.getMemoryPathSummary(); + + case 'add': { + const isGlobal = args[0] === '--global'; + const text = (isGlobal ? args.slice(1) : args).join(' ').trim(); + const path = memory.appendMemory(text, { + scope: isGlobal ? 'global' : 'project', + }); + agents.resetSystemMessage(); + return `Memory saved to ${path}`; + } + + default: + return [ + 'Unknown memory command.', + 'Usage:', + '/memory show', + '/memory path', + '/memory edit', + '/memory add ', + '/memory add --global ', + ].join('\n'); + } +} + export function Chat({ initialMessages, model, @@ -250,6 +284,32 @@ export function Chat({ return; } + if ( + (userContent === '/memory' || userContent.startsWith('/memory ')) && + !images?.length + ) { + try { + dispatch({ + type: ChatActionType.AppendMessage, + message: { + role: ROLE.SYSTEM, + content: getMemoryCommandResult(userContent), + }, + }); + } catch (error) { + dispatch({ + type: ChatActionType.AppendMessage, + message: { + role: ROLE.SYSTEM, + content: `Memory command failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }); + } + return; + } + if (userContent.startsWith('/')) { onCommand(userContent); return; diff --git a/src/constants/command.ts b/src/constants/command.ts index 9c14fa1d..9a6f7e59 100644 --- a/src/constants/command.ts +++ b/src/constants/command.ts @@ -6,6 +6,7 @@ export const LIST: CommandList[] = [ { name: '/sessions', description: 'manage sessions' }, { name: '/models', description: 'manage Ollama models' }, { name: '/mcp', description: 'show MCP server status' }, + { name: '/memory', description: 'show or update local memory' }, { name: '/skills', description: 'show loaded skills' }, { name: '/theme', description: 'change the theme' }, { name: '/search', description: 'configure web search' }, diff --git a/src/utils/agents.test.ts b/src/utils/agents.test.ts index 283acf96..d1047755 100644 --- a/src/utils/agents.test.ts +++ b/src/utils/agents.test.ts @@ -11,6 +11,7 @@ const loadSkills = vi.hoisted(() => vi.fn<() => Skill[]>(() => [])); const loadConfig = vi.hoisted(() => vi.fn(() => ({ disabledSkills: [] as string[] })), ); +const loadMemoryForPrompt = vi.hoisted(() => vi.fn<() => string | null>()); vi.mock('./skills', async () => { const actual = await vi.importActual('./skills'); @@ -25,9 +26,14 @@ vi.mock('./config', () => ({ loadConfig, })); +vi.mock('./memory', () => ({ + loadMemoryForPrompt, +})); + describe('agents', () => { beforeEach(() => { loadSkills.mockReturnValue([]); + loadMemoryForPrompt.mockReturnValue(null); }); it('creates system message with base prompt', async () => { @@ -83,6 +89,17 @@ describe('agents', () => { expect(prompt).toContain('Review pull requests'); }); + it('includes loaded memory when available', async () => { + vi.mocked(existsSync).mockReturnValue(false); + loadMemoryForPrompt.mockReturnValue('Loaded memory:\nProject fact'); + + const { buildSystemPrompt } = await import('./agents'); + const prompt = buildSystemPrompt(); + + expect(prompt).toContain('Loaded memory:'); + expect(prompt).toContain('Project fact'); + }); + it('omits skills section when no skills are loaded', async () => { vi.mocked(existsSync).mockReturnValue(false); loadConfig.mockReturnValue({ disabledSkills: [] }); diff --git a/src/utils/agents.ts b/src/utils/agents.ts index eae876d4..67942341 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { PROMPT, ROLE } from '@/constants'; import { loadConfig } from './config'; +import { loadMemoryForPrompt } from './memory'; import type { Message } from './ollama'; import { formatSkillsForPrompt, loadSkills } from './skills'; @@ -32,6 +33,11 @@ export function buildSystemPrompt(): string { parts.push('\n\nProject context from AGENTS.md:\n', agentsContent); } + const memoryContent = loadMemoryForPrompt(); + if (memoryContent) { + parts.push('\n\n', memoryContent); + } + const config = loadConfig(); const allSkills = loadSkills({ disabledSkills: config.disabledSkills }); const enabledSkills = allSkills.filter((skill) => !skill.isDisabled); diff --git a/src/utils/index.ts b/src/utils/index.ts index 0f362bd0..442a3374 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,6 +2,7 @@ export * as agents from './agents'; export * as clipboard from './clipboard'; export * as config from './config'; export * as mcp from './mcp'; +export * as memory from './memory'; export * as node from './node'; export * as ollama from './ollama'; export * as screen from './screen'; diff --git a/src/utils/memory.test.ts b/src/utils/memory.test.ts new file mode 100644 index 00000000..7e755e58 --- /dev/null +++ b/src/utils/memory.test.ts @@ -0,0 +1,389 @@ +import { execFileSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; + +const mockConfig = vi.hoisted(() => ({ + directory: `/tmp/code-ollama-memory-test-${String(Date.now())}`, +})); + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +vi.mock('@/constants', () => ({ + CONFIG: { + DIRECTORY: mockConfig.directory, + }, +})); + +describe('memory', () => { + beforeEach(() => { + vi.clearAllMocks(); + rmSync(mockConfig.directory, { recursive: true, force: true }); + }); + + afterAll(() => { + rmSync(mockConfig.directory, { recursive: true, force: true }); + }); + + it('uses the normalized git remote for stable project ids', async () => { + vi.mocked(execFileSync).mockImplementation((command, args, options) => { + const gitArgs = args as string[]; + const cwd = (options as { cwd: string }).cwd; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return cwd.includes('moved') + ? '/Users/mark/moved/code-ollama\n' + : '/Users/mark/Code/npm/code-ollama\n'; + } + + if (gitArgs.join(' ') === 'config --get remote.origin.url') { + return 'git@github.com:owner/code-ollama.git\n'; + } + + throw new Error(`Unexpected command: ${command}`); + }); + + const { getMemoryPaths } = await import('./memory'); + + const first = getMemoryPaths('/Users/mark/Code/npm/code-ollama'); + const moved = getMemoryPaths('/Users/mark/moved/code-ollama'); + + expect(first.project.id).toBe(moved.project.id); + expect(first.project.id).toMatch(/^code-ollama-[a-f0-9]{12}$/); + expect(first.project.key).toBe('remote:github.com/owner/code-ollama'); + }); + + it('falls back to the git root path when no remote exists', async () => { + vi.mocked(execFileSync).mockImplementation((_, args, options) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return `${(options as { cwd: string }).cwd}\n`; + } + + throw new Error('no remote'); + }); + + const { getMemoryPaths } = await import('./memory'); + + const first = getMemoryPaths('/tmp/one/api'); + const second = getMemoryPaths('/tmp/two/api'); + + expect(first.project.id).toMatch(/^api-[a-f0-9]{12}$/); + expect(second.project.id).toMatch(/^api-[a-f0-9]{12}$/); + expect(first.project.id).not.toBe(second.project.id); + expect(first.project.key).toBe('path:/tmp/one/api'); + }); + + it('loads only bounded global and project memory', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + if (gitArgs.join(' ') === 'config --get remote.origin.url') { + return 'https://github.com/owner/repo.git\n'; + } + + throw new Error('unexpected git command'); + }); + + const { getMemoryPaths, loadMemoryForPrompt, MEMORY_LIMITS } = + await import('./memory'); + const paths = getMemoryPaths('/repo'); + + mkdirSync(paths.globalDirectory, { recursive: true }); + mkdirSync(paths.project.directory, { recursive: true }); + writeFileSync(paths.globalMemoryPath, 'global fact\n', 'utf8'); + const projectContent = Array.from( + { length: MEMORY_LIMITS.maxLines + 1 }, + (_, index) => `project line ${String(index + 1)}`, + ).join('\n'); + writeFileSync(paths.projectMemoryPath, projectContent, 'utf8'); + + const prompt = loadMemoryForPrompt('/repo') ?? ''; + + expect(prompt).toContain('Loaded memory:'); + expect(prompt).toContain('global fact'); + expect(prompt).toContain('project line 200'); + expect(prompt).not.toContain('project line 201'); + }); + + it('falls back to project slug when remote basename is empty', async () => { + const { MEMORY_TEST_ONLY } = await import('./memory'); + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('git not found'); + }); + + expect(MEMORY_TEST_ONLY.slugify('---')).toBe('project'); + }); + + it('falls back to project slug when remote normalizes to an empty basename', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + if (gitArgs.join(' ') === 'config --get remote.origin.url') { + return 'ssh:///\n'; + } + + throw new Error('unexpected git command'); + }); + + const { getMemoryPaths } = await import('./memory'); + const paths = getMemoryPaths('/repo'); + + expect(paths.project.slug).toBe('project'); + }); + + it('falls back to cwd when git is not available', async () => { + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('git not found'); + }); + + const { getMemoryPaths } = await import('./memory'); + const paths = getMemoryPaths('/no-git-here'); + + expect(paths.project.gitRoot).toBe('/no-git-here'); + expect(paths.project.key).toBe('path:/no-git-here'); + }); + + it('returns a path summary string', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + throw new Error('no remote'); + }); + + const { getMemoryPathSummary } = await import('./memory'); + const summary = getMemoryPathSummary('/repo'); + + expect(summary).toContain('Global memory:'); + expect(summary).toContain('Project memory:'); + expect(summary).toContain('Project metadata:'); + }); + + it('returns null when neither global nor project memory file exists', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + throw new Error('no remote'); + }); + + const { loadMemoryForPrompt } = await import('./memory'); + expect(loadMemoryForPrompt('/repo')).toBeNull(); + }); + + it('showMemory returns fallback message when no memory files exist', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + throw new Error('no remote'); + }); + + const { showMemory } = await import('./memory'); + const result = showMemory('/repo'); + + expect(result).toContain('No memory found.'); + expect(result).toContain('Global memory:'); + }); + + it('throws when appendMemory is called with empty text', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + throw new Error('no remote'); + }); + + const { appendMemory } = await import('./memory'); + + expect(() => appendMemory(' ')).toThrow('Memory text is required.'); + }); + + it('renders only global memory when project memory is absent', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + throw new Error('no remote'); + }); + + const { getMemoryPaths, loadMemoryForPrompt } = await import('./memory'); + const paths = getMemoryPaths('/repo'); + + mkdirSync(paths.globalDirectory, { recursive: true }); + writeFileSync(paths.globalMemoryPath, 'global only\n', 'utf8'); + + const result = loadMemoryForPrompt('/repo') ?? ''; + + expect(result).toContain('global only'); + expect(result).not.toContain('Project memory'); + }); + + it('renders only project memory when global memory is absent', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + if (gitArgs.join(' ') === 'config --get remote.origin.url') { + return 'https://github.com/owner/repo.git\n'; + } + + throw new Error('unexpected git command'); + }); + + const { getMemoryPaths, loadMemoryForPrompt } = await import('./memory'); + const paths = getMemoryPaths('/repo'); + + mkdirSync(paths.project.directory, { recursive: true }); + writeFileSync(paths.projectMemoryPath, 'project only\n', 'utf8'); + + const result = loadMemoryForPrompt('/repo') ?? ''; + + expect(result).toContain('project only'); + expect(result).not.toContain('Global memory'); + }); + + it('writes metadata without gitRemote when no remote is configured', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + throw new Error('no remote'); + }); + + const { appendMemory, getMemoryPaths } = await import('./memory'); + const paths = getMemoryPaths('/repo'); + + appendMemory('Local note.', { cwd: '/repo' }); + + const metadata = JSON.parse( + readFileSync(paths.projectMetadataPath, 'utf8'), + ) as Record; + + expect(metadata).not.toHaveProperty('gitRemote'); + expect(metadata).toHaveProperty('gitRoot', '/repo'); + }); + + it('returns null from readBoundedMarkdown when file is unreadable', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + throw new Error('no remote'); + }); + + const { getMemoryPaths, loadMemoryForPrompt } = await import('./memory'); + const paths = getMemoryPaths('/repo'); + + mkdirSync(paths.globalDirectory, { recursive: true }); + writeFileSync(paths.globalMemoryPath, 'global fact\n', 'utf8'); + chmodSync(paths.globalMemoryPath, 0); + + expect(loadMemoryForPrompt('/repo')).toBeNull(); + + chmodSync(paths.globalMemoryPath, 0o644); + }); + + it('ensureMemoryFile and writeProjectMetadata are idempotent on second append', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + if (gitArgs.join(' ') === 'config --get remote.origin.url') { + return 'https://github.com/owner/repo.git\n'; + } + + throw new Error('unexpected git command'); + }); + + const { appendMemory, getMemoryPaths } = await import('./memory'); + const paths = getMemoryPaths('/repo'); + + appendMemory('First note.', { cwd: '/repo' }); + appendMemory('Second note.', { cwd: '/repo' }); + appendMemory('First global.', { cwd: '/repo', scope: 'global' }); + appendMemory('Second global.', { cwd: '/repo', scope: 'global' }); + + expect(readFileSync(paths.projectMemoryPath, 'utf8')).toContain( + '- Second note.', + ); + expect(readFileSync(paths.globalMemoryPath, 'utf8')).toContain( + '- Second global.', + ); + expect(existsSync(paths.projectMetadataPath)).toBe(true); + }); + + it('appends project and global memory lazily', async () => { + vi.mocked(execFileSync).mockImplementation((_, args) => { + const gitArgs = args as string[]; + + if (gitArgs.join(' ') === 'rev-parse --show-toplevel') { + return '/repo\n'; + } + + if (gitArgs.join(' ') === 'config --get remote.origin.url') { + return 'https://github.com/owner/repo.git\n'; + } + + throw new Error('unexpected git command'); + }); + + const { appendMemory, getMemoryPaths } = await import('./memory'); + const paths = getMemoryPaths('/repo'); + + appendMemory('Use Vitest.', { cwd: '/repo' }); + appendMemory('Prefer small changes.', { cwd: '/repo', scope: 'global' }); + + expect(readFileSync(paths.projectMemoryPath, 'utf8')).toContain( + '- Use Vitest.', + ); + expect(readFileSync(paths.globalMemoryPath, 'utf8')).toContain( + '- Prefer small changes.', + ); + expect(existsSync(paths.projectMetadataPath)).toBe(true); + }); +}); diff --git a/src/utils/memory.ts b/src/utils/memory.ts new file mode 100644 index 00000000..28b1114d --- /dev/null +++ b/src/utils/memory.ts @@ -0,0 +1,241 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, join } from 'node:path'; + +import { CONFIG } from '@/constants'; + +const MEMORIES_DIRECTORY = join(CONFIG.DIRECTORY, 'memories'); +const GLOBAL_MEMORY_DIRECTORY = join(MEMORIES_DIRECTORY, 'global'); +const PROJECTS_MEMORY_DIRECTORY = join(MEMORIES_DIRECTORY, 'projects'); +const MEMORY_FILE = 'MEMORY.md'; +const METADATA_FILE = 'metadata.json'; +const MAX_MEMORY_LINES = 200; +const MAX_MEMORY_BYTES = 25 * 1024; +const HASH_LENGTH = 12; + +export interface ProjectMemoryIdentity { + id: string; + slug: string; + hash: string; + key: string; + gitRoot: string; + gitRemote?: string; + directory: string; +} + +export interface MemoryPaths { + globalDirectory: string; + globalMemoryPath: string; + project: ProjectMemoryIdentity; + projectMemoryPath: string; + projectMetadataPath: string; +} + +function runGit(args: string[], cwd: string): string | null { + try { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; + } +} + +function normalizeRemote(remote: string): string { + return remote + .trim() + .replace(/^ssh:\/\//, '') + .replace(/^git@([^:]+):/, '$1/') + .replace(/^https?:\/\//, '') + .replace(/\.git$/, '') + .replace(/\/+$/, '') + .toLowerCase(); +} + +function getRemoteSlug(remote: string): string { + const normalized = normalizeRemote(remote); + return basename(normalized) || 'project'; +} + +function slugify(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return slug || 'project'; +} + +function hashKey(key: string): string { + return createHash('sha256').update(key).digest('hex').slice(0, HASH_LENGTH); +} + +function resolveProjectIdentity(cwd = process.cwd()): ProjectMemoryIdentity { + const gitRoot = runGit(['rev-parse', '--show-toplevel'], cwd) ?? cwd; + const gitRemote = runGit(['config', '--get', 'remote.origin.url'], gitRoot); + const key = gitRemote + ? `remote:${normalizeRemote(gitRemote)}` + : `path:${gitRoot}`; + const slugSource = gitRemote ? getRemoteSlug(gitRemote) : basename(gitRoot); + const slug = slugify(slugSource); + const hash = hashKey(key); + const id = `${slug}-${hash}`; + const directory = join(PROJECTS_MEMORY_DIRECTORY, id); + + return { + id, + slug, + hash, + key, + gitRoot, + ...(gitRemote ? { gitRemote } : {}), + directory, + }; +} + +function readBoundedMarkdown(path: string): string | null { + if (!existsSync(path)) { + return null; + } + + try { + const content = readFileSync(path, 'utf8'); + const boundedBytes = content.slice(0, MAX_MEMORY_BYTES); + return boundedBytes + .split('\n') + .slice(0, MAX_MEMORY_LINES) + .join('\n') + .trim(); + } catch { + return null; + } +} + +function ensureMemoryFile(path: string, title: string): void { + if (existsSync(path)) { + return; + } + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `# ${title}\n\n`, 'utf8'); +} + +function writeProjectMetadata(paths: MemoryPaths): void { + if (existsSync(paths.projectMetadataPath)) { + return; + } + + mkdirSync(paths.project.directory, { recursive: true }); + writeFileSync( + paths.projectMetadataPath, + JSON.stringify( + { + id: paths.project.id, + name: paths.project.slug, + gitRoot: paths.project.gitRoot, + ...(paths.project.gitRemote + ? { gitRemote: paths.project.gitRemote } + : {}), + createdAt: new Date().toISOString(), + }, + null, + 2, + ) + '\n', + 'utf8', + ); +} + +export function getMemoryPaths(cwd = process.cwd()): MemoryPaths { + const project = resolveProjectIdentity(cwd); + + return { + globalDirectory: GLOBAL_MEMORY_DIRECTORY, + globalMemoryPath: join(GLOBAL_MEMORY_DIRECTORY, MEMORY_FILE), + project, + projectMemoryPath: join(project.directory, MEMORY_FILE), + projectMetadataPath: join(project.directory, METADATA_FILE), + }; +} + +export function getMemoryPathSummary(cwd = process.cwd()): string { + const paths = getMemoryPaths(cwd); + + return [ + `Global memory: ${paths.globalMemoryPath}`, + `Project memory: ${paths.projectMemoryPath}`, + `Project metadata: ${paths.projectMetadataPath}`, + ].join('\n'); +} + +export function loadMemoryForPrompt(cwd = process.cwd()): string | null { + const paths = getMemoryPaths(cwd); + const globalMemory = readBoundedMarkdown(paths.globalMemoryPath); + const projectMemory = readBoundedMarkdown(paths.projectMemoryPath); + + if (!globalMemory && !projectMemory) { + return null; + } + + return [ + 'Loaded memory:', + 'These are durable user and project notes. Treat them as context, not hard configuration. Topic files referenced from MEMORY.md are not loaded unless you read them explicitly.', + globalMemory ? `--- Global memory ---\n${globalMemory}` : '', + projectMemory + ? `--- Project memory (${paths.project.id}) ---\n${projectMemory}` + : '', + ] + .filter(Boolean) + .join('\n\n'); +} + +export function showMemory(cwd = process.cwd()): string { + return ( + loadMemoryForPrompt(cwd) ?? `No memory found.\n${getMemoryPathSummary(cwd)}` + ); +} + +export function appendMemory( + text: string, + options: { cwd?: string; scope?: 'global' | 'project' } = {}, +): string { + const normalizedText = text.trim(); + if (!normalizedText) { + throw new Error('Memory text is required.'); + } + + const paths = getMemoryPaths(options.cwd); + const isGlobal = options.scope === 'global'; + const memoryPath = isGlobal + ? paths.globalMemoryPath + : paths.projectMemoryPath; + + ensureMemoryFile(memoryPath, isGlobal ? 'Global Memory' : 'Project Memory'); + + if (!isGlobal) { + writeProjectMetadata(paths); + } + + appendFileSync(memoryPath, `- ${normalizedText}\n`, 'utf8'); + + return memoryPath; +} + +export const MEMORY_LIMITS = { + maxLines: MAX_MEMORY_LINES, + maxBytes: MAX_MEMORY_BYTES, +}; + +export const MEMORY_TEST_ONLY = { + normalizeRemote, + slugify, + hashKey, +}; diff --git a/src/utils/skills.test.ts b/src/utils/skills.test.ts index ce489f9b..410fb3d9 100644 --- a/src/utils/skills.test.ts +++ b/src/utils/skills.test.ts @@ -375,6 +375,32 @@ describe('skills', () => { expect(formatSkillsForPrompt([])).toBeNull(); }); + it('formats a skill without a description', () => { + expect( + formatSkillsForPrompt([ + { + name: 'review', + source: SkillSource.Project, + content: 'Review pull requests\n', + path: '/test/.code-ollama/skills/review', + isDisabled: false, + }, + ]), + ).toBe( + [ + 'Loaded skills:', + 'These are additional instructions. Follow any skill when it applies to the user request.', + 'Do not invent skill capabilities or descriptions. If the user asks what skills are loaded or what a skill says, answer only from the names, sources, and contents below.', + '--- Skill: review (project) ---\nReview pull requests', + ].join('\n\n'), + ); + }); + + it('uses default directories when called with no options', () => { + const skills = loadSkills(); + expect(Array.isArray(skills)).toBe(true); + }); + it('marks disabled skills with isDisabled flag', () => { const projectDirectory = join(tempRoot, 'project'); mkdirSync(projectDirectory); From 01565d9f0e1c488898c2f0d0ad3b6f5d3c903fe6 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 10 Jul 2026 22:48:44 -0400 Subject: [PATCH 2/7] refactor: remove "Project metadata" and fix marginBottom --- src/components/Messages/Message.tsx | 31 ++++++++++++++++++++++------ src/components/Messages/Messages.tsx | 15 +++++++++++++- src/utils/memory.test.ts | 2 +- src/utils/memory.ts | 1 - 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/components/Messages/Message.tsx b/src/components/Messages/Message.tsx index 51168fa7..a8182871 100644 --- a/src/components/Messages/Message.tsx +++ b/src/components/Messages/Message.tsx @@ -18,6 +18,7 @@ import { getMessageColor } from './styles'; interface Props { message: OllamaMessage; isStreaming?: boolean; + marginBottom?: number; } function renderStickyPaddingLines(count: number): React.ReactElement[] { @@ -29,16 +30,22 @@ function renderStickyPaddingLines(count: number): React.ReactElement[] { } function ToolResultMessage({ + marginBottom, message, messageColor, }: { + marginBottom: number; message: OllamaMessage; messageColor?: string; }) { const diffContent = message.toolResult?.diff?.visible; return ( - + {message.content} @@ -50,7 +57,11 @@ function ToolResultMessage({ ); } -export function Message({ message, isStreaming = false }: Props) { +export function Message({ + message, + isStreaming = false, + marginBottom = 1, +}: Props) { const theme = useTheme(); const messageColor = getMessageColor(message.role, theme); const isSystem = message.role === ROLE.SYSTEM; @@ -70,12 +81,20 @@ export function Message({ message, isStreaming = false }: Props) { if (isSystem) { if (message.toolResult?.diff) { return ( - + ); } return ( - + {message.content} @@ -92,7 +111,7 @@ export function Message({ message, isStreaming = false }: Props) { // v8 ignore stop return ( - + {UI.PROMPT_PREFIX} {attachmentPrefix ? ( @@ -152,7 +171,7 @@ export function Message({ message, isStreaming = false }: Props) { : 0; return ( - + {segments.map((segment, index) => { if (segment.type === 'code') { return ( diff --git a/src/components/Messages/Messages.tsx b/src/components/Messages/Messages.tsx index 9bcbcf99..c5361ed6 100644 --- a/src/components/Messages/Messages.tsx +++ b/src/components/Messages/Messages.tsx @@ -27,7 +27,20 @@ export function Messages({ return ( <> - {(message, index) => } + {(message, index) => { + const isLastTranscriptMessage = + index === transcriptMessages.length - 1 && + !streamingMessage && + !isLoading; + + return ( + + ); + }} {streamingMessage && } diff --git a/src/utils/memory.test.ts b/src/utils/memory.test.ts index 7e755e58..da3970e7 100644 --- a/src/utils/memory.test.ts +++ b/src/utils/memory.test.ts @@ -176,7 +176,7 @@ describe('memory', () => { expect(summary).toContain('Global memory:'); expect(summary).toContain('Project memory:'); - expect(summary).toContain('Project metadata:'); + expect(summary).not.toContain('Project metadata:'); }); it('returns null when neither global nor project memory file exists', async () => { diff --git a/src/utils/memory.ts b/src/utils/memory.ts index 28b1114d..29dcafbb 100644 --- a/src/utils/memory.ts +++ b/src/utils/memory.ts @@ -172,7 +172,6 @@ export function getMemoryPathSummary(cwd = process.cwd()): string { return [ `Global memory: ${paths.globalMemoryPath}`, `Project memory: ${paths.projectMemoryPath}`, - `Project metadata: ${paths.projectMetadataPath}`, ].join('\n'); } From f5a3865b694d08453b772222dc63764b21b55221 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 10 Jul 2026 23:25:54 -0400 Subject: [PATCH 3/7] fix(commands): fix subcommands and tab autocomplete Now command suggestions behave like this: - `Tab` completes the highlighted suggestion into the input. - `/mo` + Tab -> `/models` - `/mem` + Tab -> `/memory` - `/memory a` + Tab -> `/memory add` - `Enter` submits/runs the highlighted command when it is executable. - File/model suggestions keep their old behavior because the new Tab completion path is opt-in for command suggestions. --- src/components/Chat/Chat.test.tsx | 58 ++++++++ src/components/Chat/Chat.tsx | 13 +- src/components/Chat/ChatInput.test.tsx | 63 ++++++++- src/components/Chat/ChatInput.tsx | 30 +++- src/components/Chat/CommandMenu.test.tsx | 128 ++++++++++++++++++ src/components/Chat/CommandMenu.tsx | 71 ++++++++-- .../Suggestions/Suggestions.test.tsx | 24 ++++ src/components/Suggestions/Suggestions.tsx | 15 +- 8 files changed, 384 insertions(+), 18 deletions(-) diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index ed12c792..6d5b9410 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -499,6 +499,26 @@ describe('Chat', () => { expect(lastFrame()).toContain('Use Vitest.'); }); + it('resolves a unique memory subcommand prefix', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory s'); + rerender(chat); + await time.tick(); + + expect(memory.showMemory).toHaveBeenCalled(); + expect(lastFrame()).toContain('Use Vitest.'); + expect(ollama.streamChat).not.toHaveBeenCalled(); + }); + it('shows memory paths for /memory path', async () => { const chat = ( { expect(lastFrame()).toContain('Project memory:'); }); + it('resolves a unique memory path prefix', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory p'); + rerender(chat); + await time.tick(); + + expect(memory.getMemoryPathSummary).toHaveBeenCalled(); + expect(lastFrame()).toContain('Project memory:'); + }); + it('adds project memory and resets the cached system message', async () => { const chat = ( { expect(ollama.streamChat).not.toHaveBeenCalled(); }); + it('does not resolve an ambiguous memory add prefix', async () => { + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + submitInput('/memory a'); + rerender(chat); + await time.tick(); + + expect(memory.appendMemory).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('Unknown memory command.'); + }); + it('shows an error message when the memory command throws an Error', async () => { memoryMocks.appendMemory.mockImplementation(() => { throw new Error('Disk full'); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 20484e25..94a11e2b 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -17,6 +17,7 @@ import type { Decision, Mode, ThemeDefinition } from '@/types'; import { agents, memory, ollama, tools } from '@/utils'; import { ChatInput, type SubmittedInput } from './ChatInput'; +import { MEMORY_COMMANDS } from './CommandMenu'; import { ChatActionType, InterruptReason } from './constants'; import { useCompact, useRunTurn } from './hooks'; import { chatReducer, createInitialChatState } from './reducer'; @@ -34,7 +35,17 @@ interface Props { } function getMemoryCommandResult(command: string): string { - const [, subcommand = 'show', ...args] = command.split(/\s+/); + const normalizedCommand = command.trim().toLowerCase(); + const matchingSubmitCommands = MEMORY_COMMANDS.filter( + ({ value }) => + value.shouldSubmit && + value.text.toLowerCase().startsWith(normalizedCommand), + ); + const resolvedCommand = + matchingSubmitCommands.length === 1 + ? matchingSubmitCommands[0].value.text + : command; + const [, subcommand = 'show', ...args] = resolvedCommand.split(/\s+/); switch (subcommand) { case 'show': diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx index 79082af7..5dbac366 100644 --- a/src/components/Chat/ChatInput.test.tsx +++ b/src/components/Chat/ChatInput.test.tsx @@ -122,9 +122,11 @@ vi.mock('../TextInput', () => ({ vi.mock('./CommandMenu', () => ({ CommandMenu: ({ input, + onComplete, onSubmit, }: { input: string; + onComplete?: (value: string) => void; onSubmit: (value: string) => void; }) => { const normalizedInput = input.trim().toLowerCase(); @@ -134,18 +136,32 @@ vi.mock('./CommandMenu', () => ({ { label: '/unknown - invalid command', value: '/unknown', + shouldComplete: false, }, ] - : COMMAND.LIST.filter(({ name }) => - name.toLowerCase().startsWith(normalizedInput), - ).map(({ name, description }) => ({ - label: `${name} - ${description}`, - value: name, - })); + : normalizedInput === '/mo' + ? [ + { + label: '/models - manage Ollama models', + value: '/models', + shouldComplete: true, + }, + ] + : COMMAND.LIST.filter(({ name }) => + name.toLowerCase().startsWith(normalizedInput), + ).map(({ name, description }) => ({ + label: `${name} - ${description}`, + value: name, + shouldComplete: false, + })); useInput((_, key) => { if (key.return && options[0]) { - onSubmit(options[0].value); + if (options[0].shouldComplete && onComplete) { + onComplete(options[0].value); + } else { + onSubmit(options[0].value); + } } }); @@ -414,6 +430,28 @@ describe('ChatInput', () => { expect(onSubmit).toHaveBeenCalledWith({ content: '/clear' }); }); + it('submits memory subcommands on Enter when typed directly', async () => { + const onSubmit = vi.fn(); + const { stdin } = renderInput({ onSubmit }); + stdin.write('/memory show'); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).toHaveBeenCalledWith({ content: '/memory show' }); + }); + + it('submits memory add with text on Enter when typed directly', async () => { + const onSubmit = vi.fn(); + const { stdin } = renderInput({ onSubmit }); + stdin.write('/memory add Use Vitest.'); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).toHaveBeenCalledWith({ + content: '/memory add Use Vitest.', + }); + }); + it('ignores slash command submissions that are not in the command list', async () => { const onSubmit = vi.fn(); const { stdin } = renderInput({ onSubmit }); @@ -1157,6 +1195,17 @@ describe('ChatInput', () => { expect(lastFrame()).toContain('bck-i-search: _'); }); + it('completes a command without submitting when CommandMenu calls onComplete', async () => { + const onSubmit = vi.fn(); + const { lastFrame, stdin } = renderInput({ onSubmit }); + stdin.write('/mo'); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('/models'); + }); + it('does not add slash commands to prompt history after a session change', async () => { const onSubmit = vi.fn(); const { lastFrame, rerender, stdin } = renderInput({ diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index 32869058..9d94dbd0 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -37,6 +37,21 @@ function hasFileSuggestionQuery(input: string): boolean { return /(^|.)@\S+/.test(input); } +function isSubmittableSlashCommand(value: string): boolean { + // v8 ignore next + if (value.includes('\n')) { + return false; + } + + const trimmedValue = value.trim(); + + return ( + COMMAND.LIST.some(({ name }) => name === trimmedValue) || + trimmedValue === '/memory' || + trimmedValue.startsWith('/memory ') + ); +} + function toAttachment(path: string, index: number, isTemp = false): Attachment { return { id: `${path}-${String(index)}`, @@ -307,6 +322,10 @@ export function ChatInput({ const handleSubmitText = useCallback( (value: string) => { if (value.startsWith('/') && !value.includes('\n')) { + if (isSubmittableSlashCommand(value)) { + submitAndReset(value); + } + return; } @@ -332,6 +351,11 @@ export function ChatInput({ [submitAndReset], ); + const handleCompleteCommand = useCallback((value: string) => { + setInput(value); + setCursorPosition(value.length); + }, []); + useInput((inputKey, key) => { const isEscape = key.escape || inputKey === KEY.ESCAPE || inputKey === '\x1B'; @@ -468,7 +492,11 @@ export function ChatInput({ )} {!historySearch.isActive && showCommandMenu && ( - + )} {!historySearch.isActive && showFileSuggestions && ( diff --git a/src/components/Chat/CommandMenu.test.tsx b/src/components/Chat/CommandMenu.test.tsx index 3b18a2f1..6ab43ef4 100644 --- a/src/components/Chat/CommandMenu.test.tsx +++ b/src/components/Chat/CommandMenu.test.tsx @@ -39,6 +39,20 @@ describe('CommandMenu', () => { expect(onSubmit).toHaveBeenCalledWith('/models'); }); + it('completes a top-level command with Tab instead of submitting', async () => { + const onComplete = vi.fn(); + const onSubmit = vi.fn(); + const { stdin } = renderWithTheme( + , + ); + + stdin.write(KEY.TAB); + await time.tick(); + + expect(onComplete).toHaveBeenCalledWith('/models'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it('includes /mcp in matching command results', () => { const onSubmit = vi.fn(); const { lastFrame } = renderWithTheme( @@ -48,6 +62,120 @@ describe('CommandMenu', () => { expect(lastFrame()).toContain('/mcp - show MCP server status'); }); + it('completes /mem to /memory space instead of submitting', async () => { + const onComplete = vi.fn(); + const onSubmit = vi.fn(); + const { stdin } = renderWithTheme( + , + ); + + stdin.write(KEY.TAB); + await time.tick(); + + expect(onComplete).toHaveBeenCalledWith('/memory '); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('completes exact /memory with Tab instead of submitting', async () => { + const onComplete = vi.fn(); + const onSubmit = vi.fn(); + const { stdin } = renderWithTheme( + , + ); + + stdin.write(KEY.TAB); + await time.tick(); + + expect(onComplete).toHaveBeenCalledWith('/memory'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('submits exact /memory when selected', async () => { + const onComplete = vi.fn(); + const onSubmit = vi.fn(); + const { stdin } = renderWithTheme( + , + ); + + stdin.write(KEY.ENTER); + await time.tick(); + + expect(onSubmit).toHaveBeenCalledWith('/memory'); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('renders memory subcommands after /memory space', () => { + const onSubmit = vi.fn(); + const { lastFrame } = renderWithTheme( + , + ); + + expect(lastFrame()).toContain('/memory show - display loaded memory'); + expect(lastFrame()).toContain('/memory add '); + }); + + it('completes memory add instead of submitting it', async () => { + const onComplete = vi.fn(); + const onSubmit = vi.fn(); + const { stdin } = renderWithTheme( + , + ); + + stdin.write(KEY.TAB); + await time.tick(); + + expect(onComplete).toHaveBeenCalledWith('/memory add '); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('submits memory show when selected', async () => { + const onComplete = vi.fn(); + const onSubmit = vi.fn(); + const { stdin } = renderWithTheme( + , + ); + + stdin.write(KEY.ENTER); + await time.tick(); + + expect(onSubmit).toHaveBeenCalledWith('/memory show'); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('calls onComplete via onSelect when selecting a non-submittable memory add option with Enter', async () => { + const onComplete = vi.fn(); + const onSubmit = vi.fn(); + const { stdin } = renderWithTheme( + , + ); + + stdin.write(KEY.ENTER); + await time.tick(); + + expect(onComplete).toHaveBeenCalledWith('/memory add '); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it('moves focus through slash commands before selecting', async () => { const onSubmit = vi.fn(); const { stdin } = renderWithTheme( diff --git a/src/components/Chat/CommandMenu.tsx b/src/components/Chat/CommandMenu.tsx index 35aa1f23..cb751ba8 100644 --- a/src/components/Chat/CommandMenu.tsx +++ b/src/components/Chat/CommandMenu.tsx @@ -5,24 +5,71 @@ import { COMMAND } from '@/constants'; interface Props { input: string; + onComplete?: (value: string) => void; onSubmit: (value: string) => void; } +export interface CommandOptionValue { + shouldSubmit: boolean; + text: string; +} + +interface CommandOption { + label: string; + value: CommandOptionValue; +} + +export const MEMORY_COMMANDS: CommandOption[] = [ + { + label: '/memory show - display loaded memory', + value: { shouldSubmit: true, text: '/memory show' }, + }, + { + label: '/memory path - show memory file paths', + value: { shouldSubmit: true, text: '/memory path' }, + }, + { + label: '/memory edit - show editable memory file paths', + value: { shouldSubmit: true, text: '/memory edit' }, + }, + { + label: '/memory add - append project memory', + value: { shouldSubmit: false, text: '/memory add ' }, + }, + { + label: '/memory add --global - append global memory', + value: { shouldSubmit: false, text: '/memory add --global ' }, + }, +]; + function getMatchingCommands(input: string) { - const normalizedInput = input.trim().toLowerCase(); + const normalizedInput = input.toLowerCase(); if (!normalizedInput.startsWith('/')) { return []; } + if (normalizedInput.startsWith('/memory ')) { + return MEMORY_COMMANDS.filter(({ value }) => + value.text.toLowerCase().startsWith(normalizedInput), + ); + } + return COMMAND.LIST.filter(({ name }) => - name.toLowerCase().startsWith(normalizedInput), - ).map(({ name, description }) => ({ - label: `${name} - ${description}`, - value: name, - })); + name.toLowerCase().startsWith(normalizedInput.trim()), + ).map(({ name, description }) => { + const shouldCompleteMemory = + name === '/memory' && normalizedInput.trim() !== '/memory'; + + return { + label: `${name} - ${description}`, + value: shouldCompleteMemory + ? { shouldSubmit: false, text: '/memory ' } + : { shouldSubmit: true, text: name }, + }; + }); } -export function CommandMenu({ input, onSubmit }: Props) { +export function CommandMenu({ input, onComplete, onSubmit }: Props) { const commandOptions = useMemo(() => getMatchingCommands(input), [input]); if (!commandOptions.length) { @@ -31,8 +78,16 @@ export function CommandMenu({ input, onSubmit }: Props) { return ( { + onComplete?.(option.value.text); + }} onSelect={(option) => { - onSubmit(option.value); + if (option.value.shouldSubmit) { + onSubmit(option.value.text); + return; + } + + onComplete?.(option.value.text); }} options={commandOptions} /> diff --git a/src/components/Suggestions/Suggestions.test.tsx b/src/components/Suggestions/Suggestions.test.tsx index ef6af6f2..d288ca94 100644 --- a/src/components/Suggestions/Suggestions.test.tsx +++ b/src/components/Suggestions/Suggestions.test.tsx @@ -69,6 +69,30 @@ describe('Suggestions', () => { }, ); + it('calls onComplete instead of onSelect when Tab is pressed and onComplete is provided', async () => { + const onComplete = vi.fn(); + const onSelect = vi.fn(); + const { stdin } = renderWithTheme( + , + ); + + stdin.write(KEY.TAB); + await time.tick(); + + expect(onComplete).toHaveBeenCalledWith({ + label: 'alpha', + value: 'alpha', + }); + expect(onSelect).not.toHaveBeenCalled(); + }); + it('ignores unrelated printable input', async () => { const onSelect = vi.fn(); const { stdin } = renderWithTheme( diff --git a/src/components/Suggestions/Suggestions.tsx b/src/components/Suggestions/Suggestions.tsx index 5343d265..d167cfc1 100644 --- a/src/components/Suggestions/Suggestions.tsx +++ b/src/components/Suggestions/Suggestions.tsx @@ -15,6 +15,7 @@ interface Props { isDisabled?: boolean; maxVisibleOptions?: number; resetKey?: string; + onComplete?: (option: SuggestionOption) => void; onHighlight?: (option: SuggestionOption | null) => void; onSelect: (option: SuggestionOption) => void; } @@ -24,6 +25,7 @@ export function Suggestions({ isDisabled = false, maxVisibleOptions = DEFAULT_MAX_VISIBLE_OPTIONS, resetKey, + onComplete, onHighlight, onSelect, }: Props) { @@ -66,7 +68,18 @@ export function Suggestions({ return; } - if (key.tab || key.return) { + if (key.tab) { + const option = options[focusedIndex]; + if (onComplete) { + onComplete(option); + return; + } + + onSelect(option); + return; + } + + if (key.return) { onSelect(options[focusedIndex]); } }); From bddcae634278499007d3ec6b22ec4f48f6e68c6b Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 10 Jul 2026 23:46:48 -0400 Subject: [PATCH 4/7] fix(commands): handle Enter and selected suggestion submits - If command suggestions are visible, raw text submission backs off. - The selected suggestion submits instead. - `handleSubmitCommand` now accepts memory subcommands like `/memory path`, not just top-level commands. --- src/components/Chat/ChatInput.test.tsx | 130 ++++++++++++++++++-- src/components/Chat/ChatInput.tsx | 37 ++++-- src/components/Chat/CommandMenu.tsx | 2 +- src/components/TextInput/TextInput.test.tsx | 14 +++ src/components/TextInput/TextInput.tsx | 1 - 5 files changed, 165 insertions(+), 19 deletions(-) diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx index 5dbac366..b7949241 100644 --- a/src/components/Chat/ChatInput.test.tsx +++ b/src/components/Chat/ChatInput.test.tsx @@ -120,6 +120,39 @@ vi.mock('../TextInput', () => ({ })); vi.mock('./CommandMenu', () => ({ + getMatchingCommands: (input: string) => { + const normalizedInput = input.toLowerCase(); + const trimmedInput = normalizedInput.trim(); + const options = + normalizedInput === '/memory' + ? [ + { + label: '/memory - show or update local memory', + value: { text: '/memory', shouldSubmit: true }, + }, + ] + : normalizedInput.startsWith('/memory ') + ? [ + { + label: '/memory path - show memory file paths', + value: { text: '/memory path', shouldSubmit: true }, + }, + { + label: '/memory add - append project memory', + value: { text: '/memory add ', shouldSubmit: false }, + }, + ].filter(({ value }) => + value.text.toLowerCase().startsWith(normalizedInput), + ) + : COMMAND.LIST.filter(({ name }) => + name.toLowerCase().startsWith(trimmedInput), + ).map(({ name, description }) => ({ + label: `${name} - ${description}`, + value: { text: name, shouldSubmit: true }, + })); + + return options; + }, CommandMenu: ({ input, onComplete, @@ -129,9 +162,10 @@ vi.mock('./CommandMenu', () => ({ onComplete?: (value: string) => void; onSubmit: (value: string) => void; }) => { - const normalizedInput = input.trim().toLowerCase(); + const normalizedInput = input.toLowerCase(); + const trimmedInput = normalizedInput.trim(); const options = - normalizedInput === '/unknown' + trimmedInput === '/unknown' ? [ { label: '/unknown - invalid command', @@ -139,7 +173,7 @@ vi.mock('./CommandMenu', () => ({ shouldComplete: false, }, ] - : normalizedInput === '/mo' + : trimmedInput === '/mo' ? [ { label: '/models - manage Ollama models', @@ -147,13 +181,36 @@ vi.mock('./CommandMenu', () => ({ shouldComplete: true, }, ] - : COMMAND.LIST.filter(({ name }) => - name.toLowerCase().startsWith(normalizedInput), - ).map(({ name, description }) => ({ - label: `${name} - ${description}`, - value: name, - shouldComplete: false, - })); + : normalizedInput === '/memory' + ? [ + { + label: '/memory - show or update local memory', + value: '/memory', + shouldComplete: false, + }, + ] + : normalizedInput.startsWith('/memory ') + ? [ + { + label: '/memory path - show memory file paths', + value: '/memory path', + shouldComplete: false, + }, + { + label: '/memory add - append project memory', + value: '/memory add ', + shouldComplete: true, + }, + ].filter(({ value }) => + value.toLowerCase().startsWith(normalizedInput), + ) + : COMMAND.LIST.filter(({ name }) => + name.toLowerCase().startsWith(trimmedInput), + ).map(({ name, description }) => ({ + label: `${name} - ${description}`, + value: name, + shouldComplete: false, + })); useInput((_, key) => { if (key.return && options[0]) { @@ -430,6 +487,17 @@ describe('ChatInput', () => { expect(onSubmit).toHaveBeenCalledWith({ content: '/clear' }); }); + it('submits selected memory command suggestions instead of the raw input', async () => { + const onSubmit = vi.fn(); + const { stdin } = renderInput({ onSubmit }); + stdin.write('/memory '); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).toHaveBeenCalledWith({ content: '/memory path' }); + expect(onSubmit).not.toHaveBeenCalledWith({ content: '/memory ' }); + }); + it('submits memory subcommands on Enter when typed directly', async () => { const onSubmit = vi.fn(); const { stdin } = renderInput({ onSubmit }); @@ -440,6 +508,36 @@ describe('ChatInput', () => { expect(onSubmit).toHaveBeenCalledWith({ content: '/memory show' }); }); + it('submits /memory on Enter when typed directly', async () => { + const onSubmit = vi.fn(); + const { stdin } = renderInput({ onSubmit }); + stdin.write('/memory'); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).toHaveBeenCalledWith({ content: '/memory' }); + }); + + it('submits unique memory command prefixes on Enter when typed directly', async () => { + const onSubmit = vi.fn(); + const { stdin } = renderInput({ onSubmit }); + stdin.write('/memory s'); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).toHaveBeenCalledWith({ content: '/memory s' }); + }); + + it('does not submit incomplete memory add prefixes on Enter', async () => { + const onSubmit = vi.fn(); + const { stdin } = renderInput({ onSubmit }); + stdin.write('/memory a'); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it('submits memory add with text on Enter when typed directly', async () => { const onSubmit = vi.fn(); const { stdin } = renderInput({ onSubmit }); @@ -452,6 +550,18 @@ describe('ChatInput', () => { }); }); + it('submits memory add --global with text on Enter when typed directly', async () => { + const onSubmit = vi.fn(); + const { stdin } = renderInput({ onSubmit }); + stdin.write('/memory add --global Use Vitest globally.'); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).toHaveBeenCalledWith({ + content: '/memory add --global Use Vitest globally.', + }); + }); + it('ignores slash command submissions that are not in the command list', async () => { const onSubmit = vi.fn(); const { stdin } = renderInput({ onSubmit }); diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index 9d94dbd0..97d8c6ff 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -11,7 +11,7 @@ import { extractImageAttachments, getAttachmentLabel, } from './attachments'; -import { CommandMenu } from './CommandMenu'; +import { CommandMenu, getMatchingCommands } from './CommandMenu'; import { FileSuggestions } from './FileSuggestions'; import { useHistorySearch } from './hooks'; @@ -44,12 +44,31 @@ function isSubmittableSlashCommand(value: string): boolean { } const trimmedValue = value.trim(); + const memoryRunnableCommands = [ + '/memory show', + '/memory path', + '/memory edit', + ]; + + if (trimmedValue === '/memory') { + return true; + } - return ( - COMMAND.LIST.some(({ name }) => name === trimmedValue) || - trimmedValue === '/memory' || - trimmedValue.startsWith('/memory ') - ); + if ( + memoryRunnableCommands.some((command) => command.startsWith(trimmedValue)) + ) { + return true; + } + + if (trimmedValue.startsWith('/memory add --global ')) { + return trimmedValue.slice('/memory add --global '.length).trim().length > 0; + } + + if (trimmedValue.startsWith('/memory add ')) { + return trimmedValue.slice('/memory add '.length).trim().length > 0; + } + + return COMMAND.LIST.some(({ name }) => name === trimmedValue); } function toAttachment(path: string, index: number, isTemp = false): Attachment { @@ -322,6 +341,10 @@ export function ChatInput({ const handleSubmitText = useCallback( (value: string) => { if (value.startsWith('/') && !value.includes('\n')) { + if (getMatchingCommands(value).length) { + return; + } + if (isSubmittableSlashCommand(value)) { submitAndReset(value); } @@ -344,7 +367,7 @@ export function ChatInput({ const handleSubmitCommand = useCallback( (value: string) => { - if (COMMAND.LIST.find(({ name }) => name === value)) { + if (isSubmittableSlashCommand(value)) { submitAndReset(value); } }, diff --git a/src/components/Chat/CommandMenu.tsx b/src/components/Chat/CommandMenu.tsx index cb751ba8..a1d3f79c 100644 --- a/src/components/Chat/CommandMenu.tsx +++ b/src/components/Chat/CommandMenu.tsx @@ -42,7 +42,7 @@ export const MEMORY_COMMANDS: CommandOption[] = [ }, ]; -function getMatchingCommands(input: string) { +export function getMatchingCommands(input: string) { const normalizedInput = input.toLowerCase(); if (!normalizedInput.startsWith('/')) { return []; diff --git a/src/components/TextInput/TextInput.test.tsx b/src/components/TextInput/TextInput.test.tsx index 20678155..01b88531 100644 --- a/src/components/TextInput/TextInput.test.tsx +++ b/src/components/TextInput/TextInput.test.tsx @@ -136,6 +136,20 @@ describe('TextInput', () => { expect(onSubmit).toHaveBeenCalledWith('test'); }); + it('keeps cursor position after Enter when the value is unchanged', async () => { + const onChange = vi.fn(); + const { stdin } = renderWithTheme( + , + ); + + stdin.write(KEY.ENTER); + await time.tick(); + stdin.write('!'); + await time.tick(); + + expect(onChange).toHaveBeenCalledWith('test!'); + }); + it('submits on Enter when multiline paste is enabled', async () => { const onChange = vi.fn(); const onSubmit = vi.fn(); diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index 55b5b1ca..792e3705 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -160,7 +160,6 @@ export function TextInput({ if (key.return) { onSubmit(value); - setCursorPosition(0); return; } From 33833ff3716b0c8fd5f4e715a24b0a8cec2c0d72 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 10 Jul 2026 23:54:04 -0400 Subject: [PATCH 5/7] refactor(components): extract command concerns to CommandMenu Now command-specific validity lives next to command suggestions in `CommandMenu.tsx`: - `getMatchingCommands(input)` - `isSubmittableCommand(value)` - `MEMORY_COMMANDS` `ChatInput` only asks those helpers whether suggestions exist or whether the command is submittable. It no longer knows memory subcommand grammar directly. --- src/components/Chat/ChatInput.test.tsx | 34 ++++++++++++++++++ src/components/Chat/ChatInput.tsx | 46 +++++------------------- src/components/Chat/CommandMenu.test.tsx | 17 ++++++++- src/components/Chat/CommandMenu.tsx | 39 ++++++++++++++++++++ 4 files changed, 97 insertions(+), 39 deletions(-) diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx index b7949241..9757c572 100644 --- a/src/components/Chat/ChatInput.test.tsx +++ b/src/components/Chat/ChatInput.test.tsx @@ -153,6 +153,40 @@ vi.mock('./CommandMenu', () => ({ return options; }, + isSubmittableCommand: (value: string) => { + const trimmedValue = value.trim(); + + if (trimmedValue === '/memory') { + return true; + } + + if ( + trimmedValue === '/memory add' || + trimmedValue === '/memory add --global' + ) { + return false; + } + + if (trimmedValue.startsWith('/memory add --global ')) { + return ( + trimmedValue.slice('/memory add --global '.length).trim().length > 0 + ); + } + + if (trimmedValue.startsWith('/memory add ')) { + return trimmedValue.slice('/memory add '.length).trim().length > 0; + } + + if ( + ['/memory show', '/memory path', '/memory edit'].some((command) => + command.startsWith(trimmedValue), + ) + ) { + return true; + } + + return COMMAND.LIST.some(({ name }) => name === trimmedValue); + }, CommandMenu: ({ input, onComplete, diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index 97d8c6ff..9a861843 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -2,7 +2,7 @@ import { Box, Text, useApp, useInput } from 'ink'; import { useCallback, useEffect, useRef, useState } from 'react'; import { TextInput } from '@/components/TextInput'; -import { COMMAND, KEY, UI } from '@/constants'; +import { KEY, UI } from '@/constants'; import { useTheme } from '@/contexts'; import { clipboard } from '@/utils'; @@ -11,7 +11,11 @@ import { extractImageAttachments, getAttachmentLabel, } from './attachments'; -import { CommandMenu, getMatchingCommands } from './CommandMenu'; +import { + CommandMenu, + getMatchingCommands, + isSubmittableCommand, +} from './CommandMenu'; import { FileSuggestions } from './FileSuggestions'; import { useHistorySearch } from './hooks'; @@ -37,40 +41,6 @@ function hasFileSuggestionQuery(input: string): boolean { return /(^|.)@\S+/.test(input); } -function isSubmittableSlashCommand(value: string): boolean { - // v8 ignore next - if (value.includes('\n')) { - return false; - } - - const trimmedValue = value.trim(); - const memoryRunnableCommands = [ - '/memory show', - '/memory path', - '/memory edit', - ]; - - if (trimmedValue === '/memory') { - return true; - } - - if ( - memoryRunnableCommands.some((command) => command.startsWith(trimmedValue)) - ) { - return true; - } - - if (trimmedValue.startsWith('/memory add --global ')) { - return trimmedValue.slice('/memory add --global '.length).trim().length > 0; - } - - if (trimmedValue.startsWith('/memory add ')) { - return trimmedValue.slice('/memory add '.length).trim().length > 0; - } - - return COMMAND.LIST.some(({ name }) => name === trimmedValue); -} - function toAttachment(path: string, index: number, isTemp = false): Attachment { return { id: `${path}-${String(index)}`, @@ -345,7 +315,7 @@ export function ChatInput({ return; } - if (isSubmittableSlashCommand(value)) { + if (isSubmittableCommand(value)) { submitAndReset(value); } @@ -367,7 +337,7 @@ export function ChatInput({ const handleSubmitCommand = useCallback( (value: string) => { - if (isSubmittableSlashCommand(value)) { + if (isSubmittableCommand(value)) { submitAndReset(value); } }, diff --git a/src/components/Chat/CommandMenu.test.tsx b/src/components/Chat/CommandMenu.test.tsx index 6ab43ef4..c896e33b 100644 --- a/src/components/Chat/CommandMenu.test.tsx +++ b/src/components/Chat/CommandMenu.test.tsx @@ -2,9 +2,24 @@ import { KEY } from '@/constants'; import { time } from '@/utils'; import { renderWithTheme } from '@/utils/testing'; -import { CommandMenu } from './CommandMenu'; +import { CommandMenu, isSubmittableCommand } from './CommandMenu'; describe('CommandMenu', () => { + it.each([ + ['/models', true], + ['/memory', true], + ['/memory s', true], + ['/memory path', true], + ['/memory add', false], + ['/memory add ', false], + ['/memory add Use Vitest.', true], + ['/memory add --global', false], + ['/memory add --global Use Vitest.', true], + ['/unknown', false], + ])('reports whether %s is submittable', (command, expected) => { + expect(isSubmittableCommand(command)).toBe(expected); + }); + it('returns null when input does not start with a slash', () => { const onSubmit = vi.fn(); const { lastFrame } = renderWithTheme( diff --git a/src/components/Chat/CommandMenu.tsx b/src/components/Chat/CommandMenu.tsx index a1d3f79c..2941929b 100644 --- a/src/components/Chat/CommandMenu.tsx +++ b/src/components/Chat/CommandMenu.tsx @@ -69,6 +69,45 @@ export function getMatchingCommands(input: string) { }); } +export function isSubmittableCommand(value: string): boolean { + // v8 ignore next + if (value.includes('\n')) { + return false; + } + + const trimmedValue = value.trim(); + const runnableMemoryCommands = MEMORY_COMMANDS.filter( + ({ value }) => value.shouldSubmit, + ).map(({ value }) => value.text); + + if (trimmedValue === '/memory') { + return true; + } + + if ( + trimmedValue === '/memory add' || + trimmedValue === '/memory add --global' + ) { + return false; + } + + if (trimmedValue.startsWith('/memory add --global ')) { + return trimmedValue.slice('/memory add --global '.length).trim().length > 0; + } + + if (trimmedValue.startsWith('/memory add ')) { + return trimmedValue.slice('/memory add '.length).trim().length > 0; + } + + if ( + runnableMemoryCommands.some((command) => command.startsWith(trimmedValue)) + ) { + return true; + } + + return COMMAND.LIST.some(({ name }) => name === trimmedValue); +} + export function CommandMenu({ input, onComplete, onSubmit }: Props) { const commandOptions = useMemo(() => getMatchingCommands(input), [input]); From e9f863cf7463602f38b4dcb9800f971cdfdbb4b6 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 11 Jul 2026 00:00:40 -0400 Subject: [PATCH 6/7] refactor(memory): `show` should show contents only and not path `/memory show` should show contents only; `/memory path` is for locations. --- src/utils/memory.test.ts | 3 +-- src/utils/memory.ts | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/utils/memory.test.ts b/src/utils/memory.test.ts index da3970e7..f4f664b2 100644 --- a/src/utils/memory.test.ts +++ b/src/utils/memory.test.ts @@ -208,8 +208,7 @@ describe('memory', () => { const { showMemory } = await import('./memory'); const result = showMemory('/repo'); - expect(result).toContain('No memory found.'); - expect(result).toContain('Global memory:'); + expect(result).toBe('No memory found.'); }); it('throws when appendMemory is called with empty text', async () => { diff --git a/src/utils/memory.ts b/src/utils/memory.ts index 29dcafbb..cd518a3b 100644 --- a/src/utils/memory.ts +++ b/src/utils/memory.ts @@ -197,9 +197,7 @@ export function loadMemoryForPrompt(cwd = process.cwd()): string | null { } export function showMemory(cwd = process.cwd()): string { - return ( - loadMemoryForPrompt(cwd) ?? `No memory found.\n${getMemoryPathSummary(cwd)}` - ); + return loadMemoryForPrompt(cwd) ?? 'No memory found.'; } export function appendMemory( From 8f2c3dcf62aeb3d621e4973643b06dc8a9163355 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 11 Jul 2026 00:05:56 -0400 Subject: [PATCH 7/7] refactor(commands): remove redundant `/memory edit` --- src/components/Chat/Chat.tsx | 2 -- src/components/Chat/ChatInput.test.tsx | 2 +- src/components/Chat/CommandMenu.tsx | 4 ---- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 94a11e2b..f82c9538 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -52,7 +52,6 @@ function getMemoryCommandResult(command: string): string { return memory.showMemory(); case 'path': - case 'edit': return memory.getMemoryPathSummary(); case 'add': { @@ -71,7 +70,6 @@ function getMemoryCommandResult(command: string): string { 'Usage:', '/memory show', '/memory path', - '/memory edit', '/memory add ', '/memory add --global ', ].join('\n'); diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx index 9757c572..8f603558 100644 --- a/src/components/Chat/ChatInput.test.tsx +++ b/src/components/Chat/ChatInput.test.tsx @@ -178,7 +178,7 @@ vi.mock('./CommandMenu', () => ({ } if ( - ['/memory show', '/memory path', '/memory edit'].some((command) => + ['/memory show', '/memory path'].some((command) => command.startsWith(trimmedValue), ) ) { diff --git a/src/components/Chat/CommandMenu.tsx b/src/components/Chat/CommandMenu.tsx index 2941929b..5029f4b2 100644 --- a/src/components/Chat/CommandMenu.tsx +++ b/src/components/Chat/CommandMenu.tsx @@ -28,10 +28,6 @@ export const MEMORY_COMMANDS: CommandOption[] = [ label: '/memory path - show memory file paths', value: { shouldSubmit: true, text: '/memory path' }, }, - { - label: '/memory edit - show editable memory file paths', - value: { shouldSubmit: true, text: '/memory edit' }, - }, { label: '/memory add - append project memory', value: { shouldSubmit: false, text: '/memory add ' },