Skip to content
Merged
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
61 changes: 42 additions & 19 deletions cli/src/components/chat-history-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { SelectableList } from './selectable-list'
import { useSearchableList } from '../hooks/use-searchable-list'
import { useTerminalLayout } from '../hooks/use-terminal-layout'
import { useTheme } from '../hooks/use-theme'
import { getAllChats, formatRelativeTime } from '../utils/chat-history'
import {
deleteChatSession,
formatRelativeTime,
getAllChats,
} from '../utils/chat-history'

import type { SelectableListItem } from './selectable-list'

Expand All @@ -21,6 +25,7 @@ const LAYOUT = {
MAX_RENDERED_CHATS: 100, // Only render this many in the list
TIME_COL_WIDTH: 12, // e.g., "2 hours ago"
MSGS_COL_WIDTH: 8, // e.g., "99 msgs"
DELETE_COL_WIDTH: 8, // e.g., " Delete "
GAP_WIDTH: 3, // gap between columns
} as const

Expand All @@ -42,34 +47,37 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
const contentWidth = terminalWidth - LAYOUT.CONTENT_PADDING

// Two-phase loading: load initial chats immediately, then more in background
const initialChats = useMemo(() => getAllChats(LAYOUT.INITIAL_CHATS), [])
const [backgroundChats, setBackgroundChats] = useState<typeof initialChats>(
[],
)
const [chats, setChats] = useState(() => getAllChats(LAYOUT.INITIAL_CHATS))
const [statusMessage, setStatusMessage] = useState<string | null>(null)

// Load more chats in the background after initial render
useEffect(() => {
// Use setTimeout to defer the expensive loading to after first paint
const timer = setTimeout(() => {
const moreChats = getAllChats(
LAYOUT.INITIAL_CHATS + LAYOUT.BACKGROUND_CHATS,
)
// Only keep the chats beyond the initial set
setBackgroundChats(moreChats.slice(LAYOUT.INITIAL_CHATS))
setChats(getAllChats(LAYOUT.INITIAL_CHATS + LAYOUT.BACKGROUND_CHATS))
}, 0)
return () => clearTimeout(timer)
}, [])

// Combine initial and background chats
const chats = useMemo(
() => [...initialChats, ...backgroundChats],
[initialChats, backgroundChats],
)
const handleDeleteChat = useCallback((chatId: string) => {
const deleted = deleteChatSession(chatId)
if (deleted) {
setChats((prev) => prev.filter((chat) => chat.chatId !== chatId))
setStatusMessage('Chat deleted')
return
}

setStatusMessage('Could not delete chat')
}, [])

// Calculate available width for the prompt text (last column, variable width)
// Format: "[time] [msgs] [prompt...]"
// Format: "[time] [msgs] [prompt...] [Delete]"
const reservedWidth =
LAYOUT.TIME_COL_WIDTH + LAYOUT.MSGS_COL_WIDTH + LAYOUT.GAP_WIDTH * 2 + 2 // +2 for padding
LAYOUT.TIME_COL_WIDTH +
LAYOUT.MSGS_COL_WIDTH +
LAYOUT.DELETE_COL_WIDTH +
LAYOUT.GAP_WIDTH * 2 +
2 // +2 for padding
const maxPromptWidth = Math.max(20, contentWidth - reservedWidth)

// Truncate text to fit single line
Expand Down Expand Up @@ -146,6 +154,13 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
[onSelectChat],
)

const handleChatDelete = useCallback(
(item: SelectableListItem) => {
handleDeleteChat(item.id)
},
[handleDeleteChat],
)

// Handle keyboard input
const handleKeyIntercept = useCallback(
(key: { name?: string; shift?: boolean; ctrl?: boolean }) => {
Expand Down Expand Up @@ -275,9 +290,11 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
items={filteredItems.slice(0, LAYOUT.MAX_RENDERED_CHATS)}
focusedIndex={focusedIndex}
onSelect={handleChatSelect}
actionLabel="Delete"
onAction={handleChatDelete}
onFocusChange={handleFocusChange}
emptyMessage={
initialChats.length === 0
chats.length === 0
? 'No chat history yet'
: searchQuery
? 'No matching chats'
Expand Down Expand Up @@ -314,8 +331,14 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
{/* Help text */}
<box style={{ flexGrow: 1, flexShrink: 1 }}>
<text style={{ fg: theme.muted }}>
↑↓ navigate · Enter select · Esc cancel
↑↓ navigate · Enter select · Click Delete to remove · Esc cancel
</text>
{statusMessage && (
<text style={{ fg: theme.muted }}>
{' · '}
{statusMessage}
</text>
)}
</box>

{/* Buttons - hidden on narrow screens */}
Expand Down
108 changes: 79 additions & 29 deletions cli/src/components/selectable-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface SelectableListProps {
/** Optional max height - if not provided, list fills available space */
maxHeight?: number
onSelect: (item: SelectableListItem, index: number) => void
actionLabel?: string
onAction?: (item: SelectableListItem, index: number) => void
onFocusChange?: (index: number) => void
emptyMessage?: string
}
Expand All @@ -53,7 +55,16 @@ export const SelectableList = forwardRef<
SelectableListProps
>(
(
{ items, focusedIndex, maxHeight, onSelect, onFocusChange, emptyMessage = 'No items' },
{
items,
focusedIndex,
maxHeight,
onSelect,
actionLabel,
onAction,
onFocusChange,
emptyMessage = 'No items',
},
ref,
) => {
const theme = useTheme()
Expand Down Expand Up @@ -141,13 +152,21 @@ export const SelectableList = forwardRef<
const isHighlighted = isFocused || isHovered

// Use subtle highlight that works in both light and dark themes
const backgroundColor = isHighlighted ? theme.surfaceHover : 'transparent'
const backgroundColor = isHighlighted
? theme.surfaceHover
: 'transparent'
const textColor = isHighlighted ? theme.foreground : theme.muted

return (
<Button
<box
key={item.id}
onClick={() => onSelect(item, idx)}
style={{
flexDirection: 'row',
width: '100%',
backgroundColor,
height: 1,
overflow: 'hidden',
}}
onMouseOver={() => {
setHoveredIndex(idx)
onFocusChange?.(idx)
Expand All @@ -157,37 +176,68 @@ export const SelectableList = forwardRef<
setHoveredIndex(null)
}
}}
style={{
flexDirection: 'row',
gap: 3,
backgroundColor,
paddingLeft: 1,
paddingRight: 1,
paddingTop: 0,
paddingBottom: 0,
height: 1,
overflow: 'hidden',
}}
>
{item.icon && (
<text style={{ fg: isHighlighted ? theme.foreground : theme.muted }}>
{item.icon}
</text>
)}
<text
<Button
onClick={() => onSelect(item, idx)}
style={{
fg: item.accent && !isHighlighted ? theme.primary : textColor,
attributes: item.accent || isHighlighted ? TextAttributes.BOLD : undefined,
flexDirection: 'row',
gap: 3,
width: '100%',
flexGrow: 1,
flexShrink: 1,
paddingLeft: 1,
paddingRight: 1,
paddingTop: 0,
paddingBottom: 0,
height: 1,
overflow: 'hidden',
}}
>
{item.label}
</text>
{item.secondary && !item.hideSecondary && (
<text style={{ fg: theme.muted }}>
{item.secondary}
{item.icon && (
<text
style={{
fg: isHighlighted ? theme.foreground : theme.muted,
}}
>
{item.icon}
</text>
)}
<text
style={{
fg:
item.accent && !isHighlighted ? theme.primary : textColor,
attributes:
item.accent || isHighlighted
? TextAttributes.BOLD
: undefined,
}}
>
{item.label}
</text>
{item.secondary && !item.hideSecondary && (
<text style={{ fg: theme.muted }}>{item.secondary}</text>
)}
</Button>
{actionLabel && onAction && (
<Button
onClick={() => onAction(item, idx)}
style={{
paddingLeft: 1,
paddingRight: 1,
paddingTop: 0,
paddingBottom: 0,
height: 1,
flexShrink: 0,
}}
>
<text
style={{ fg: isHighlighted ? theme.error : theme.muted }}
>
{actionLabel}
</text>
</Button>
)}
</Button>
</box>
)
})}
</scrollbox>
Expand Down
74 changes: 74 additions & 0 deletions cli/src/utils/__tests__/chat-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'

let tempDataDir = ''

mock.module('../../project-files', () => ({
getProjectDataDir: () => tempDataDir,
}))

mock.module('../logger', () => ({
logger: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
fatal: () => {},
},
}))

import { deleteChatSession, getAllChats } from '../chat-history'

function writeChat(chatId: string, prompt: string) {
const chatDir = path.join(tempDataDir, 'chats', chatId)
fs.mkdirSync(chatDir, { recursive: true })
fs.writeFileSync(
path.join(chatDir, 'chat-messages.json'),
JSON.stringify([
{
id: `${chatId}-message`,
variant: 'user',
content: prompt,
timestamp: new Date().toISOString(),
blocks: [],
},
]),
)
}

describe('chat-history', () => {
beforeEach(() => {
tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codebuff-history-'))
})

afterEach(() => {
fs.rmSync(tempDataDir, { recursive: true, force: true })
})

test('deleteChatSession removes a saved chat directory', () => {
writeChat('chat-a', 'hello from chat a')
writeChat('chat-b', 'hello from chat b')

expect(deleteChatSession('chat-a')).toBe(true)

expect(fs.existsSync(path.join(tempDataDir, 'chats', 'chat-a'))).toBe(false)
expect(fs.existsSync(path.join(tempDataDir, 'chats', 'chat-b'))).toBe(true)
expect(getAllChats().map((chat) => chat.chatId)).toEqual(['chat-b'])
})

test('deleteChatSession rejects invalid chat ids', () => {
const outsideDir = path.join(tempDataDir, 'outside')
fs.mkdirSync(outsideDir, { recursive: true })

expect(deleteChatSession('../outside')).toBe(false)
expect(deleteChatSession('..')).toBe(false)

expect(fs.existsSync(outsideDir)).toBe(true)
})

test('deleteChatSession returns false when the chat does not exist', () => {
expect(deleteChatSession('missing-chat')).toBe(false)
})
})
Loading
Loading