loopwork 0.3.0
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.
- package/CHANGELOG.md +52 -0
- package/README.md +528 -0
- package/bin/loopwork +0 -0
- package/examples/README.md +70 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +22 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +23 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +37 -0
- package/examples/basic-json-backend/.specs/tasks/tasks.json +19 -0
- package/examples/basic-json-backend/README.md +32 -0
- package/examples/basic-json-backend/TESTING.md +184 -0
- package/examples/basic-json-backend/hello.test.ts +9 -0
- package/examples/basic-json-backend/hello.ts +3 -0
- package/examples/basic-json-backend/loopwork.config.js +35 -0
- package/examples/basic-json-backend/math.test.ts +29 -0
- package/examples/basic-json-backend/math.ts +3 -0
- package/examples/basic-json-backend/package.json +15 -0
- package/examples/basic-json-backend/quick-start.sh +80 -0
- package/loopwork.config.ts +164 -0
- package/package.json +26 -0
- package/src/backends/github.ts +426 -0
- package/src/backends/index.ts +86 -0
- package/src/backends/json.ts +598 -0
- package/src/backends/plugin.ts +317 -0
- package/src/backends/types.ts +19 -0
- package/src/commands/init.ts +100 -0
- package/src/commands/run.ts +365 -0
- package/src/contracts/backend.ts +127 -0
- package/src/contracts/config.ts +129 -0
- package/src/contracts/index.ts +43 -0
- package/src/contracts/plugin.ts +82 -0
- package/src/contracts/task.ts +78 -0
- package/src/core/cli.ts +275 -0
- package/src/core/config.ts +165 -0
- package/src/core/state.ts +154 -0
- package/src/core/utils.ts +125 -0
- package/src/dashboard/cli.ts +449 -0
- package/src/dashboard/index.ts +6 -0
- package/src/dashboard/kanban.tsx +226 -0
- package/src/dashboard/tui.tsx +372 -0
- package/src/index.ts +19 -0
- package/src/mcp/server.ts +451 -0
- package/src/monitor/index.ts +420 -0
- package/src/plugins/asana.ts +192 -0
- package/src/plugins/cost-tracking.ts +402 -0
- package/src/plugins/discord.ts +269 -0
- package/src/plugins/everhour.ts +335 -0
- package/src/plugins/index.ts +253 -0
- package/src/plugins/telegram/bot.ts +517 -0
- package/src/plugins/telegram/index.ts +6 -0
- package/src/plugins/telegram/notifications.ts +198 -0
- package/src/plugins/todoist.ts +261 -0
- package/test/backends.test.ts +929 -0
- package/test/cli.test.ts +145 -0
- package/test/config.test.ts +90 -0
- package/test/e2e.test.ts +458 -0
- package/test/github-tasks.test.ts +191 -0
- package/test/loopwork-config-types.test.ts +288 -0
- package/test/monitor.test.ts +123 -0
- package/test/plugins.test.ts +1175 -0
- package/test/state.test.ts +295 -0
- package/test/utils.test.ts +60 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { LoopworkMonitor } from '../monitor'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Loopwork Dashboard
|
|
8
|
+
*
|
|
9
|
+
* Displays comprehensive status, logs, and metrics for all running loops.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface TaskStats {
|
|
13
|
+
completed: number
|
|
14
|
+
failed: number
|
|
15
|
+
pending: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface NamespaceStats {
|
|
19
|
+
namespace: string
|
|
20
|
+
status: 'running' | 'stopped'
|
|
21
|
+
pid?: number
|
|
22
|
+
uptime?: string
|
|
23
|
+
lastRun?: string
|
|
24
|
+
currentTask?: string
|
|
25
|
+
tasks: TaskStats
|
|
26
|
+
iterations: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class Dashboard {
|
|
30
|
+
private monitor: LoopworkMonitor
|
|
31
|
+
private projectRoot: string
|
|
32
|
+
|
|
33
|
+
constructor(projectRoot?: string) {
|
|
34
|
+
this.projectRoot = projectRoot || process.cwd()
|
|
35
|
+
this.monitor = new LoopworkMonitor(this.projectRoot)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Display full dashboard
|
|
40
|
+
*/
|
|
41
|
+
display(): void {
|
|
42
|
+
console.clear()
|
|
43
|
+
this.printHeader()
|
|
44
|
+
this.printRunningLoops()
|
|
45
|
+
this.printRecentActivity()
|
|
46
|
+
this.printHelp()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private printHeader(): void {
|
|
50
|
+
console.log(chalk.bold.cyan('\n╔══════════════════════════════════════════════════════════════╗'))
|
|
51
|
+
console.log(chalk.bold.cyan('║ RALPH LOOP DASHBOARD ║'))
|
|
52
|
+
console.log(chalk.bold.cyan('╚══════════════════════════════════════════════════════════════╝'))
|
|
53
|
+
console.log(chalk.gray(` ${new Date().toLocaleString()}`))
|
|
54
|
+
console.log()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private printRunningLoops(): void {
|
|
58
|
+
const { running, namespaces } = this.monitor.getStatus()
|
|
59
|
+
|
|
60
|
+
console.log(chalk.bold.white('┌─ Running Loops ─────────────────────────────────────────────┐'))
|
|
61
|
+
|
|
62
|
+
if (running.length === 0) {
|
|
63
|
+
console.log(chalk.gray('│ No loops currently running │'))
|
|
64
|
+
} else {
|
|
65
|
+
for (const proc of running) {
|
|
66
|
+
const stats = this.getNamespaceStats(proc.namespace)
|
|
67
|
+
const uptime = this.getUptime(proc.startedAt)
|
|
68
|
+
|
|
69
|
+
console.log(chalk.green(`│ ● ${chalk.bold(proc.namespace.padEnd(15))} PID: ${String(proc.pid).padEnd(8)} Uptime: ${uptime.padEnd(10)} │`))
|
|
70
|
+
|
|
71
|
+
if (stats.currentTask) {
|
|
72
|
+
console.log(chalk.gray(`│ └─ Current: ${stats.currentTask.padEnd(43)} │`))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const taskLine = `Completed: ${chalk.green(stats.tasks.completed)} | Failed: ${chalk.red(stats.tasks.failed)} | Pending: ${chalk.yellow(stats.tasks.pending)}`
|
|
76
|
+
console.log(chalk.gray(`│ └─ ${taskLine.padEnd(51)} │`))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(chalk.white('└─────────────────────────────────────────────────────────────┘'))
|
|
81
|
+
console.log()
|
|
82
|
+
|
|
83
|
+
// Show stopped namespaces
|
|
84
|
+
const stopped = namespaces.filter(n => n.status === 'stopped')
|
|
85
|
+
if (stopped.length > 0) {
|
|
86
|
+
console.log(chalk.bold.white('┌─ Available Namespaces ───────────────────────────────────────┐'))
|
|
87
|
+
for (const ns of stopped) {
|
|
88
|
+
const lastRunStr = ns.lastRun || 'never'
|
|
89
|
+
console.log(chalk.gray(`│ ○ ${ns.name.padEnd(20)} Last run: ${lastRunStr.padEnd(25)} │`))
|
|
90
|
+
}
|
|
91
|
+
console.log(chalk.white('└─────────────────────────────────────────────────────────────┘'))
|
|
92
|
+
console.log()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private printRecentActivity(): void {
|
|
97
|
+
console.log(chalk.bold.white('┌─ Recent Activity ───────────────────────────────────────────┐'))
|
|
98
|
+
|
|
99
|
+
const activity = this.getRecentActivity()
|
|
100
|
+
|
|
101
|
+
if (activity.length === 0) {
|
|
102
|
+
console.log(chalk.gray('│ No recent activity │'))
|
|
103
|
+
} else {
|
|
104
|
+
for (const item of activity.slice(0, 10)) {
|
|
105
|
+
const icon = item.type === 'completed' ? chalk.green('✓')
|
|
106
|
+
: item.type === 'failed' ? chalk.red('✗')
|
|
107
|
+
: chalk.blue('→')
|
|
108
|
+
|
|
109
|
+
const line = `${icon} ${chalk.gray(item.time)} ${item.namespace}: ${item.message}`
|
|
110
|
+
console.log(`│ ${line.padEnd(65)}│`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(chalk.white('└─────────────────────────────────────────────────────────────┘'))
|
|
115
|
+
console.log()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private printHelp(): void {
|
|
119
|
+
console.log(chalk.gray('Commands:'))
|
|
120
|
+
console.log(chalk.gray(' q - Quit | r - Refresh | s - Start loop | k - Kill loop | l - View logs'))
|
|
121
|
+
console.log()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get stats for a specific namespace
|
|
126
|
+
*/
|
|
127
|
+
private getNamespaceStats(namespace: string): NamespaceStats {
|
|
128
|
+
const stats: NamespaceStats = {
|
|
129
|
+
namespace,
|
|
130
|
+
status: 'stopped',
|
|
131
|
+
tasks: { completed: 0, failed: 0, pending: 0 },
|
|
132
|
+
iterations: 0,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check if running
|
|
136
|
+
const running = this.monitor.getRunningProcesses()
|
|
137
|
+
const proc = running.find(p => p.namespace === namespace)
|
|
138
|
+
|
|
139
|
+
if (proc) {
|
|
140
|
+
stats.status = 'running'
|
|
141
|
+
stats.pid = proc.pid
|
|
142
|
+
stats.uptime = this.getUptime(proc.startedAt)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Parse state file for current task
|
|
146
|
+
const stateFile = path.join(
|
|
147
|
+
this.projectRoot,
|
|
148
|
+
namespace === 'default' ? '.loopwork-state' : `.loopwork-state-${namespace}`
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if (fs.existsSync(stateFile)) {
|
|
152
|
+
try {
|
|
153
|
+
const content = fs.readFileSync(stateFile, 'utf-8')
|
|
154
|
+
const lines = content.split('\n')
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
const [key, value] = line.split('=')
|
|
157
|
+
if (key === 'LAST_ISSUE' && value) {
|
|
158
|
+
stats.currentTask = `Task #${value}`
|
|
159
|
+
}
|
|
160
|
+
if (key === 'LAST_ITERATION' && value) {
|
|
161
|
+
stats.iterations = parseInt(value, 10)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Count tasks from logs
|
|
168
|
+
const logsDir = path.join(this.projectRoot, 'loopwork-runs', namespace)
|
|
169
|
+
if (fs.existsSync(logsDir)) {
|
|
170
|
+
const runDirs = fs.readdirSync(logsDir, { withFileTypes: true })
|
|
171
|
+
.filter(d => d.isDirectory() && d.name !== 'monitor-logs')
|
|
172
|
+
|
|
173
|
+
for (const dir of runDirs) {
|
|
174
|
+
const logPath = path.join(logsDir, dir.name, 'logs')
|
|
175
|
+
if (fs.existsSync(logPath)) {
|
|
176
|
+
const files = fs.readdirSync(logPath)
|
|
177
|
+
for (const file of files) {
|
|
178
|
+
if (file.includes('completed')) stats.tasks.completed++
|
|
179
|
+
else if (file.includes('failed')) stats.tasks.failed++
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return stats
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get recent activity across all namespaces
|
|
190
|
+
*/
|
|
191
|
+
private getRecentActivity(): { time: string; namespace: string; type: string; message: string }[] {
|
|
192
|
+
const activity: { time: string; namespace: string; type: string; message: string }[] = []
|
|
193
|
+
|
|
194
|
+
const runsDir = path.join(this.projectRoot, 'loopwork-runs')
|
|
195
|
+
if (!fs.existsSync(runsDir)) return activity
|
|
196
|
+
|
|
197
|
+
const namespaces = fs.readdirSync(runsDir, { withFileTypes: true })
|
|
198
|
+
.filter(d => d.isDirectory())
|
|
199
|
+
|
|
200
|
+
for (const ns of namespaces) {
|
|
201
|
+
const monitorLogsDir = path.join(runsDir, ns.name, 'monitor-logs')
|
|
202
|
+
if (!fs.existsSync(monitorLogsDir)) continue
|
|
203
|
+
|
|
204
|
+
const logFiles = fs.readdirSync(monitorLogsDir)
|
|
205
|
+
.filter(f => f.endsWith('.log'))
|
|
206
|
+
.sort()
|
|
207
|
+
.reverse()
|
|
208
|
+
.slice(0, 1)
|
|
209
|
+
|
|
210
|
+
for (const logFile of logFiles) {
|
|
211
|
+
const content = fs.readFileSync(path.join(monitorLogsDir, logFile), 'utf-8')
|
|
212
|
+
const lines = content.split('\n').slice(-50)
|
|
213
|
+
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
if (line.includes('[SUCCESS]') && line.includes('completed')) {
|
|
216
|
+
const time = this.extractTime(line)
|
|
217
|
+
const taskMatch = line.match(/Task (\S+)/)
|
|
218
|
+
activity.push({
|
|
219
|
+
time,
|
|
220
|
+
namespace: ns.name,
|
|
221
|
+
type: 'completed',
|
|
222
|
+
message: taskMatch ? `Completed ${taskMatch[1]}` : 'Task completed',
|
|
223
|
+
})
|
|
224
|
+
} else if (line.includes('[ERROR]') && line.includes('failed')) {
|
|
225
|
+
const time = this.extractTime(line)
|
|
226
|
+
const taskMatch = line.match(/Task (\S+)/)
|
|
227
|
+
activity.push({
|
|
228
|
+
time,
|
|
229
|
+
namespace: ns.name,
|
|
230
|
+
type: 'failed',
|
|
231
|
+
message: taskMatch ? `Failed ${taskMatch[1]}` : 'Task failed',
|
|
232
|
+
})
|
|
233
|
+
} else if (line.includes('Iteration')) {
|
|
234
|
+
const time = this.extractTime(line)
|
|
235
|
+
const iterMatch = line.match(/Iteration (\d+)/)
|
|
236
|
+
if (iterMatch) {
|
|
237
|
+
activity.push({
|
|
238
|
+
time,
|
|
239
|
+
namespace: ns.name,
|
|
240
|
+
type: 'progress',
|
|
241
|
+
message: `Started iteration ${iterMatch[1]}`,
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Sort by time descending
|
|
250
|
+
activity.sort((a, b) => b.time.localeCompare(a.time))
|
|
251
|
+
|
|
252
|
+
return activity.slice(0, 10)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private extractTime(line: string): string {
|
|
256
|
+
const match = line.match(/\d{1,2}:\d{2}:\d{2}\s*[AP]M/i)
|
|
257
|
+
return match ? match[0] : ''
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private getUptime(startedAt: string): string {
|
|
261
|
+
const start = new Date(startedAt).getTime()
|
|
262
|
+
const now = Date.now()
|
|
263
|
+
const diff = now - start
|
|
264
|
+
|
|
265
|
+
const hours = Math.floor(diff / (1000 * 60 * 60))
|
|
266
|
+
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
267
|
+
|
|
268
|
+
if (hours > 0) {
|
|
269
|
+
return `${hours}h ${mins}m`
|
|
270
|
+
}
|
|
271
|
+
return `${mins}m`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Interactive mode with auto-refresh
|
|
276
|
+
*/
|
|
277
|
+
async interactive(): Promise<void> {
|
|
278
|
+
const readline = await import('readline')
|
|
279
|
+
|
|
280
|
+
// Enable raw mode for single keypress
|
|
281
|
+
if (process.stdin.isTTY) {
|
|
282
|
+
process.stdin.setRawMode(true)
|
|
283
|
+
}
|
|
284
|
+
process.stdin.resume()
|
|
285
|
+
|
|
286
|
+
const refresh = () => {
|
|
287
|
+
this.display()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
refresh()
|
|
291
|
+
|
|
292
|
+
// Auto-refresh every 5 seconds
|
|
293
|
+
const interval = setInterval(refresh, 5000)
|
|
294
|
+
|
|
295
|
+
process.stdin.on('data', async (data) => {
|
|
296
|
+
const key = data.toString()
|
|
297
|
+
|
|
298
|
+
if (key === 'q' || key === '\u0003') {
|
|
299
|
+
// Quit
|
|
300
|
+
clearInterval(interval)
|
|
301
|
+
if (process.stdin.isTTY) {
|
|
302
|
+
process.stdin.setRawMode(false)
|
|
303
|
+
}
|
|
304
|
+
process.stdin.pause()
|
|
305
|
+
console.log(chalk.gray('\nExiting dashboard...'))
|
|
306
|
+
process.exit(0)
|
|
307
|
+
} else if (key === 'r') {
|
|
308
|
+
refresh()
|
|
309
|
+
} else if (key === 's') {
|
|
310
|
+
// Start a new loop
|
|
311
|
+
clearInterval(interval)
|
|
312
|
+
if (process.stdin.isTTY) {
|
|
313
|
+
process.stdin.setRawMode(false)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const rl = readline.createInterface({
|
|
317
|
+
input: process.stdin,
|
|
318
|
+
output: process.stdout,
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
rl.question(chalk.cyan('Enter namespace to start: '), async (namespace) => {
|
|
322
|
+
rl.close()
|
|
323
|
+
if (namespace) {
|
|
324
|
+
const result = await this.monitor.start(namespace.trim())
|
|
325
|
+
if (result.success) {
|
|
326
|
+
console.log(chalk.green(`✓ Started ${namespace} (PID: ${result.pid})`))
|
|
327
|
+
} else {
|
|
328
|
+
console.log(chalk.red(`✗ ${result.error}`))
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
setTimeout(() => {
|
|
332
|
+
if (process.stdin.isTTY) {
|
|
333
|
+
process.stdin.setRawMode(true)
|
|
334
|
+
}
|
|
335
|
+
refresh()
|
|
336
|
+
}, 1000)
|
|
337
|
+
})
|
|
338
|
+
} else if (key === 'k') {
|
|
339
|
+
// Kill a loop
|
|
340
|
+
clearInterval(interval)
|
|
341
|
+
if (process.stdin.isTTY) {
|
|
342
|
+
process.stdin.setRawMode(false)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const rl = readline.createInterface({
|
|
346
|
+
input: process.stdin,
|
|
347
|
+
output: process.stdout,
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
rl.question(chalk.cyan('Enter namespace to stop (or "all"): '), (namespace) => {
|
|
351
|
+
rl.close()
|
|
352
|
+
if (namespace === 'all') {
|
|
353
|
+
const result = this.monitor.stopAll()
|
|
354
|
+
if (result.stopped.length > 0) {
|
|
355
|
+
console.log(chalk.green(`✓ Stopped: ${result.stopped.join(', ')}`))
|
|
356
|
+
}
|
|
357
|
+
} else if (namespace) {
|
|
358
|
+
const result = this.monitor.stop(namespace.trim())
|
|
359
|
+
if (result.success) {
|
|
360
|
+
console.log(chalk.green(`✓ Stopped ${namespace}`))
|
|
361
|
+
} else {
|
|
362
|
+
console.log(chalk.red(`✗ ${result.error}`))
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
setTimeout(() => {
|
|
366
|
+
if (process.stdin.isTTY) {
|
|
367
|
+
process.stdin.setRawMode(true)
|
|
368
|
+
}
|
|
369
|
+
refresh()
|
|
370
|
+
}, 1000)
|
|
371
|
+
})
|
|
372
|
+
} else if (key === 'l') {
|
|
373
|
+
// View logs
|
|
374
|
+
clearInterval(interval)
|
|
375
|
+
if (process.stdin.isTTY) {
|
|
376
|
+
process.stdin.setRawMode(false)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const rl = readline.createInterface({
|
|
380
|
+
input: process.stdin,
|
|
381
|
+
output: process.stdout,
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
rl.question(chalk.cyan('Enter namespace for logs: '), (namespace) => {
|
|
385
|
+
rl.close()
|
|
386
|
+
if (namespace) {
|
|
387
|
+
const logs = this.monitor.getLogs(namespace.trim(), 30)
|
|
388
|
+
console.log(chalk.gray('\n─'.repeat(60)))
|
|
389
|
+
console.log(logs.join('\n'))
|
|
390
|
+
console.log(chalk.gray('─'.repeat(60)))
|
|
391
|
+
console.log(chalk.gray('Press any key to return...'))
|
|
392
|
+
|
|
393
|
+
process.stdin.once('data', () => {
|
|
394
|
+
if (process.stdin.isTTY) {
|
|
395
|
+
process.stdin.setRawMode(true)
|
|
396
|
+
}
|
|
397
|
+
refresh()
|
|
398
|
+
})
|
|
399
|
+
} else {
|
|
400
|
+
if (process.stdin.isTTY) {
|
|
401
|
+
process.stdin.setRawMode(true)
|
|
402
|
+
}
|
|
403
|
+
refresh()
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* One-time status display
|
|
412
|
+
*/
|
|
413
|
+
static display(): void {
|
|
414
|
+
const dashboard = new Dashboard()
|
|
415
|
+
dashboard.display()
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* CLI entry point
|
|
421
|
+
*/
|
|
422
|
+
async function main() {
|
|
423
|
+
const args = process.argv.slice(2)
|
|
424
|
+
const command = args[0]
|
|
425
|
+
|
|
426
|
+
const dashboard = new Dashboard()
|
|
427
|
+
|
|
428
|
+
switch (command) {
|
|
429
|
+
case 'watch':
|
|
430
|
+
case '-w':
|
|
431
|
+
await dashboard.interactive()
|
|
432
|
+
break
|
|
433
|
+
|
|
434
|
+
case 'status':
|
|
435
|
+
default:
|
|
436
|
+
dashboard.display()
|
|
437
|
+
break
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export { Dashboard }
|
|
442
|
+
|
|
443
|
+
// Only run main when executed directly, not when imported
|
|
444
|
+
if (import.meta.main) {
|
|
445
|
+
main().catch((err) => {
|
|
446
|
+
console.error(chalk.red(`Error: ${err.message}`))
|
|
447
|
+
process.exit(1)
|
|
448
|
+
})
|
|
449
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kanban Board TUI
|
|
3
|
+
*
|
|
4
|
+
* Simple kanban view of tasks using ink (React for CLI)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useState, useEffect } from 'react'
|
|
8
|
+
import { render, Box, Text, useInput, useApp } from 'ink'
|
|
9
|
+
import type { Task, TaskStatus } from '../contracts'
|
|
10
|
+
import { createBackend, detectBackend } from '../backends'
|
|
11
|
+
|
|
12
|
+
interface Column {
|
|
13
|
+
status: TaskStatus
|
|
14
|
+
title: string
|
|
15
|
+
color: string
|
|
16
|
+
tasks: Task[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface KanbanProps {
|
|
20
|
+
projectRoot: string
|
|
21
|
+
refreshInterval?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function TaskCard({ task, selected }: { task: Task; selected: boolean }) {
|
|
25
|
+
const priorityColor = {
|
|
26
|
+
high: 'red',
|
|
27
|
+
medium: 'yellow',
|
|
28
|
+
low: 'gray',
|
|
29
|
+
}[task.priority]
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Box
|
|
33
|
+
flexDirection="column"
|
|
34
|
+
borderStyle={selected ? 'double' : 'single'}
|
|
35
|
+
borderColor={selected ? 'cyan' : 'gray'}
|
|
36
|
+
paddingX={1}
|
|
37
|
+
marginBottom={1}
|
|
38
|
+
>
|
|
39
|
+
<Text bold color={selected ? 'cyan' : 'white'}>
|
|
40
|
+
{task.id}
|
|
41
|
+
</Text>
|
|
42
|
+
<Text wrap="truncate-end">
|
|
43
|
+
{task.title.length > 28 ? task.title.slice(0, 25) + '...' : task.title}
|
|
44
|
+
</Text>
|
|
45
|
+
<Box>
|
|
46
|
+
<Text color={priorityColor}>[{task.priority}]</Text>
|
|
47
|
+
{task.feature && <Text color="blue"> #{task.feature}</Text>}
|
|
48
|
+
</Box>
|
|
49
|
+
</Box>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function KanbanColumn({ column, selectedTask }: { column: Column; selectedTask: string | null }) {
|
|
54
|
+
return (
|
|
55
|
+
<Box flexDirection="column" width="33%" paddingX={1}>
|
|
56
|
+
<Box marginBottom={1} justifyContent="center">
|
|
57
|
+
<Text bold color={column.color}>
|
|
58
|
+
{column.title} ({column.tasks.length})
|
|
59
|
+
</Text>
|
|
60
|
+
</Box>
|
|
61
|
+
<Box flexDirection="column" borderStyle="single" borderColor={column.color} minHeight={10}>
|
|
62
|
+
{column.tasks.length === 0 ? (
|
|
63
|
+
<Box padding={1}>
|
|
64
|
+
<Text color="gray" dimColor>
|
|
65
|
+
No tasks
|
|
66
|
+
</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
) : (
|
|
69
|
+
column.tasks.slice(0, 5).map((task) => (
|
|
70
|
+
<TaskCard key={task.id} task={task} selected={selectedTask === task.id} />
|
|
71
|
+
))
|
|
72
|
+
)}
|
|
73
|
+
{column.tasks.length > 5 && (
|
|
74
|
+
<Box paddingX={1}>
|
|
75
|
+
<Text color="gray">+{column.tasks.length - 5} more</Text>
|
|
76
|
+
</Box>
|
|
77
|
+
)}
|
|
78
|
+
</Box>
|
|
79
|
+
</Box>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function KanbanBoard({ projectRoot, refreshInterval = 5000 }: KanbanProps) {
|
|
84
|
+
const { exit } = useApp()
|
|
85
|
+
const [columns, setColumns] = useState<Column[]>([
|
|
86
|
+
{ status: 'pending', title: 'PENDING', color: 'yellow', tasks: [] },
|
|
87
|
+
{ status: 'in-progress', title: 'IN PROGRESS', color: 'blue', tasks: [] },
|
|
88
|
+
{ status: 'completed', title: 'COMPLETED', color: 'green', tasks: [] },
|
|
89
|
+
])
|
|
90
|
+
const [selectedColumn, setSelectedColumn] = useState(0)
|
|
91
|
+
const [selectedRow, setSelectedRow] = useState(0)
|
|
92
|
+
const [error, setError] = useState<string | null>(null)
|
|
93
|
+
const [lastRefresh, setLastRefresh] = useState(new Date())
|
|
94
|
+
|
|
95
|
+
const loadTasks = async () => {
|
|
96
|
+
try {
|
|
97
|
+
const backendConfig = detectBackend(projectRoot)
|
|
98
|
+
const backend = createBackend(backendConfig)
|
|
99
|
+
|
|
100
|
+
// Get all tasks
|
|
101
|
+
const pending = await backend.listPendingTasks()
|
|
102
|
+
|
|
103
|
+
// For completed/in-progress, we need to check all tasks
|
|
104
|
+
// This is a simplified approach - in reality you might want a listAllTasks method
|
|
105
|
+
const allTasks = pending // For now, just show pending tasks in the board
|
|
106
|
+
|
|
107
|
+
setColumns([
|
|
108
|
+
{
|
|
109
|
+
status: 'pending',
|
|
110
|
+
title: 'PENDING',
|
|
111
|
+
color: 'yellow',
|
|
112
|
+
tasks: allTasks.filter((t) => t.status === 'pending'),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
status: 'in-progress',
|
|
116
|
+
title: 'IN PROGRESS',
|
|
117
|
+
color: 'blue',
|
|
118
|
+
tasks: allTasks.filter((t) => t.status === 'in-progress'),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
status: 'completed',
|
|
122
|
+
title: 'COMPLETED',
|
|
123
|
+
color: 'green',
|
|
124
|
+
tasks: allTasks.filter((t) => t.status === 'completed'),
|
|
125
|
+
},
|
|
126
|
+
])
|
|
127
|
+
setLastRefresh(new Date())
|
|
128
|
+
setError(null)
|
|
129
|
+
} catch (e: any) {
|
|
130
|
+
setError(e.message)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
loadTasks()
|
|
136
|
+
const interval = setInterval(loadTasks, refreshInterval)
|
|
137
|
+
return () => clearInterval(interval)
|
|
138
|
+
}, [projectRoot, refreshInterval])
|
|
139
|
+
|
|
140
|
+
useInput((input, key) => {
|
|
141
|
+
if (input === 'q' || key.escape) {
|
|
142
|
+
exit()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (input === 'r') {
|
|
147
|
+
loadTasks()
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (key.leftArrow) {
|
|
152
|
+
setSelectedColumn((c) => Math.max(0, c - 1))
|
|
153
|
+
setSelectedRow(0)
|
|
154
|
+
}
|
|
155
|
+
if (key.rightArrow) {
|
|
156
|
+
setSelectedColumn((c) => Math.min(columns.length - 1, c + 1))
|
|
157
|
+
setSelectedRow(0)
|
|
158
|
+
}
|
|
159
|
+
if (key.upArrow) {
|
|
160
|
+
setSelectedRow((r) => Math.max(0, r - 1))
|
|
161
|
+
}
|
|
162
|
+
if (key.downArrow) {
|
|
163
|
+
const maxRow = Math.min(4, columns[selectedColumn].tasks.length - 1)
|
|
164
|
+
setSelectedRow((r) => Math.min(maxRow, r + 1))
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const selectedTask = columns[selectedColumn]?.tasks[selectedRow]?.id || null
|
|
169
|
+
const totalTasks = columns.reduce((sum, c) => sum + c.tasks.length, 0)
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<Box flexDirection="column" padding={1}>
|
|
173
|
+
{/* Header */}
|
|
174
|
+
<Box marginBottom={1} justifyContent="space-between">
|
|
175
|
+
<Text bold color="cyan">
|
|
176
|
+
LOOPWORK KANBAN
|
|
177
|
+
</Text>
|
|
178
|
+
<Text color="gray">
|
|
179
|
+
{totalTasks} tasks | Last refresh: {lastRefresh.toLocaleTimeString()}
|
|
180
|
+
</Text>
|
|
181
|
+
</Box>
|
|
182
|
+
|
|
183
|
+
{/* Error */}
|
|
184
|
+
{error && (
|
|
185
|
+
<Box marginBottom={1}>
|
|
186
|
+
<Text color="red">Error: {error}</Text>
|
|
187
|
+
</Box>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{/* Columns */}
|
|
191
|
+
<Box flexDirection="row">
|
|
192
|
+
{columns.map((column, idx) => (
|
|
193
|
+
<KanbanColumn
|
|
194
|
+
key={column.status}
|
|
195
|
+
column={column}
|
|
196
|
+
selectedTask={idx === selectedColumn ? selectedTask : null}
|
|
197
|
+
/>
|
|
198
|
+
))}
|
|
199
|
+
</Box>
|
|
200
|
+
|
|
201
|
+
{/* Footer */}
|
|
202
|
+
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
|
203
|
+
<Text color="gray">
|
|
204
|
+
[←/→] Switch column | [↑/↓] Select task | [r] Refresh | [q] Quit
|
|
205
|
+
</Text>
|
|
206
|
+
</Box>
|
|
207
|
+
|
|
208
|
+
{/* Selected task details */}
|
|
209
|
+
{selectedTask && (
|
|
210
|
+
<Box marginTop={1} flexDirection="column" borderStyle="single" paddingX={1}>
|
|
211
|
+
<Text bold>Selected: {selectedTask}</Text>
|
|
212
|
+
<Text>{columns[selectedColumn].tasks[selectedRow]?.title}</Text>
|
|
213
|
+
</Box>
|
|
214
|
+
)}
|
|
215
|
+
</Box>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function runKanban(projectRoot: string = process.cwd()) {
|
|
220
|
+
render(<KanbanBoard projectRoot={projectRoot} />)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// CLI entry point
|
|
224
|
+
if (import.meta.main) {
|
|
225
|
+
runKanban(process.cwd())
|
|
226
|
+
}
|