Files
claude-code/src/components/HistorySearchDialog.tsx
claude-code-best 5b1a52b8e0 更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files)

纯格式化:移除分号、React Compiler import、import 多行展开。
修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-2): 格式化 commands (79 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-5): 格式化 components其余 + hooks + tools (232 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md

- README.md: 大幅重写,更详细版本历史和配置示例
- Run.ps1: 新增 Windows 启动脚本
- TODO.md: 新增包完成清单
- V6.md: 删除(架构重构规划已不适用)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复以前的问题

* fix: 修复 login 面板的问题

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:24:27 +08:00

171 lines
4.8 KiB
TypeScript

import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useRegisterOverlay } from '../context/overlayContext.js'
import {
getTimestampedHistory,
type TimestampedHistoryEntry,
} from '../history.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { stringWidth } from '../ink/stringWidth.js'
import { wrapAnsi } from '../ink/wrapAnsi.js'
import { Box, Text } from '../ink.js'
import { logEvent } from '../services/analytics/index.js'
import type { HistoryEntry } from '../utils/config.js'
import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js'
import { FuzzyPicker } from './design-system/FuzzyPicker.js'
type Props = {
initialQuery?: string
onSelect: (entry: HistoryEntry) => void
onCancel: () => void
}
const PREVIEW_ROWS = 6
const AGE_WIDTH = 8
type Item = {
entry: TimestampedHistoryEntry
display: string
lower: string
firstLine: string
age: string
}
export function HistorySearchDialog({
initialQuery,
onSelect,
onCancel,
}: Props): React.ReactNode {
useRegisterOverlay('history-search')
const { columns } = useTerminalSize()
const [items, setItems] = useState<Item[] | null>(null)
const [query, setQuery] = useState(initialQuery ?? '')
useEffect(() => {
let cancelled = false
void (async () => {
const reader = getTimestampedHistory()
const loaded: Item[] = []
for await (const entry of reader) {
if (cancelled) {
void reader.return(undefined)
return
}
const display = entry.display
const nl = display.indexOf('\n')
const age = formatRelativeTimeAgo(new Date(entry.timestamp))
loaded.push({
entry,
display,
lower: display.toLowerCase(),
firstLine: nl === -1 ? display : display.slice(0, nl),
age: age + ' '.repeat(Math.max(0, AGE_WIDTH - stringWidth(age))),
})
}
if (!cancelled) setItems(loaded)
})()
return () => {
cancelled = true
}
}, [])
const filtered = useMemo(() => {
if (!items) return []
const q = query.trim().toLowerCase()
if (!q) return items
const exact: Item[] = []
const fuzzy: Item[] = []
for (const item of items) {
if (item.lower.includes(q)) {
exact.push(item)
} else if (isSubsequence(item.lower, q)) {
fuzzy.push(item)
}
}
return exact.concat(fuzzy)
}, [items, query])
const previewOnRight = columns >= 100
const listWidth = previewOnRight
? Math.floor((columns - 6) * 0.5)
: columns - 6
const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1)
const previewWidth = previewOnRight
? Math.max(20, columns - listWidth - 12)
: Math.max(20, columns - 10)
return (
<FuzzyPicker
title="Search prompts"
placeholder="Filter history…"
initialQuery={initialQuery}
items={filtered}
getKey={item => String(item.entry.timestamp)}
onQueryChange={setQuery}
onSelect={item => {
logEvent('tengu_history_picker_select', {
result_count: filtered.length,
query_length: query.length,
})
void item.entry.resolve().then(onSelect)
}}
onCancel={onCancel}
emptyMessage={q =>
items === null
? 'Loading…'
: q
? 'No matching prompts'
: 'No history yet'
}
selectAction="use"
direction="up"
previewPosition={previewOnRight ? 'right' : 'bottom'}
renderItem={(item, isFocused) => (
<Text>
<Text dimColor>{item.age}</Text>
<Text color={isFocused ? 'suggestion' : undefined}>
{' '}
{truncateToWidth(item.firstLine, rowWidth)}
</Text>
</Text>
)}
renderPreview={item => {
const wrapped = wrapAnsi(item.display, previewWidth, { hard: true })
.split('\n')
.filter(l => l.trim() !== '')
const overflow = wrapped.length > PREVIEW_ROWS
const shown = wrapped.slice(
0,
overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS,
)
const more = wrapped.length - shown.length
return (
<Box
flexDirection="column"
borderStyle="round"
borderDimColor
paddingX={1}
height={PREVIEW_ROWS + 2}
>
{shown.map((row, i) => (
<Text key={i} dimColor>
{row}
</Text>
))}
{more > 0 && <Text dimColor>{`… +${more} more lines`}</Text>}
</Box>
)
}}
/>
)
}
function isSubsequence(text: string, query: string): boolean {
let j = 0
for (let i = 0; i < text.length && j < query.length; i++) {
if (text[i] === query[j]) j++
}
return j === query.length
}