@jxtools/promptline 1.0.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.
@@ -0,0 +1,53 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import type { ProjectView } from '../types/queue';
3
+ import { api } from '../api/client';
4
+ import { useSSE } from './useSSE';
5
+
6
+ const FALLBACK_POLL_MS = 2000;
7
+
8
+ export function useProjects() {
9
+ const [projects, setProjects] = useState<ProjectView[]>([]);
10
+ const [loading, setLoading] = useState(true);
11
+ const [error, setError] = useState<string | null>(null);
12
+ const fallbackRef = useRef<ReturnType<typeof setInterval> | null>(null);
13
+
14
+ const handleProjects = useCallback((data: ProjectView[]) => {
15
+ setProjects(data);
16
+ setError(null);
17
+ setLoading(false);
18
+ }, []);
19
+
20
+ const { connected } = useSSE({ onProjects: handleProjects });
21
+
22
+ const refresh = useCallback(async () => {
23
+ try {
24
+ const data = await api.listProjects();
25
+ setProjects(data);
26
+ setError(null);
27
+ } catch (err: unknown) {
28
+ setError(err instanceof Error ? err.message : 'Unknown error');
29
+ } finally {
30
+ setLoading(false);
31
+ }
32
+ }, []);
33
+
34
+ // Fallback polling when SSE is disconnected (also handles initial load if SSE is slow)
35
+ useEffect(() => {
36
+ if (connected) {
37
+ if (fallbackRef.current) {
38
+ clearInterval(fallbackRef.current);
39
+ fallbackRef.current = null;
40
+ }
41
+ return;
42
+ }
43
+ fallbackRef.current = setInterval(refresh, FALLBACK_POLL_MS);
44
+ return () => {
45
+ if (fallbackRef.current) {
46
+ clearInterval(fallbackRef.current);
47
+ fallbackRef.current = null;
48
+ }
49
+ };
50
+ }, [connected, refresh]);
51
+
52
+ return { projects, loading, error, refresh };
53
+ }
@@ -0,0 +1,37 @@
1
+ import { useEffect, useRef, useState, useCallback } from 'react';
2
+ import type { ProjectView } from '../types/queue';
3
+
4
+ interface UseSSEOptions {
5
+ onProjects: (projects: ProjectView[]) => void;
6
+ }
7
+
8
+ export function useSSE({ onProjects }: UseSSEOptions) {
9
+ const [connected, setConnected] = useState(false);
10
+ const callbackRef = useRef(onProjects);
11
+ callbackRef.current = onProjects;
12
+
13
+ const connect = useCallback(() => {
14
+ const es = new EventSource('/api/events');
15
+
16
+ es.addEventListener('projects', (event: MessageEvent) => {
17
+ try {
18
+ const data = JSON.parse(event.data) as ProjectView[];
19
+ callbackRef.current(data);
20
+ } catch {
21
+ // Ignore malformed events
22
+ }
23
+ });
24
+
25
+ es.onopen = () => setConnected(true);
26
+ es.onerror = () => setConnected(false);
27
+
28
+ return es;
29
+ }, []);
30
+
31
+ useEffect(() => {
32
+ const es = connect();
33
+ return () => es.close();
34
+ }, [connect]);
35
+
36
+ return { connected };
37
+ }
package/src/index.css ADDED
@@ -0,0 +1,34 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --color-bg: #0a0a0f;
5
+ --color-surface: #111118;
6
+ --color-border: #1e1e2e;
7
+ --color-text: #e2e8f0;
8
+ --color-muted: #64748b;
9
+ --color-active: #4ade80;
10
+ --color-running: #a78bfa;
11
+ --color-pending: #60a5fa;
12
+ --color-idle: #f472b6;
13
+ --color-completed: #34d399;
14
+ }
15
+
16
+ html {
17
+ font-family: 'JetBrains Mono', monospace;
18
+ }
19
+
20
+ body {
21
+ margin: 0;
22
+ background: var(--color-bg);
23
+ color: var(--color-text);
24
+ }
25
+
26
+ /* Pulsing animation for active status dots */
27
+ @keyframes pulse-dot {
28
+ 0%, 100% { opacity: 1; }
29
+ 50% { opacity: 0.5; }
30
+ }
31
+
32
+ .animate-pulse-dot {
33
+ animation: pulse-dot 2s ease-in-out infinite;
34
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,10 @@
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
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
@@ -0,0 +1,33 @@
1
+ export type PromptStatus = 'pending' | 'running' | 'completed';
2
+ export type SessionStatus = 'active' | 'idle';
3
+ export type QueueStatus = 'active' | 'completed' | 'empty';
4
+
5
+ export interface Prompt {
6
+ id: string;
7
+ text: string;
8
+ status: PromptStatus;
9
+ createdAt: string;
10
+ completedAt: string | null;
11
+ }
12
+
13
+ export interface SessionQueue {
14
+ sessionId: string;
15
+ project: string;
16
+ directory: string;
17
+ sessionName: string | null;
18
+ prompts: Prompt[];
19
+ startedAt: string;
20
+ lastActivity: string;
21
+ currentPromptId: string | null;
22
+ completedAt: string | null;
23
+ closedAt: string | null;
24
+ }
25
+
26
+ export type SessionWithStatus = SessionQueue & { status: SessionStatus };
27
+
28
+ export interface ProjectView {
29
+ project: string;
30
+ directory: string;
31
+ sessions: SessionWithStatus[];
32
+ queueStatus: QueueStatus;
33
+ }
@@ -0,0 +1,29 @@
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
+ "exclude": ["src/backend"]
29
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,26 @@
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", "vite-plugin-api.ts", "src/backend/**/*.ts", "src/types/**/*.ts"]
26
+ }
@@ -0,0 +1,307 @@
1
+ import type { Plugin, Connect } from 'vite';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { mkdirSync, watch } from 'node:fs';
4
+ import type { FSWatcher } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { v4 as uuidv4 } from 'uuid';
8
+ import type { PromptStatus } from './src/types/queue.ts';
9
+ import {
10
+ listProjects,
11
+ getProject,
12
+ deleteProject,
13
+ readSession,
14
+ writeSession,
15
+ withComputedStatus,
16
+ deleteSession,
17
+ addPrompt,
18
+ updatePrompt,
19
+ deletePrompt,
20
+ reorderPrompts,
21
+ } from './src/backend/queue-store.ts';
22
+
23
+ const QUEUES_DIR = join(homedir(), '.promptline', 'queues');
24
+
25
+ function parseBody(req: IncomingMessage): Promise<Record<string, unknown>> {
26
+ return new Promise((resolve, reject) => {
27
+ let body = '';
28
+ req.on('data', (chunk: Buffer) => {
29
+ body += chunk.toString();
30
+ });
31
+ req.on('end', () => {
32
+ try {
33
+ resolve(body ? (JSON.parse(body) as Record<string, unknown>) : {});
34
+ } catch {
35
+ reject(new Error('Invalid JSON body'));
36
+ }
37
+ });
38
+ req.on('error', reject);
39
+ });
40
+ }
41
+
42
+ function json(res: ServerResponse, status: number, data: unknown): void {
43
+ res.writeHead(status, { 'Content-Type': 'application/json' });
44
+ res.end(JSON.stringify(data));
45
+ }
46
+
47
+ function jsonError(res: ServerResponse, status: number, message: string): void {
48
+ json(res, status, { error: message });
49
+ }
50
+
51
+ // --- SSE connection manager ---
52
+ const sseClients = new Set<ServerResponse>();
53
+ let fsWatcher: FSWatcher | null = null;
54
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
55
+ const DEBOUNCE_MS = 200;
56
+
57
+ function broadcastProjects(): void {
58
+ const data = JSON.stringify(listProjects(QUEUES_DIR));
59
+ const message = `event: projects\ndata: ${data}\n\n`;
60
+ for (const client of sseClients) {
61
+ client.write(message);
62
+ }
63
+ }
64
+
65
+ function startWatcher(): void {
66
+ if (fsWatcher) return;
67
+ mkdirSync(QUEUES_DIR, { recursive: true });
68
+ try {
69
+ fsWatcher = watch(QUEUES_DIR, { recursive: true }, (_event, filename) => {
70
+ if (!filename || !filename.endsWith('.json')) return;
71
+ if (debounceTimer) clearTimeout(debounceTimer);
72
+ debounceTimer = setTimeout(broadcastProjects, DEBOUNCE_MS);
73
+ });
74
+ } catch {
75
+ // fs.watch not supported — SSE will work but without auto-push
76
+ }
77
+ }
78
+
79
+ function stopWatcher(): void {
80
+ if (fsWatcher) {
81
+ fsWatcher.close();
82
+ fsWatcher = null;
83
+ }
84
+ if (debounceTimer) {
85
+ clearTimeout(debounceTimer);
86
+ debounceTimer = null;
87
+ }
88
+ }
89
+
90
+ const HEARTBEAT_MS = 25_000;
91
+
92
+ function handleSSE(_req: IncomingMessage, res: ServerResponse): void {
93
+ res.writeHead(200, {
94
+ 'Content-Type': 'text/event-stream',
95
+ 'Cache-Control': 'no-cache',
96
+ 'Connection': 'keep-alive',
97
+ });
98
+
99
+ // Send initial state
100
+ const data = JSON.stringify(listProjects(QUEUES_DIR));
101
+ res.write(`event: projects\ndata: ${data}\n\n`);
102
+
103
+ const heartbeat = setInterval(() => {
104
+ res.write(': heartbeat\n\n');
105
+ }, HEARTBEAT_MS);
106
+
107
+ sseClients.add(res);
108
+
109
+ res.on('close', () => {
110
+ clearInterval(heartbeat);
111
+ sseClients.delete(res);
112
+ });
113
+ }
114
+
115
+ export default function apiPlugin(): Plugin {
116
+ return {
117
+ name: 'promptline-api',
118
+ configureServer(server) {
119
+ startWatcher();
120
+
121
+ server.httpServer?.on('close', () => {
122
+ stopWatcher();
123
+ for (const client of sseClients) {
124
+ client.end();
125
+ }
126
+ sseClients.clear();
127
+ });
128
+
129
+ server.middlewares.use(((req: IncomingMessage, res: ServerResponse, next: Connect.NextFunction) => {
130
+ const url = req.url ?? '';
131
+ const method = req.method ?? 'GET';
132
+
133
+ if (!url.startsWith('/api/')) {
134
+ next();
135
+ return;
136
+ }
137
+
138
+ // SSE endpoint
139
+ if (url === '/api/events' && method === 'GET') {
140
+ handleSSE(req, res);
141
+ return;
142
+ }
143
+
144
+ handleApi(url, method, req, res).catch((err: unknown) => {
145
+ const message = err instanceof Error ? err.message : 'Internal server error';
146
+ jsonError(res, 500, message);
147
+ });
148
+ }) as Connect.NextHandleFunction);
149
+ },
150
+ };
151
+ }
152
+
153
+ async function handleApi(
154
+ url: string,
155
+ method: string,
156
+ req: IncomingMessage,
157
+ res: ServerResponse,
158
+ ): Promise<void> {
159
+ // Parse URL segments: /api/projects/:project/sessions/:sessionId/prompts/:promptId
160
+ const segments = url.replace(/^\/api\//, '').split('/').map(decodeURIComponent);
161
+
162
+ // GET /api/projects
163
+ if (segments[0] === 'projects' && segments.length === 1 && method === 'GET') {
164
+ return json(res, 200, listProjects(QUEUES_DIR));
165
+ }
166
+
167
+ // /api/projects/:project
168
+ if (segments[0] === 'projects' && segments.length === 2) {
169
+ const project = segments[1];
170
+
171
+ if (method === 'GET') {
172
+ const pv = getProject(QUEUES_DIR, project);
173
+ if (!pv) return jsonError(res, 404, `Project "${project}" not found`);
174
+ return json(res, 200, pv);
175
+ }
176
+
177
+ if (method === 'DELETE') {
178
+ try {
179
+ deleteProject(QUEUES_DIR, project);
180
+ } catch (err: unknown) {
181
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
182
+ return jsonError(res, 404, `Project "${project}" not found`);
183
+ }
184
+ throw err;
185
+ }
186
+ return json(res, 200, { deleted: project });
187
+ }
188
+
189
+ return jsonError(res, 405, `Method ${method} not allowed`);
190
+ }
191
+
192
+ // /api/projects/:project/sessions/:sessionId
193
+ if (segments[0] === 'projects' && segments[2] === 'sessions' && segments.length === 4) {
194
+ const project = segments[1];
195
+ const sessionId = segments[3];
196
+
197
+ if (method === 'GET') {
198
+ const session = readSession(QUEUES_DIR, project, sessionId);
199
+ if (!session) return jsonError(res, 404, 'Session not found');
200
+ return json(res, 200, withComputedStatus(session));
201
+ }
202
+
203
+ if (method === 'DELETE') {
204
+ try {
205
+ deleteSession(QUEUES_DIR, project, sessionId);
206
+ } catch (err: unknown) {
207
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
208
+ return jsonError(res, 404, 'Session not found');
209
+ }
210
+ throw err;
211
+ }
212
+ return json(res, 200, { deleted: sessionId });
213
+ }
214
+
215
+ return jsonError(res, 405, `Method ${method} not allowed`);
216
+ }
217
+
218
+ // /api/projects/:project/sessions/:sessionId/prompts/reorder
219
+ if (
220
+ segments[0] === 'projects' && segments[2] === 'sessions' &&
221
+ segments[4] === 'prompts' && segments[5] === 'reorder' &&
222
+ segments.length === 6 && method === 'PUT'
223
+ ) {
224
+ const project = segments[1];
225
+ const sessionId = segments[3];
226
+ const session = readSession(QUEUES_DIR, project, sessionId);
227
+ if (!session) return jsonError(res, 404, 'Session not found');
228
+
229
+ const body = await parseBody(req);
230
+ const order = body.order as string[] | undefined;
231
+ if (!Array.isArray(order)) {
232
+ return jsonError(res, 400, 'Field "order" (string[]) is required');
233
+ }
234
+
235
+ const error = reorderPrompts(session, order);
236
+ if (error) return jsonError(res, 400, error);
237
+
238
+ writeSession(QUEUES_DIR, project, session);
239
+ return json(res, 200, session);
240
+ }
241
+
242
+ // /api/projects/:project/sessions/:sessionId/prompts/:promptId
243
+ if (
244
+ segments[0] === 'projects' && segments[2] === 'sessions' &&
245
+ segments[4] === 'prompts' && segments.length === 6
246
+ ) {
247
+ const project = segments[1];
248
+ const sessionId = segments[3];
249
+ const promptId = segments[5];
250
+ const session = readSession(QUEUES_DIR, project, sessionId);
251
+ if (!session) return jsonError(res, 404, 'Session not found');
252
+
253
+ if (method === 'PATCH') {
254
+ const body = await parseBody(req);
255
+ const updates: { text?: string; status?: PromptStatus } = {};
256
+
257
+ if (body.text !== undefined) {
258
+ updates.text = body.text as string;
259
+ }
260
+ if (body.status !== undefined) {
261
+ const validStatuses: PromptStatus[] = ['pending', 'running', 'completed'];
262
+ if (!validStatuses.includes(body.status as PromptStatus)) {
263
+ return jsonError(res, 400, `Invalid status. Must be one of: ${validStatuses.join(', ')}`);
264
+ }
265
+ updates.status = body.status as PromptStatus;
266
+ }
267
+
268
+ const updated = updatePrompt(session, promptId, updates);
269
+ if (!updated) return jsonError(res, 404, `Prompt "${promptId}" not found`);
270
+
271
+ writeSession(QUEUES_DIR, project, session);
272
+ return json(res, 200, updated);
273
+ }
274
+
275
+ if (method === 'DELETE') {
276
+ const removed = deletePrompt(session, promptId);
277
+ if (!removed) return jsonError(res, 404, `Prompt "${promptId}" not found`);
278
+
279
+ writeSession(QUEUES_DIR, project, session);
280
+ return json(res, 200, removed);
281
+ }
282
+
283
+ return jsonError(res, 405, `Method ${method} not allowed`);
284
+ }
285
+
286
+ // /api/projects/:project/sessions/:sessionId/prompts
287
+ if (
288
+ segments[0] === 'projects' && segments[2] === 'sessions' &&
289
+ segments[4] === 'prompts' && segments.length === 5 && method === 'POST'
290
+ ) {
291
+ const project = segments[1];
292
+ const sessionId = segments[3];
293
+ const session = readSession(QUEUES_DIR, project, sessionId);
294
+ if (!session) return jsonError(res, 404, 'Session not found');
295
+
296
+ const body = await parseBody(req);
297
+ if (!body.text || typeof body.text !== 'string') {
298
+ return jsonError(res, 400, 'Field "text" (string) is required');
299
+ }
300
+
301
+ const prompt = addPrompt(session, uuidv4(), body.text as string);
302
+ writeSession(QUEUES_DIR, project, session);
303
+ return json(res, 201, prompt);
304
+ }
305
+
306
+ return jsonError(res, 404, 'Not found');
307
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+ import apiPlugin from './vite-plugin-api.ts'
5
+
6
+ const randomPort = 3000 + Math.floor(Math.random() * 7000);
7
+
8
+ export default defineConfig({
9
+ plugins: [react(), tailwindcss(), apiPlugin()],
10
+ server: {
11
+ port: randomPort,
12
+ open: true,
13
+ },
14
+ })