├── src ├── index.css ├── App.tsx ├── main.tsx ├── specflow │ ├── hooks │ │ ├── index.ts │ │ ├── useHotkeys.ts │ │ ├── useClipboard.ts │ │ └── useChainRunner.ts │ ├── components │ │ ├── SettingsIcon.tsx │ │ ├── InlineCheckbox.tsx │ │ ├── index.ts │ │ ├── CodeSearchOutputPreview.tsx │ │ ├── ToolbarButton.tsx │ │ ├── CopyButton.tsx │ │ ├── OutputViewerModal.tsx │ │ ├── ConfirmModal.tsx │ │ ├── ModelSelect.tsx │ │ ├── ExpandableTextarea.tsx │ │ ├── DropdownMenu.tsx │ │ ├── MultiSelectInfo.tsx │ │ ├── ArchivedMemberModal.tsx │ │ ├── TextEditorModal.tsx │ │ ├── Icons.tsx │ │ ├── CanvasFilePicker.tsx │ │ ├── RepoPickerModal.tsx │ │ └── APISettingsModal.tsx │ ├── ChainManager.tsx │ ├── types.ts │ ├── utils.ts │ ├── api.ts │ ├── i18n.ts │ └── nodes.tsx └── assets │ └── react.svg ├── docs └── images │ ├── banner.png │ ├── specflow-workflow.png │ └── specflow-minimal-workflow.png ├── examples └── example-repo │ ├── README.md │ └── src │ ├── auth.ts │ └── server.ts ├── tsconfig.json ├── server ├── rangeUtils.ts ├── repoContext.ts ├── searchRunLog.ts ├── openRouter.ts ├── defaultData.ts ├── repoBrowser.ts ├── index.ts └── appData.ts ├── vite.config.ts ├── index.html ├── .gitignore ├── eslint.config.js ├── tsconfig.node.json ├── tsconfig.app.json ├── AGENTS.md ├── LICENSE ├── package.json ├── public └── vite.svg ├── shared ├── rangeUtils.ts └── appDataTypes.ts ├── README.zh.md └── README.md /src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | -------------------------------------------------------------------------------- /docs/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuxueshuxue/SpexFlow/HEAD/docs/images/banner.png -------------------------------------------------------------------------------- /docs/images/specflow-workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuxueshuxue/SpexFlow/HEAD/docs/images/specflow-workflow.png -------------------------------------------------------------------------------- /docs/images/specflow-minimal-workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuxueshuxue/SpexFlow/HEAD/docs/images/specflow-minimal-workflow.png -------------------------------------------------------------------------------- /examples/example-repo/README.md: -------------------------------------------------------------------------------- 1 | # Example Repo (for Relace Search) 2 | 3 | This folder is a tiny “codebase” you can point the Relace Search tester at. 4 | 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /server/rangeUtils.ts: -------------------------------------------------------------------------------- 1 | export { mergeRanges, mergeCodeSearchOutputs } from '../shared/rangeUtils.js' 2 | export type { LineRange } from '../shared/rangeUtils.js' 3 | 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css' 2 | import { SpecFlowApp } from './specflow/SpecFlowApp' 3 | 4 | export default function App() { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | proxy: { 9 | '/api': 'http://localhost:3001', 10 | }, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /examples/example-repo/src/auth.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: string 3 | email: string 4 | } 5 | 6 | export function authenticate(email: string, password: string): User | null { 7 | if (email === 'demo@example.com' && password === 'password') { 8 | return { id: 'u_demo', email } 9 | } 10 | return null 11 | } 12 | 13 | -------------------------------------------------------------------------------- /src/specflow/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useAppData, type Selected } from './useAppData' 2 | export { useNodeRunner, type LocalOutput, type RunMode } from './useNodeRunner' 3 | export { useChainRunner } from './useChainRunner' 4 | export { useClipboard } from './useClipboard' 5 | export { useHotkeys, type InteractionMode } from './useHotkeys' 6 | -------------------------------------------------------------------------------- /examples/example-repo/src/server.ts: -------------------------------------------------------------------------------- 1 | import { authenticate } from './auth' 2 | 3 | export function handleLogin(body: { email: string; password: string }) { 4 | const user = authenticate(body.email, body.password) 5 | if (!user) { 6 | return { ok: false, error: 'invalid_credentials' as const } 7 | } 8 | return { ok: true, user } 9 | } 10 | 11 | -------------------------------------------------------------------------------- /src/specflow/components/SettingsIcon.tsx: -------------------------------------------------------------------------------- 1 | export function SettingsIcon() { 2 | return ( 3 | 4 | 5 | 6 | 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | spec-flow 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .apikey 27 | .llmkey 28 | data.json 29 | backup/* 30 | 31 | # Local canvases 32 | canvases/ 33 | -------------------------------------------------------------------------------- /src/specflow/components/InlineCheckbox.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | label: string 3 | checked: boolean 4 | onChange: (checked: boolean) => void 5 | disabled?: boolean 6 | } 7 | 8 | export function InlineCheckbox({ label, checked, onChange, disabled }: Props) { 9 | return ( 10 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { defineConfig, globalIgnores } from 'eslint/config' 7 | 8 | export default defineConfig([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs.flat.recommended, 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "types": ["vite/client"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AGENTS.md (spec-flow scope) 2 | 3 | ## 原则 4 | 5 | - 永远遵循最小实现原则;不做“未来可能用到”的扩展。 6 | - Debug 用 print/console/日志把现状打出来,再判断;不要用“match/猜测式”调试。 7 | - Fail loudly:不要吞错、不要静默 fallback。 8 | - 只在真正棘手处加注释,且使用格式:`@@@title - explanation`。 9 | 10 | ## 技术约定 11 | 12 | - 前端:React + TypeScript + Vite;画布用 `@xyflow/react`(React Flow)。 13 | - 后端:Express + TypeScript,入口 `server/index.ts`,用 `tsx watch` 跑。 14 | - 模块:保持 ESM(`"type": "module"`)。 15 | 16 | ## 常用命令 17 | 18 | - 安装:`pnpm install` 19 | - 开发:`pnpm dev`(并发启动 web + server) 20 | - 构建:`pnpm build` 21 | - 后端健康检查:`curl http://localhost:3001/api/health` 22 | 23 | ## 常见问题 24 | 25 | - 如果运行 `pnpm ...` 触发 corepack 报错 `Cannot find matching keyid`:直接在当前 Node 版本下安装 pnpm(绕过 corepack): 26 | - `npm i -g pnpm@9.15.9 --force`(如果提示 `EEXIST` file already exists) 27 | - `hash -r` 28 | - `pnpm -v` 29 | - 仍不生效就先打印确认:`which -a pnpm` 30 | -------------------------------------------------------------------------------- /src/specflow/components/index.ts: -------------------------------------------------------------------------------- 1 | export { NodeSidebar } from './NodeSidebar' 2 | export { CodeSearchOutputPreview } from './CodeSearchOutputPreview' 3 | export { ToolbarButton } from './ToolbarButton' 4 | export { MultiSelectInfo } from './MultiSelectInfo' 5 | export { TextEditorModal } from './TextEditorModal' 6 | export { ExpandableTextarea } from './ExpandableTextarea' 7 | export { InlineCheckbox } from './InlineCheckbox' 8 | export { CopyButton } from './CopyButton' 9 | export { APISettingsModal } from './APISettingsModal' 10 | export { ModelSelect } from './ModelSelect' 11 | export { SettingsIcon } from './SettingsIcon' 12 | export { OutputViewerModal } from './OutputViewerModal' 13 | export { ArchivedMemberModal } from './ArchivedMemberModal' 14 | export { RepoPickerModal } from './RepoPickerModal' 15 | export { DropdownMenu } from './DropdownMenu' 16 | export { CanvasFilePicker } from './CanvasFilePicker' 17 | export { ConfirmModal } from './ConfirmModal' 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 F2J 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spec-flow", 3 | "private": true, 4 | "version": "0.0.0", 5 | "packageManager": "pnpm@9.15.9", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "concurrently \"pnpm dev:web\" \"pnpm dev:server\"", 9 | "dev:web": "vite", 10 | "dev:server": "tsx watch server/index.ts", 11 | "build": "tsc -b && vite build", 12 | "lint": "eslint .", 13 | "preview": "vite preview" 14 | }, 15 | "dependencies": { 16 | "@xyflow/react": "^12.10.0", 17 | "express": "^5.2.1", 18 | "react": "^19.2.0", 19 | "react-dom": "^19.2.0" 20 | }, 21 | "devDependencies": { 22 | "@eslint/js": "^9.39.1", 23 | "@types/express": "^5.0.6", 24 | "@types/node": "^24.10.1", 25 | "@types/react": "^19.2.5", 26 | "@types/react-dom": "^19.2.3", 27 | "@vitejs/plugin-react": "^5.1.1", 28 | "concurrently": "^9.2.1", 29 | "eslint": "^9.39.1", 30 | "eslint-plugin-react-hooks": "^7.0.1", 31 | "eslint-plugin-react-refresh": "^0.4.24", 32 | "globals": "^16.5.0", 33 | "tsx": "^4.21.0", 34 | "typescript": "~5.9.3", 35 | "typescript-eslint": "^8.46.4", 36 | "vite": "^7.2.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/specflow/components/CodeSearchOutputPreview.tsx: -------------------------------------------------------------------------------- 1 | import { DocumentIcon } from './Icons' 2 | 3 | type CodeSearchOutput = { 4 | explanation?: string 5 | files: Record> 6 | } 7 | 8 | type Props = { 9 | output: CodeSearchOutput 10 | } 11 | 12 | export function CodeSearchOutputPreview({ output }: Props) { 13 | const fileEntries = Object.entries(output.files || {}) 14 | const formatRange = (start: number, end: number) => (end === -1 ? `L${start}-EOF` : `L${start}-${end}`) 15 | 16 | return ( 17 |
18 | {fileEntries.length > 0 && ( 19 |
20 | {fileEntries.map(([filePath, ranges]) => ( 21 |
22 | 23 | 24 | 25 | 26 | {filePath} 27 | 28 | 29 | {ranges.map(([start, end], idx) => ( 30 | 31 | {formatRange(start, end)} 32 | 33 | ))} 34 | 35 |
36 | ))} 37 |
38 | )} 39 | 40 | {fileEntries.length === 0 && !output.explanation && ( 41 |
No results
42 | )} 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/specflow/components/ToolbarButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react' 2 | 3 | type Props = { 4 | icon: React.ReactNode 5 | label: string 6 | description: string 7 | shortcut?: string 8 | isActive?: boolean 9 | onClick: () => void 10 | } 11 | 12 | export function ToolbarButton({ icon, label, description, shortcut, isActive, onClick }: Props) { 13 | const [isHovered, setIsHovered] = useState(false) 14 | const timeoutRef = useRef(null) 15 | 16 | function handleMouseEnter() { 17 | timeoutRef.current = window.setTimeout(() => { 18 | setIsHovered(true) 19 | }, 300) 20 | } 21 | 22 | function handleMouseLeave() { 23 | if (timeoutRef.current) { 24 | clearTimeout(timeoutRef.current) 25 | } 26 | setIsHovered(false) 27 | } 28 | 29 | return ( 30 |
31 | 40 | 41 | {isHovered && ( 42 |
43 |
44 |
45 | {label} 46 | {shortcut && {shortcut}} 47 |
48 |
{description}
49 |
50 |
51 | )} 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/specflow/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react' 2 | 3 | type Props = { 4 | getText: () => string 5 | label?: string 6 | copiedLabel?: string 7 | titleCopy?: string 8 | titleCopied?: string 9 | } 10 | 11 | export function CopyButton({ 12 | getText, 13 | label = 'Copy', 14 | copiedLabel = 'Copied', 15 | titleCopy = 'Copy to clipboard', 16 | titleCopied = 'Copied!', 17 | }: Props) { 18 | const [copied, setCopied] = useState(false) 19 | 20 | const handleCopy = useCallback(async () => { 21 | const text = getText() 22 | if (!text) return 23 | 24 | try { 25 | await navigator.clipboard.writeText(text) 26 | setCopied(true) 27 | setTimeout(() => setCopied(false), 2000) 28 | } catch (err) { 29 | console.error('Failed to copy:', err) 30 | } 31 | }, [getText]) 32 | 33 | return ( 34 | 49 | ) 50 | } 51 | 52 | function CopyIcon() { 53 | return ( 54 | 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | function CheckIcon() { 62 | return ( 63 | 64 | 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/specflow/components/OutputViewerModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | type Props = { 4 | isOpen: boolean 5 | title: string 6 | content: string 7 | onClose: () => void 8 | closeLabel?: string 9 | hintClose?: string 10 | closeTitle?: string 11 | } 12 | 13 | export function OutputViewerModal({ 14 | isOpen, 15 | title, 16 | content, 17 | onClose, 18 | closeLabel = 'Close', 19 | hintClose = 'Press Escape or click outside to close', 20 | closeTitle = 'Close (Esc)', 21 | }: Props) { 22 | // Handle escape key to close 23 | useEffect(() => { 24 | if (!isOpen) return 25 | 26 | function handleKeyDown(e: KeyboardEvent) { 27 | if (e.key === 'Escape') { 28 | onClose() 29 | } 30 | } 31 | 32 | document.addEventListener('keydown', handleKeyDown) 33 | return () => document.removeEventListener('keydown', handleKeyDown) 34 | }, [isOpen, onClose]) 35 | 36 | if (!isOpen) return null 37 | 38 | function handleBackdropClick(e: React.MouseEvent) { 39 | if (e.target === e.currentTarget) { 40 | onClose() 41 | } 42 | } 43 | 44 | return ( 45 |
46 |
47 |
48 | {title} 49 | 52 |
53 |
{content}
54 |
55 | {hintClose} 56 | 59 |
60 |
61 |
62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /server/repoContext.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises' 2 | import path from 'node:path' 3 | 4 | function resolveInRepo(repoRoot: string, filePath: string) { 5 | const normalized = path.posix.normalize(filePath) 6 | if (normalized.startsWith('..')) { 7 | throw new Error(`Invalid file path (escapes repo): ${filePath}`) 8 | } 9 | return path.join(repoRoot, ...normalized.split('/')) 10 | } 11 | 12 | function renderWithLineNumbers(content: string, start: number, end: number) { 13 | const lines = content.split('\n') 14 | const from = Math.max(1, start) 15 | const to = end === -1 ? lines.length : Math.max(from, end) 16 | const slice = lines.slice(from - 1, to) 17 | return slice.map((line, idx) => `${from + idx} ${line}`).join('\n') 18 | } 19 | 20 | export type FileRefs = Record 21 | 22 | export async function buildRepoContext(args: { 23 | repoRoot: string 24 | explanation?: string | null 25 | files: FileRefs 26 | fullFile: boolean 27 | }) { 28 | const parts: string[] = [] 29 | if (args.explanation) { 30 | parts.push('## Explanation') 31 | parts.push(args.explanation.trimEnd()) 32 | parts.push('') 33 | } 34 | 35 | parts.push('## Files') 36 | 37 | const filePaths = Object.keys(args.files).sort() 38 | for (const relPath of filePaths) { 39 | const abs = resolveInRepo(args.repoRoot, relPath) 40 | const content = await readFile(abs, 'utf-8') 41 | 42 | parts.push('') 43 | parts.push(`### ${relPath}`) 44 | 45 | if (args.fullFile) { 46 | parts.push(renderWithLineNumbers(content, 1, -1)) 47 | continue 48 | } 49 | 50 | const ranges = args.files[relPath] ?? [] 51 | for (const [start, end] of ranges) { 52 | parts.push('') 53 | parts.push(`#### ${relPath} [${start}-${end}]`) 54 | parts.push(renderWithLineNumbers(content, start, end)) 55 | } 56 | } 57 | 58 | parts.push('') 59 | return parts.join('\n') 60 | } 61 | 62 | -------------------------------------------------------------------------------- /server/searchRunLog.ts: -------------------------------------------------------------------------------- 1 | import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises' 2 | import path from 'node:path' 3 | 4 | export type SearchRunLogEntry = { 5 | id: string 6 | startedAt: string 7 | durationMs: number 8 | repoPath: string 9 | query: string 10 | ok: boolean 11 | error?: string 12 | trace?: { turn: number; toolCalls: string[] }[] 13 | reportFilesCount?: number 14 | messageDumpPath?: string 15 | messageStats?: { turn: number; messagesChars: number; messagesCount: number }[] 16 | } 17 | 18 | const logDir = path.join(process.cwd(), 'logs') 19 | const logPath = path.join(logDir, 'relace-search.jsonl') 20 | const dumpDir = path.join(logDir, 'relace-search-runs') 21 | 22 | export async function appendSearchRunLog(entry: SearchRunLogEntry) { 23 | await mkdir(logDir, { recursive: true }) 24 | await appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf-8') 25 | } 26 | 27 | export async function readRecentSearchRunLogs(limit: number) { 28 | const n = Math.max(1, Math.min(200, limit)) 29 | const raw = await readFile(logPath, 'utf-8').catch(() => '') 30 | if (!raw.trim()) return [] 31 | const lines = raw.trimEnd().split('\n') 32 | const tail = lines.slice(Math.max(0, lines.length - n)) 33 | return tail 34 | .map((line) => { 35 | try { 36 | return JSON.parse(line) as SearchRunLogEntry 37 | } catch { 38 | return null 39 | } 40 | }) 41 | .filter(Boolean) as SearchRunLogEntry[] 42 | } 43 | 44 | export async function writeSearchRunDump(runId: string, dump: unknown) { 45 | await mkdir(dumpDir, { recursive: true }) 46 | const p = path.join(dumpDir, `${runId}.json`) 47 | await writeFile(p, `${JSON.stringify(dump, null, 2)}\n`, 'utf-8') 48 | return p 49 | } 50 | 51 | export async function readSearchRunDump(runId: string) { 52 | const p = path.join(dumpDir, `${runId}.json`) 53 | const raw = await readFile(p, 'utf-8') 54 | return JSON.parse(raw) as unknown 55 | } 56 | -------------------------------------------------------------------------------- /src/specflow/components/ConfirmModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | type Props = { 4 | isOpen: boolean 5 | title: string 6 | message: string 7 | confirmLabel?: string 8 | cancelLabel?: string 9 | onConfirm: () => void 10 | onCancel: () => void 11 | } 12 | 13 | export function ConfirmModal({ 14 | isOpen, 15 | title, 16 | message, 17 | confirmLabel = 'Confirm', 18 | cancelLabel = 'Cancel', 19 | onConfirm, 20 | onCancel, 21 | }: Props) { 22 | useEffect(() => { 23 | if (!isOpen) return 24 | 25 | function handleKeyDown(e: KeyboardEvent) { 26 | if (e.key === 'Escape') onCancel() 27 | } 28 | 29 | document.addEventListener('keydown', handleKeyDown) 30 | return () => document.removeEventListener('keydown', handleKeyDown) 31 | }, [isOpen, onCancel]) 32 | 33 | if (!isOpen) return null 34 | 35 | function handleBackdropClick(e: React.MouseEvent) { 36 | if (e.target === e.currentTarget) onCancel() 37 | } 38 | 39 | return ( 40 |
41 |
e.stopPropagation()}> 42 |
43 | {title} 44 | 47 |
48 |
{message}
49 |
50 | 51 |
52 | 55 | 58 |
59 |
60 |
61 |
62 | ) 63 | } 64 | 65 | -------------------------------------------------------------------------------- /src/specflow/components/ModelSelect.tsx: -------------------------------------------------------------------------------- 1 | import type { APISettings, LLMModel } from '../types' 2 | 3 | type Props = { 4 | value: string 5 | onChange: (modelId: string) => void 6 | settings: APISettings 7 | disabled?: boolean 8 | label?: string 9 | selectPlaceholder?: string 10 | noModelsPlaceholder?: string 11 | } 12 | 13 | export function ModelSelect({ 14 | value, 15 | onChange, 16 | settings, 17 | disabled, 18 | label = 'Model', 19 | selectPlaceholder = 'Select a model...', 20 | noModelsPlaceholder = 'No models configured - open Settings to add', 21 | }: Props) { 22 | // Flatten all models from all providers with provider context 23 | const allModels: Array<{ model: LLMModel; providerName: string }> = [] 24 | 25 | for (const provider of settings.llm.providers) { 26 | for (const model of provider.models) { 27 | allModels.push({ model, providerName: provider.name }) 28 | } 29 | } 30 | 31 | if (allModels.length === 0) { 32 | return ( 33 |
34 | 35 | onChange(e.target.value)} 39 | disabled={disabled} 40 | placeholder={noModelsPlaceholder} 41 | /> 42 |
43 | ) 44 | } 45 | 46 | // Group models by provider for optgroup display 47 | const providerGroups = settings.llm.providers.filter(p => p.models.length > 0) 48 | 49 | return ( 50 |
51 | 52 | 69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/specflow/components/ExpandableTextarea.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { TextEditorModal } from './TextEditorModal' 3 | 4 | type Props = { 5 | label: string 6 | value: string 7 | onChange: (value: string) => void 8 | disabled?: boolean 9 | rows?: number 10 | placeholder?: string 11 | expandTitle?: string 12 | doneLabel?: string 13 | hintSave?: string 14 | closeTitle?: string 15 | } 16 | 17 | export function ExpandableTextarea({ 18 | label, 19 | value, 20 | onChange, 21 | disabled, 22 | rows = 5, 23 | placeholder, 24 | expandTitle = 'Open in larger editor', 25 | doneLabel, 26 | hintSave, 27 | closeTitle, 28 | }: Props) { 29 | const [isModalOpen, setIsModalOpen] = useState(false) 30 | 31 | return ( 32 | <> 33 |
34 |
35 | 36 | 44 |
45 |