Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.')])
}
},
[
Expand Down
31 changes: 15 additions & 16 deletions cli/src/hooks/use-input-history.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useRef, useCallback, useEffect } from 'react'

import {
appendMessageHistory,
loadMessageHistory,
saveMessageHistory,
} from '../utils/message-history'

import type { InputValue } from '../types/store'
Expand Down Expand Up @@ -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<string[]>([])
const historyIndexRef = useRef<number>(-1)
const currentDraftRef = useRef<string>('')
Expand All @@ -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
Expand All @@ -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
Expand Down
112 changes: 112 additions & 0 deletions cli/src/utils/__tests__/message-history.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spyOn> | 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'])
})
})
121 changes: 107 additions & 14 deletions cli/src/utils/message-history.ts
Original file line number Diff line number Diff line change
@@ -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[],
Expand All @@ -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 }
: {}),
}
}

Expand All @@ -54,16 +81,54 @@ 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')
}

/**
* 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 []
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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)) {
Expand Down
Loading