From 69c4e193add6a700e289fe7d50b335554151cc4f Mon Sep 17 00:00:00 2001 From: "liumingyao.marvin" Date: Wed, 26 Aug 2026 21:52:40 +0800 Subject: [PATCH] feat(cli): scope prompt history by project --- cli/README.md | 8 ++ cli/src/chat.tsx | 9 +- cli/src/hooks/use-input-history.ts | 31 +++-- .../utils/__tests__/message-history.test.ts | 112 ++++++++++++++++ cli/src/utils/message-history.ts | 121 ++++++++++++++++-- 5 files changed, 249 insertions(+), 32 deletions(-) create mode 100644 cli/src/utils/__tests__/message-history.test.ts diff --git a/cli/README.md b/cli/README.md index 45d8af675a..fb66abf25d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -77,6 +77,14 @@ Or use the binary directly: codebuff-tui ``` +## Prompt History + +Submitted prompts are saved in `message-history.json` under the current +project's config directory and can be recalled with the up/down arrow keys. +History is stored as plaintext and capped at 500 entries by default. Set +`FREEBUFF_HISTORY_SIZE=0` to disable prompt history persistence, or set +`FREEBUFF_HISTORY_SCOPE=global` to use one shared history file across projects. + ## Features - Built with OpenTUI for modern terminal interfaces diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 88f6662fe8..2f40cae618 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -425,7 +425,11 @@ export const Chat = ({ }, [cursorPosition, inputValue, setInputValue]) const { saveToHistory, navigateUp, navigateDown, resetHistoryNavigation } = - useInputHistory(inputValue, setInputValue, { inputMode, setInputMode }) + useInputHistory(inputValue, setInputValue, { + inputMode, + setInputMode, + projectRoot: getProjectRoot(), + }) // Use extracted streaming hook for connection, timer, queue, and exit handling const { @@ -798,7 +802,8 @@ export const Chat = ({ // The panel closes itself once the queue drains, so opening an empty // one would just flash. Say so instead. if (queuedCount > 0) useQueuePanelStore.getState().openQueuePanel() - else setMessages((prev) => [...prev, getSystemMessage('Nothing queued.')]) + else + setMessages((prev) => [...prev, getSystemMessage('Nothing queued.')]) } }, [ diff --git a/cli/src/hooks/use-input-history.ts b/cli/src/hooks/use-input-history.ts index e8fadec1fe..4886795c07 100644 --- a/cli/src/hooks/use-input-history.ts +++ b/cli/src/hooks/use-input-history.ts @@ -1,8 +1,8 @@ import { useRef, useCallback, useEffect } from 'react' import { + appendMessageHistory, loadMessageHistory, - saveMessageHistory, } from '../utils/message-history' import type { InputValue } from '../types/store' @@ -31,9 +31,10 @@ export const useInputHistory = ( options?: { inputMode?: InputMode setInputMode?: (mode: InputMode) => void + projectRoot?: string }, ) => { - const { inputMode, setInputMode } = options ?? {} + const { inputMode, setInputMode, projectRoot } = options ?? {} const messageHistoryRef = useRef([]) const historyIndexRef = useRef(-1) const currentDraftRef = useRef('') @@ -45,10 +46,10 @@ export const useInputHistory = ( useEffect(() => { if (!isInitializedRef.current) { isInitializedRef.current = true - const savedHistory = loadMessageHistory() + const savedHistory = loadMessageHistory(projectRoot) messageHistoryRef.current = savedHistory } - }, []) + }, [projectRoot]) const resetHistoryNavigation = useCallback(() => { historyIndexRef.current = -1 @@ -62,18 +63,16 @@ export const useInputHistory = ( } }, [inputMode, resetHistoryNavigation]) - const saveToHistory = useCallback((message: string) => { - // Re-read from disk to pick up messages from other terminals - const diskHistory = loadMessageHistory() - const newHistory = [...diskHistory, message] - messageHistoryRef.current = newHistory - historyIndexRef.current = -1 - currentDraftRef.current = '' - currentDraftModeRef.current = 'default' - - // Persist to disk - saveMessageHistory(newHistory) - }, []) + const saveToHistory = useCallback( + (message: string) => { + const newHistory = appendMessageHistory(message, projectRoot) + messageHistoryRef.current = newHistory + historyIndexRef.current = -1 + currentDraftRef.current = '' + currentDraftModeRef.current = 'default' + }, + [projectRoot], + ) const navigateUp = useCallback(() => { const history = messageHistoryRef.current diff --git a/cli/src/utils/__tests__/message-history.test.ts b/cli/src/utils/__tests__/message-history.test.ts new file mode 100644 index 0000000000..85a892f0d1 --- /dev/null +++ b/cli/src/utils/__tests__/message-history.test.ts @@ -0,0 +1,112 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' + +import * as auth from '../auth' +import { + appendMessageHistory, + getMessageHistoryPath, + loadMessageHistory, + saveMessageHistory, +} from '../message-history' + +let tempConfigDir = '' +let getConfigDirSpy: ReturnType | undefined +let originalHistorySize: string | undefined +let originalHistoryScope: string | undefined + +beforeEach(() => { + tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-history-')) + getConfigDirSpy = spyOn(auth, 'getConfigDir').mockReturnValue(tempConfigDir) + originalHistorySize = process.env.FREEBUFF_HISTORY_SIZE + originalHistoryScope = process.env.FREEBUFF_HISTORY_SCOPE + delete process.env.FREEBUFF_HISTORY_SIZE + delete process.env.FREEBUFF_HISTORY_SCOPE +}) + +afterEach(() => { + getConfigDirSpy?.mockRestore() + getConfigDirSpy = undefined + + if (originalHistorySize === undefined) { + delete process.env.FREEBUFF_HISTORY_SIZE + } else { + process.env.FREEBUFF_HISTORY_SIZE = originalHistorySize + } + + if (originalHistoryScope === undefined) { + delete process.env.FREEBUFF_HISTORY_SCOPE + } else { + process.env.FREEBUFF_HISTORY_SCOPE = originalHistoryScope + } + + fs.rmSync(tempConfigDir, { recursive: true, force: true }) +}) + +describe('message history persistence', () => { + test('scopes history to the current project by default', () => { + const projectA = path.join(os.tmpdir(), 'freebuff-project-a') + const projectB = path.join(os.tmpdir(), 'freebuff-project-b') + + saveMessageHistory(['from a'], projectA) + saveMessageHistory(['from b'], projectB) + + expect(loadMessageHistory(projectA)).toEqual(['from a']) + expect(loadMessageHistory(projectB)).toEqual(['from b']) + expect(getMessageHistoryPath(projectA)).not.toBe( + getMessageHistoryPath(projectB), + ) + }) + + test('can opt back into global history scope', () => { + process.env.FREEBUFF_HISTORY_SCOPE = 'global' + + saveMessageHistory(['shared'], '/repo/one') + + expect(loadMessageHistory('/repo/two')).toEqual(['shared']) + expect(getMessageHistoryPath('/repo/one')).toBe( + getMessageHistoryPath('/repo/two'), + ) + }) + + test('appendMessageHistory collapses consecutive duplicates', () => { + saveMessageHistory(['first', 'repeat']) + + expect(appendMessageHistory('repeat')).toEqual(['first', 'repeat']) + expect(loadMessageHistory()).toEqual(['first', 'repeat']) + + expect(appendMessageHistory('next')).toEqual(['first', 'repeat', 'next']) + expect(loadMessageHistory()).toEqual(['first', 'repeat', 'next']) + }) + + test('FREEBUFF_HISTORY_SIZE limits persisted entries', () => { + process.env.FREEBUFF_HISTORY_SIZE = '2' + + saveMessageHistory(['one', 'two', 'three']) + + expect(loadMessageHistory()).toEqual(['two', 'three']) + }) + + test('default history size is capped at 500 entries', () => { + const history = Array.from({ length: 501 }, (_, index) => `entry-${index}`) + + saveMessageHistory(history) + + const savedHistory = loadMessageHistory() + expect(savedHistory).toHaveLength(500) + expect(savedHistory[0]).toBe('entry-1') + expect(savedHistory.at(-1)).toBe('entry-500') + }) + + test('FREEBUFF_HISTORY_SIZE=0 disables reads and writes without deleting existing history', () => { + saveMessageHistory(['before']) + const historyPath = getMessageHistoryPath() + process.env.FREEBUFF_HISTORY_SIZE = '0' + + expect(loadMessageHistory()).toEqual([]) + expect(appendMessageHistory('after')).toEqual([]) + expect(JSON.parse(fs.readFileSync(historyPath, 'utf8'))).toEqual(['before']) + }) +}) diff --git a/cli/src/utils/message-history.ts b/cli/src/utils/message-history.ts index 11c3497bf5..9a1fe3ef6a 100644 --- a/cli/src/utils/message-history.ts +++ b/cli/src/utils/message-history.ts @@ -1,13 +1,36 @@ import fs from 'fs' +import { createHash } from 'node:crypto' import path from 'path' import { getConfigDir } from './auth' import { formatTimestamp } from './helpers' import { logger } from './logger' -import type { ChatMessage, ContentBlock, FileAttachment, ImageAttachment, TextAttachment } from '../types/chat' - -const MAX_HISTORY_SIZE = 1000 +import type { + ChatMessage, + ContentBlock, + FileAttachment, + ImageAttachment, + TextAttachment, +} from '../types/chat' + +const MAX_HISTORY_SIZE = 500 +const HISTORY_SIZE_ENV_VAR = 'FREEBUFF_HISTORY_SIZE' +const HISTORY_SCOPE_ENV_VAR = 'FREEBUFF_HISTORY_SCOPE' + +function getProjectHistoryDirName(projectRoot: string): string { + const normalizedRoot = path.resolve(projectRoot) + const safeBaseName = (path.basename(normalizedRoot) || 'project').replace( + /[^a-zA-Z0-9._-]/g, + '_', + ) + const rootHash = createHash('sha256') + .update(normalizedRoot) + .digest('hex') + .slice(0, 12) + + return `${safeBaseName}-${rootHash}` +} export function getUserMessage( message: string | ContentBlock[], @@ -28,8 +51,12 @@ export function getUserMessage( }), timestamp: formatTimestamp(), ...(attachments && attachments.length > 0 ? { attachments } : {}), - ...(textAttachments && textAttachments.length > 0 ? { textAttachments } : {}), - ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), + ...(textAttachments && textAttachments.length > 0 + ? { textAttachments } + : {}), + ...(fileAttachments && fileAttachments.length > 0 + ? { fileAttachments } + : {}), } } @@ -54,7 +81,41 @@ export function getSystemMessage( /** * Get the message history file path */ -export const getMessageHistoryPath = (): string => { +function getMessageHistoryLimit(): number { + const value = process.env[HISTORY_SIZE_ENV_VAR]?.trim() + if (value === undefined) { + return MAX_HISTORY_SIZE + } + + if (!/^\d+$/.test(value)) { + return MAX_HISTORY_SIZE + } + + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) { + return MAX_HISTORY_SIZE + } + + return parsed +} + +function isMessageHistoryEnabled(): boolean { + return getMessageHistoryLimit() !== 0 +} + +/** + * Get the message history file path + */ +export const getMessageHistoryPath = (projectRoot?: string): string => { + if (projectRoot && process.env[HISTORY_SCOPE_ENV_VAR] !== 'global') { + return path.join( + getConfigDir(), + 'projects', + getProjectHistoryDirName(projectRoot), + 'message-history.json', + ) + } + return path.join(getConfigDir(), 'message-history.json') } @@ -62,8 +123,12 @@ export const getMessageHistoryPath = (): string => { * Load message history from file system * @returns Array of previous messages, most recent last */ -export const loadMessageHistory = (): string[] => { - const historyPath = getMessageHistoryPath() +export const loadMessageHistory = (projectRoot?: string): string[] => { + if (!isMessageHistoryEnabled()) { + return [] + } + + const historyPath = getMessageHistoryPath(projectRoot) if (!fs.existsSync(historyPath)) { return [] @@ -93,9 +158,17 @@ export const loadMessageHistory = (): string[] => { /** * Save message history to file system */ -export const saveMessageHistory = (history: string[]): void => { +export const saveMessageHistory = ( + history: string[], + projectRoot?: string, +): void => { + const historyLimit = getMessageHistoryLimit() + if (historyLimit === 0) { + return + } + const configDir = getConfigDir() - const historyPath = getMessageHistoryPath() + const historyPath = getMessageHistoryPath(projectRoot) try { // Ensure config directory exists @@ -105,11 +178,15 @@ export const saveMessageHistory = (history: string[]): void => { // Limit history size to prevent file from growing too large const limitedHistory = - history.length > MAX_HISTORY_SIZE - ? history.slice(history.length - MAX_HISTORY_SIZE) + history.length > historyLimit + ? history.slice(history.length - historyLimit) : history // Save history + const historyDir = path.dirname(historyPath) + if (!fs.existsSync(historyDir)) { + fs.mkdirSync(historyDir, { recursive: true }) + } fs.writeFileSync(historyPath, JSON.stringify(limitedHistory, null, 2)) } catch (error) { logger.error( @@ -122,11 +199,27 @@ export const saveMessageHistory = (history: string[]): void => { } } +export const appendMessageHistory = ( + message: string, + projectRoot?: string, +): string[] => { + if (!isMessageHistoryEnabled()) { + return [] + } + + const diskHistory = loadMessageHistory(projectRoot) + const newHistory = + diskHistory.at(-1) === message ? diskHistory : [...diskHistory, message] + + saveMessageHistory(newHistory, projectRoot) + return newHistory +} + /** * Clear message history from file system */ -export const clearMessageHistory = (): void => { - const historyPath = getMessageHistoryPath() +export const clearMessageHistory = (projectRoot?: string): void => { + const historyPath = getMessageHistoryPath(projectRoot) try { if (fs.existsSync(historyPath)) {