sillyspec 3.7.7 → 3.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.sillyspec/changes/dashboard/design.md +219 -0
  2. package/.sillyspec/plans/2026-04-05-dashboard.md +737 -0
  3. package/.sillyspec/specs/2026-04-05-dashboard-design.md +206 -0
  4. package/bin/sillyspec.js +0 -0
  5. package/package.json +1 -1
  6. package/packages/dashboard/dist/assets/index-Bh-GPjKY.css +1 -0
  7. package/packages/dashboard/dist/assets/index-CrCn5Gg6.js +17 -0
  8. package/packages/dashboard/dist/index.html +16 -0
  9. package/packages/dashboard/index.html +15 -0
  10. package/packages/dashboard/package-lock.json +2164 -0
  11. package/packages/dashboard/package.json +22 -0
  12. package/packages/dashboard/server/executor.js +86 -0
  13. package/packages/dashboard/server/index.js +359 -0
  14. package/packages/dashboard/server/parser.js +154 -0
  15. package/packages/dashboard/server/watcher.js +277 -0
  16. package/packages/dashboard/src/App.vue +154 -0
  17. package/packages/dashboard/src/components/ActionBar.vue +100 -0
  18. package/packages/dashboard/src/components/CommandPalette.vue +117 -0
  19. package/packages/dashboard/src/components/DetailPanel.vue +122 -0
  20. package/packages/dashboard/src/components/LogStream.vue +85 -0
  21. package/packages/dashboard/src/components/PipelineStage.vue +75 -0
  22. package/packages/dashboard/src/components/PipelineView.vue +94 -0
  23. package/packages/dashboard/src/components/ProjectList.vue +152 -0
  24. package/packages/dashboard/src/components/StageBadge.vue +53 -0
  25. package/packages/dashboard/src/components/StepCard.vue +89 -0
  26. package/packages/dashboard/src/composables/useDashboard.js +171 -0
  27. package/packages/dashboard/src/composables/useKeyboard.js +117 -0
  28. package/packages/dashboard/src/composables/useWebSocket.js +129 -0
  29. package/packages/dashboard/src/main.js +5 -0
  30. package/packages/dashboard/src/style.css +132 -0
  31. package/packages/dashboard/vite.config.js +18 -0
  32. package/src/index.js +68 -8
  33. package/src/init.js +23 -1
  34. package/src/progress.js +422 -0
  35. package/src/setup.js +16 -0
  36. package/templates/archive.md +56 -0
  37. package/templates/brainstorm.md +82 -26
  38. package/templates/commit.md +2 -0
  39. package/templates/execute.md +20 -1
  40. package/templates/progress-format.md +90 -0
  41. package/templates/quick.md +36 -3
  42. package/templates/resume-dialog.md +55 -0
  43. package/templates/skills/playwright-e2e/SKILL.md +1 -1
@@ -0,0 +1,277 @@
1
+ import chokidar from 'chokidar'
2
+ import { join } from 'path'
3
+ import { homedir } from 'os'
4
+ import { existsSync } from 'fs'
5
+ import { parseProjectState } from './parser.js'
6
+
7
+ let watcher = null
8
+ let updateCallback = null
9
+ let projectStates = new Map()
10
+
11
+ /**
12
+ * Start watching all .sillyspec directories
13
+ * @param {function} callback - Callback function when projects are updated
14
+ * @returns {object} The watcher instance
15
+ */
16
+ export function startWatcher(callback) {
17
+ if (watcher) {
18
+ stopWatcher()
19
+ }
20
+
21
+ updateCallback = callback
22
+
23
+ // Discover all .sillyspec directories
24
+ const home = homedir()
25
+ const cwd = process.cwd()
26
+
27
+ // Directories to exclude (system junk, cache, etc.)
28
+ const excludeDirs = new Set([
29
+ '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
30
+ '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
31
+ '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew'
32
+ ])
33
+
34
+ // Helper to check if directory should be excluded
35
+ const shouldExclude = (name) => {
36
+ // Check exact matches
37
+ if (excludeDirs.has(name)) return true
38
+ // Check wildcard patterns (like .Trash-*)
39
+ for (const pattern of excludeDirs) {
40
+ if (pattern.includes('*')) {
41
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
42
+ if (regex.test(name)) return true
43
+ }
44
+ }
45
+ // Exclude hidden directories (starting with .) unless it's the cwd basename
46
+ if (name.startsWith('.') && name !== cwd.split('/').pop()) {
47
+ return true
48
+ }
49
+ return false
50
+ }
51
+
52
+ // Build scan directories: cwd + home subdirs + common project locations
53
+ const scanDirs = [cwd, home]
54
+ const extraDirs = ['Desktop', 'Documents', 'Projects', 'Work', 'Repos', 'Code', 'src', 'dev']
55
+
56
+ for (const extra of extraDirs) {
57
+ const extraPath = join(home, extra)
58
+ if (existsSync(extraPath)) {
59
+ scanDirs.push(extraPath)
60
+ }
61
+ }
62
+
63
+ const watchPaths = []
64
+ const projects = []
65
+ const seen = new Set() // Dedupe by path
66
+
67
+ for (const baseDir of scanDirs) {
68
+ try {
69
+ const { readdirSync } = require('fs')
70
+ const entries = readdirSync(baseDir, { withFileTypes: true })
71
+
72
+ for (const entry of entries) {
73
+ if (!entry.isDirectory()) continue
74
+ if (shouldExclude(entry.name)) continue
75
+
76
+ const dirPath = join(baseDir, entry.name)
77
+
78
+ // Skip if we've already seen this path
79
+ if (seen.has(dirPath)) continue
80
+ seen.add(dirPath)
81
+
82
+ const sillyspecPath = join(dirPath, '.sillyspec')
83
+
84
+ if (existsSync(sillyspecPath)) {
85
+ watchPaths.push(sillyspecPath)
86
+ projects.push({
87
+ name: entry.name,
88
+ path: dirPath
89
+ })
90
+ }
91
+ }
92
+ } catch (err) {
93
+ // Skip directories we can't read
94
+ continue
95
+ }
96
+ }
97
+
98
+ // Parse initial states
99
+ for (const project of projects) {
100
+ const state = parseProjectState(project.path)
101
+ if (state) {
102
+ projectStates.set(project.name, { ...project, state })
103
+ }
104
+ }
105
+
106
+ // Emit initial state
107
+ if (updateCallback) {
108
+ updateCallback(Array.from(projectStates.values()))
109
+ }
110
+
111
+ // Watch for changes
112
+ watcher = chokidar.watch(watchPaths, {
113
+ ignored: /node_modules/,
114
+ persistent: true,
115
+ ignoreInitial: true,
116
+ awaitWriteFinish: {
117
+ stabilityThreshold: 300,
118
+ pollInterval: 100
119
+ },
120
+ depth: 5
121
+ })
122
+
123
+ watcher.on('change', async (filePath) => {
124
+ await handleFileChange(filePath)
125
+ })
126
+
127
+ watcher.on('add', async (filePath) => {
128
+ await handleFileChange(filePath)
129
+ })
130
+
131
+ watcher.on('unlink', async (filePath) => {
132
+ await handleFileChange(filePath)
133
+ })
134
+
135
+ watcher.on('error', (error) => {
136
+ console.error('Watcher error:', error)
137
+ })
138
+
139
+ return watcher
140
+ }
141
+
142
+ /**
143
+ * Handle file change events
144
+ * @param {string} filePath - Path to the changed file
145
+ */
146
+ async function handleFileChange(filePath) {
147
+ // Find which project this file belongs to
148
+ const projectName = Array.from(projectStates.values()).find(p =>
149
+ filePath.startsWith(p.path)
150
+ )?.name
151
+
152
+ if (!projectName) {
153
+ // Re-scan for new projects
154
+ await rescanProjects()
155
+ return
156
+ }
157
+
158
+ // Re-parse the project state
159
+ const project = projectStates.get(projectName)
160
+ if (project) {
161
+ const newState = parseProjectState(project.path)
162
+ if (newState) {
163
+ projectStates.set(projectName, { ...project, state: newState })
164
+ }
165
+ }
166
+
167
+ // Emit updated state
168
+ if (updateCallback) {
169
+ updateCallback(Array.from(projectStates.values()))
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Re-scan for projects (e.g., new .sillyspec directories)
175
+ */
176
+ async function rescanProjects() {
177
+ const home = homedir()
178
+ const cwd = process.cwd()
179
+
180
+ // Directories to exclude (system junk, cache, etc.)
181
+ const excludeDirs = new Set([
182
+ '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
183
+ '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
184
+ '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew'
185
+ ])
186
+
187
+ const shouldExclude = (name) => {
188
+ if (excludeDirs.has(name)) return true
189
+ for (const pattern of excludeDirs) {
190
+ if (pattern.includes('*')) {
191
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
192
+ if (regex.test(name)) return true
193
+ }
194
+ }
195
+ if (name.startsWith('.') && name !== cwd.split('/').pop()) {
196
+ return true
197
+ }
198
+ return false
199
+ }
200
+
201
+ // Build scan directories
202
+ const scanDirs = [cwd, home]
203
+ const extraDirs = ['Desktop', 'Documents', 'Projects', 'Work', 'Repos', 'Code', 'src', 'dev']
204
+
205
+ for (const extra of extraDirs) {
206
+ const extraPath = join(home, extra)
207
+ if (existsSync(extraPath)) {
208
+ scanDirs.push(extraPath)
209
+ }
210
+ }
211
+
212
+ const seen = new Set()
213
+
214
+ for (const baseDir of scanDirs) {
215
+ try {
216
+ const { readdirSync } = require('fs')
217
+ const entries = readdirSync(baseDir, { withFileTypes: true })
218
+
219
+ for (const entry of entries) {
220
+ if (!entry.isDirectory()) continue
221
+ if (shouldExclude(entry.name)) continue
222
+
223
+ const dirPath = join(baseDir, entry.name)
224
+ if (seen.has(dirPath)) continue
225
+ seen.add(dirPath)
226
+
227
+ const sillyspecPath = join(dirPath, '.sillyspec')
228
+
229
+ if (existsSync(sillyspecPath) && !projectStates.has(entry.name)) {
230
+ const state = parseProjectState(dirPath)
231
+ if (state) {
232
+ projectStates.set(entry.name, {
233
+ name: entry.name,
234
+ path: dirPath,
235
+ state
236
+ })
237
+ }
238
+ }
239
+ }
240
+ } catch (err) {
241
+ continue
242
+ }
243
+ }
244
+
245
+ if (updateCallback) {
246
+ updateCallback(Array.from(projectStates.values()))
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Stop the file watcher
252
+ */
253
+ export function stopWatcher() {
254
+ if (watcher) {
255
+ watcher.close()
256
+ watcher = null
257
+ projectStates.clear()
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Get current project states
263
+ * @returns {array} Array of project states
264
+ */
265
+ export function getProjectStates() {
266
+ return Array.from(projectStates.values())
267
+ }
268
+
269
+ /**
270
+ * Get a specific project state
271
+ * @param {string} projectName - Name of the project
272
+ * @returns {object|null} Project state or null
273
+ */
274
+ export function getProjectState(projectName) {
275
+ return projectStates.get(projectName) || null
276
+ }
277
+
@@ -0,0 +1,154 @@
1
+ <template>
2
+ <div class="h-screen w-screen flex flex-col overflow-hidden font-[DM_Sans,sans-serif] relative" style="background-color: #0A0A0B;">
3
+ <!-- Ambient background -->
4
+ <div class="absolute inset-0 pointer-events-none" style="background: radial-gradient(ellipse 60% 40% at 10% 20%, rgba(251,191,36,0.04) 0%, transparent 70%), radial-gradient(ellipse 50% 50% at 90% 80%, rgba(251,191,36,0.02) 0%, transparent 70%);" />
5
+
6
+ <!-- Main Content -->
7
+ <div class="flex-1 flex overflow-hidden relative z-10">
8
+ <!-- Left: Project List -->
9
+ <aside class="w-[240px] flex-shrink-0 relative" style="background: #111113; border-right: 1px solid #1F1F22;">
10
+ <ProjectList
11
+ :projects="dashboard.state.projects"
12
+ :active-project="dashboard.state.activeProject"
13
+ :is-loading="dashboard.state.isLoading"
14
+ @select="handleSelectProject"
15
+ />
16
+ </aside>
17
+
18
+ <!-- Center: Pipeline View -->
19
+ <main class="flex-1 overflow-hidden accent-stripe">
20
+ <PipelineView
21
+ :project="dashboard.state.activeProject"
22
+ :active-step="dashboard.state.activeStep"
23
+ @select-step="handleSelectStep"
24
+ />
25
+ </main>
26
+
27
+ <!-- Right: Detail Panel -->
28
+ <aside
29
+ :class="[
30
+ 'flex-shrink-0 transition-all duration-300 relative overflow-hidden',
31
+ dashboard.state.isPanelOpen ? 'w-[340px]' : 'w-0'
32
+ ]"
33
+ style="background: #111113; border-left: 1px solid #1F1F22;"
34
+ >
35
+ <DetailPanel
36
+ :is-open="dashboard.state.isPanelOpen"
37
+ :active-step="dashboard.state.activeStep"
38
+ :logs="dashboard.state.logs"
39
+ @close="dashboard.closePanel"
40
+ @clear-logs="dashboard.clearLogs"
41
+ />
42
+ </aside>
43
+ </div>
44
+
45
+ <!-- Bottom: Action Bar -->
46
+ <ActionBar
47
+ :project="dashboard.state.activeProject"
48
+ :is-executing="dashboard.state.executingProject !== null"
49
+ :execution-result="executionResult"
50
+ :is-panel-open="dashboard.state.isPanelOpen"
51
+ @execute="handleExecute"
52
+ @kill="handleKill"
53
+ @toggle-panel="dashboard.togglePanel"
54
+ @open-palette="isCommandPaletteOpen = true"
55
+ />
56
+
57
+ <!-- Command Palette Overlay -->
58
+ <CommandPalette
59
+ :is-open="isCommandPaletteOpen"
60
+ :projects="dashboard.state.projects"
61
+ @close="isCommandPaletteOpen = false"
62
+ @select-project="handleSelectProject"
63
+ @select-stage="handleSelectStage"
64
+ />
65
+ </div>
66
+ </template>
67
+
68
+ <script setup>
69
+ import { ref, onMounted } from 'vue'
70
+ import { useWebSocket } from './composables/useWebSocket.js'
71
+ import { useDashboard } from './composables/useDashboard.js'
72
+ import { useDashboardKeyboard } from './composables/useKeyboard.js'
73
+ import ProjectList from './components/ProjectList.vue'
74
+ import PipelineView from './components/PipelineView.vue'
75
+ import DetailPanel from './components/DetailPanel.vue'
76
+ import ActionBar from './components/ActionBar.vue'
77
+ import CommandPalette from './components/CommandPalette.vue'
78
+
79
+ // Composables
80
+ const ws = useWebSocket()
81
+ const dashboard = useDashboard()
82
+ const isCommandPaletteOpen = ref(false)
83
+ const executionResult = ref(null)
84
+
85
+ // Keyboard shortcuts
86
+ useDashboardKeyboard({
87
+ onOpenCommandPalette: () => { isCommandPaletteOpen.value = true },
88
+ onClose: () => {
89
+ if (isCommandPaletteOpen.value) {
90
+ isCommandPaletteOpen.value = false
91
+ } else if (dashboard.state.activeStep) {
92
+ dashboard.state.activeStep = null
93
+ } else {
94
+ dashboard.closePanel()
95
+ }
96
+ }
97
+ })
98
+
99
+ // WebSocket event handlers
100
+ onMounted(() => {
101
+ ws.on('projects:init', (projects) => { dashboard.updateProjects(projects) })
102
+ ws.on('projects:updated', (projects) => { dashboard.updateProjects(projects) })
103
+ ws.on('cli:output', (data) => {
104
+ if (data.projectName === dashboard.activeProjectName.value) {
105
+ dashboard.appendLog(data.output)
106
+ }
107
+ })
108
+ ws.on('cli:complete', (data) => {
109
+ if (data.projectName === dashboard.activeProjectName.value) {
110
+ dashboard.setExecuting(null)
111
+ executionResult.value = { exitCode: data.exitCode, signal: data.signal }
112
+ }
113
+ })
114
+ ws.on('cli:started', (data) => {
115
+ if (data.projectName === dashboard.activeProjectName.value) {
116
+ dashboard.setExecuting(data.projectName)
117
+ executionResult.value = null
118
+ }
119
+ })
120
+ ws.on('cli:killed', (data) => {
121
+ if (data.projectName === dashboard.activeProjectName.value) {
122
+ dashboard.setExecuting(null)
123
+ executionResult.value = { exitCode: -1, signal: 'SIGTERM' }
124
+ }
125
+ })
126
+ })
127
+
128
+ function handleSelectProject(project) { dashboard.selectProject(project) }
129
+ function handleSelectStage({ project, stage }) { dashboard.selectProject(project) }
130
+ function handleSelectStep(step) { dashboard.selectStep(step) }
131
+ function handleExecute() {
132
+ const projectName = dashboard.activeProjectName.value
133
+ if (!projectName) return
134
+ dashboard.clearLogs()
135
+ ws.send({ type: 'cli:execute', data: { projectName, command: 'next' } })
136
+ }
137
+ function handleKill() {
138
+ const projectName = dashboard.activeProjectName.value
139
+ if (!projectName) return
140
+ ws.send({ type: 'cli:kill', data: { projectName } })
141
+ }
142
+ </script>
143
+
144
+ <style>
145
+ * { margin: 0; padding: 0; box-sizing: border-box; }
146
+
147
+ body {
148
+ font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
149
+ -webkit-font-smoothing: antialiased;
150
+ -moz-osx-font-smoothing: grayscale;
151
+ }
152
+
153
+ #app { width: 100vw; height: 100vh; overflow: hidden; }
154
+ </style>
@@ -0,0 +1,100 @@
1
+ <template>
2
+ <div class="h-12 flex items-center justify-between px-5 relative" style="background: rgba(17,17,19,0.9); backdrop-filter: blur(20px); border-top: 1px solid #1F1F22;">
3
+ <!-- Ambient top glow -->
4
+ <div class="absolute inset-x-0 top-0 h-px" style="background: linear-gradient(90deg, transparent, rgba(251,191,36,0.1), transparent);"></div>
5
+
6
+ <!-- Left: Status -->
7
+ <div class="flex items-center gap-3">
8
+ <div v-if="project" class="flex items-center gap-2">
9
+ <span class="text-[11px] font-[JetBrains_Mono,monospace]" style="color: #525252;">{{ project.name }}</span>
10
+ <span style="color: #1F1F22;">|</span>
11
+ <StageBadge v-if="project.state?.currentStage" :status="getProjectStatus()" :label="stageLabel()" />
12
+ </div>
13
+ <div v-else class="text-[10px] font-[JetBrains_Mono,monospace]" style="color: #3A3A3D;">
14
+ no project
15
+ </div>
16
+ </div>
17
+
18
+ <!-- Center: Execution -->
19
+ <div class="flex items-center gap-2">
20
+ <div v-if="isExecuting" class="flex items-center gap-2">
21
+ <div class="w-1 h-1 rounded-full animate-pulse-dot" style="background: #FBBF24;" />
22
+ <span class="text-[10px] font-[JetBrains_Mono,monospace]" style="color: #FBBF24;">running</span>
23
+ </div>
24
+ <div v-else-if="executionResult" class="flex items-center gap-1.5">
25
+ <span class="text-[10px] font-[JetBrains_Mono,monospace]" :style="{ color: executionResult.exitCode === 0 ? '#34D399' : '#EF4444' }">
26
+ {{ executionResult.exitCode === 0 ? '● done' : `● exit ${executionResult.exitCode}` }}
27
+ </span>
28
+ </div>
29
+ </div>
30
+
31
+ <!-- Right: Actions -->
32
+ <div class="flex items-center gap-1">
33
+ <button
34
+ @click="$emit('toggle-panel')"
35
+ class="p-1.5 rounded-sm transition-colors duration-100"
36
+ style="color: #525252;"
37
+ @mouseenter="$event.target.style.color='#FBBF24';$event.target.style.background='rgba(251,191,36,0.06)'"
38
+ @mouseleave="$event.target.style.color='#525252';$event.target.style.background='transparent'"
39
+ title="Toggle detail panel"
40
+ >
41
+ <svg :class="['w-3.5 h-3.5 transition-transform duration-200', { 'rotate-180': !isPanelOpen }]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
42
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
43
+ </svg>
44
+ </button>
45
+
46
+ <button
47
+ v-if="isExecuting"
48
+ @click="$emit('kill')"
49
+ class="px-2.5 py-1 rounded-sm text-[10px] font-[JetBrains_Mono,monospace] transition-colors duration-100"
50
+ style="background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); color: #EF4444;"
51
+ >
52
+ kill
53
+ </button>
54
+
55
+ <button
56
+ @click="$emit('open-palette')"
57
+ class="p-1.5 rounded-sm transition-colors duration-100"
58
+ style="color: #525252;"
59
+ @mouseenter="$event.target.style.color='#FBBF24';$event.target.style.background='rgba(251,191,36,0.06)'"
60
+ @mouseleave="$event.target.style.color='#525252';$event.target.style.background='transparent'"
61
+ title="Command palette (⌘K)"
62
+ >
63
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
64
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
65
+ </svg>
66
+ </button>
67
+ </div>
68
+ </div>
69
+ </template>
70
+
71
+ <script setup>
72
+ import { computed } from 'vue'
73
+ import StageBadge from './StageBadge.vue'
74
+
75
+ const props = defineProps({
76
+ project: { type: Object, default: null },
77
+ isExecuting: { type: Boolean, default: false },
78
+ executionResult: { type: Object, default: null },
79
+ isPanelOpen: { type: Boolean, default: true }
80
+ })
81
+
82
+ const emit = defineEmits(['execute', 'kill', 'toggle-panel', 'open-palette'])
83
+
84
+ function getProjectStatus() {
85
+ if (!props.project?.state) return 'pending'
86
+ const stage = props.project.state.currentStage
87
+ const steps = props.project.state.progress?.stages?.[stage]?.steps || []
88
+ if (steps.some(s => s.status === 'failed')) return 'failed'
89
+ if (steps.some(s => s.status === 'blocked')) return 'blocked'
90
+ if (steps.some(s => s.status === 'in-progress')) return 'in-progress'
91
+ if (steps.every(s => s.status === 'completed')) return 'completed'
92
+ return 'pending'
93
+ }
94
+
95
+ function stageLabel() {
96
+ const stage = props.project?.state?.currentStage
97
+ const labels = { 'brainstorm': '头脑风暴', 'plan': '规划', 'execute': '执行', 'verify': '验证' }
98
+ return labels[stage] || stage || '未知'
99
+ }
100
+ </script>
@@ -0,0 +1,117 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <Transition name="backdrop">
4
+ <div v-if="isOpen" class="fixed inset-0 z-40" style="background: rgba(0,0,0,0.7); backdrop-filter: blur(4px);" @click="close" />
5
+ </Transition>
6
+
7
+ <Transition name="palette">
8
+ <div v-if="isOpen" class="fixed left-1/2 top-[18%] -translate-x-1/2 w-full max-w-md z-50">
9
+ <div class="overflow-hidden rounded-md" style="background: #141416; border: 1px solid #2A2A2D; box-shadow: 0 25px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(251,191,36,0.05);">
10
+ <!-- Search -->
11
+ <div class="px-4 py-3" style="border-bottom: 1px solid #1F1F22;">
12
+ <div class="flex items-center gap-3">
13
+ <svg class="w-3.5 h-3.5 flex-shrink-0" style="color: #525252;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
14
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
15
+ </svg>
16
+ <input
17
+ ref="searchInput"
18
+ v-model="searchQuery"
19
+ type="text"
20
+ placeholder="Search projects or stages..."
21
+ class="flex-1 bg-transparent border-none outline-none text-[12px] font-[JetBrains_Mono,monospace]"
22
+ style="color: #E4E4E7;"
23
+ @keydown="handleKeydown"
24
+ />
25
+ <kbd v-if="searchQuery" class="text-[9px] px-1 py-0.5 rounded-sm font-mono-log" style="color: #525252; background: #0A0A0B; border: 1px solid #2A2A2D;">ESC</kbd>
26
+ </div>
27
+ </div>
28
+
29
+ <!-- Results -->
30
+ <div class="max-h-72 overflow-y-auto">
31
+ <div v-if="filteredItems.length === 0" class="py-10 text-center">
32
+ <p class="text-[11px] font-[JetBrains_Mono,monospace]" style="color: #3A3A3D;">No results</p>
33
+ </div>
34
+ <div v-else class="py-0.5">
35
+ <div
36
+ v-for="(item, index) in filteredItems"
37
+ :key="item.id"
38
+ :class="['px-4 py-2.5 cursor-pointer transition-colors duration-100 flex items-center gap-3']"
39
+ :style="{ background: selectedIndex === index ? 'rgba(251,191,36,0.06)' : 'transparent' }"
40
+ @click="selectItem(item)"
41
+ @mouseenter="selectedIndex = index"
42
+ >
43
+ <div class="w-6 h-6 rounded-sm flex items-center justify-center text-[10px] font-[JetBrains_Mono,monospace] flex-shrink-0" style="background: #0A0A0B; border: 1px solid #1F1F22; color: #525252;">
44
+ {{ item.type === 'project' ? '□' : '◇' }}
45
+ </div>
46
+ <div class="flex-1 min-w-0">
47
+ <div class="text-[12px] font-medium truncate font-[JetBrains_Mono,monospace]" style="color: #E4E4E7;">{{ item.title }}</div>
48
+ <div class="text-[10px] truncate" style="color: #525252;">{{ item.subtitle }}</div>
49
+ </div>
50
+ <StageBadge v-if="item.status" :status="item.status" size="sm" />
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- Footer -->
56
+ <div class="px-4 py-2 flex items-center gap-4 text-[9px] font-mono-log" style="border-top: 1px solid #1F1F22; color: #3A3A3D;">
57
+ <span><kbd class="px-1 rounded-sm" style="background: #0A0A0B; border: 1px solid #1F1F22;">↑↓</kbd> nav</span>
58
+ <span><kbd class="px-1 rounded-sm" style="background: #0A0A0B; border: 1px solid #1F1F22;">↵</kbd> open</span>
59
+ <span><kbd class="px-1 rounded-sm" style="background: #0A0A0B; border: 1px solid #1F1F22;">esc</kbd> close</span>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </Transition>
64
+ </Teleport>
65
+ </template>
66
+
67
+ <script setup>
68
+ import { ref, computed, watch, nextTick } from 'vue'
69
+ import StageBadge from './StageBadge.vue'
70
+
71
+ const props = defineProps({ isOpen: { type: Boolean, default: false }, projects: { type: Array, default: () => [] } })
72
+ const emit = defineEmits(['close', 'select-project', 'select-stage'])
73
+
74
+ const searchQuery = ref('')
75
+ const searchInput = ref(null)
76
+ const selectedIndex = ref(0)
77
+
78
+ const stageNames = [
79
+ { id: 'brainstorm', name: '头脑风暴' },
80
+ { id: 'plan', name: '规划' },
81
+ { id: 'execute', name: '执行' },
82
+ { id: 'verify', name: '验证' }
83
+ ]
84
+
85
+ const filteredItems = computed(() => {
86
+ const items = []
87
+ for (const project of props.projects) {
88
+ const pm = !searchQuery.value || project.name.toLowerCase().includes(searchQuery.value.toLowerCase()) || project.path.toLowerCase().includes(searchQuery.value.toLowerCase())
89
+ if (pm) items.push({ id: `project-${project.name}`, type: 'project', title: project.name, subtitle: project.path, data: project, status: getProjectStatus(project) })
90
+ for (const stage of stageNames) {
91
+ const sm = !searchQuery.value || stage.name.toLowerCase().includes(searchQuery.value.toLowerCase()) || stage.id.toLowerCase().includes(searchQuery.value.toLowerCase())
92
+ if (sm || pm) items.push({ id: `stage-${project.name}-${stage.id}`, type: 'stage', title: `${project.name} / ${stage.name}`, subtitle: `Jump to ${stage.name}`, data: { project, stage: stage.id }, status: getStageStatus(project, stage.id) })
93
+ }
94
+ }
95
+ return items
96
+ })
97
+
98
+ function getProjectStatus(p) { const s = p.state?.progress?.stages?.[p.state?.currentStage]?.steps || []; if (s.some(x => x.status === 'failed')) return 'failed'; if (s.some(x => x.status === 'in-progress')) return 'in-progress'; if (s.every(x => x.status === 'completed')) return 'completed'; return 'pending' }
99
+ function getStageStatus(p, id) { const s = p.state?.progress?.stages?.[id]?.steps || []; if (!s.length) return 'pending'; if (s.some(x => x.status === 'failed')) return 'failed'; if (s.some(x => x.status === 'in-progress')) return 'in-progress'; if (s.every(x => x.status === 'completed')) return 'completed'; return 'pending' }
100
+ function close() { searchQuery.value = ''; selectedIndex.value = 0; emit('close') }
101
+ function selectItem(item) { if (item.type === 'project') emit('select-project', item.data); else emit('select-stage', item.data); close() }
102
+ function handleKeydown(e) {
103
+ if (e.key === 'ArrowDown') { e.preventDefault(); selectedIndex.value = Math.min(selectedIndex.value + 1, filteredItems.value.length - 1) }
104
+ else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIndex.value = Math.max(selectedIndex.value - 1, 0) }
105
+ else if (e.key === 'Enter') { e.preventDefault(); if (filteredItems.value[selectedIndex.value]) selectItem(filteredItems.value[selectedIndex.value]) }
106
+ else if (e.key === 'Escape') { e.preventDefault(); close() }
107
+ }
108
+ watch(filteredItems, () => { selectedIndex.value = 0 })
109
+ watch(() => props.isOpen, (v) => { if (v) nextTick(() => { searchInput.value?.focus() }) })
110
+ </script>
111
+
112
+ <style scoped>
113
+ .backdrop-enter-active, .backdrop-leave-active { transition: opacity 150ms ease; }
114
+ .backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
115
+ .palette-enter-active, .palette-leave-active { transition: all 200ms cubic-bezier(0.16, 1, 0.3, 1); }
116
+ .palette-enter-from, .palette-leave-to { opacity: 0; transform: translate(-50%, -8px) scale(0.98); }
117
+ </style>