@vectorasystems/cli 0.1.2 → 0.1.3
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/package.json +1 -1
- package/src/lib/constants.js +1 -1
- package/src/tui/App.js +95 -35
- package/src/tui/components/ActionMenu.js +30 -0
- package/src/tui/components/ArtifactBrowser.js +96 -0
- package/src/tui/components/ChatWindow.js +84 -0
- package/src/tui/components/CreateProjectForm.js +81 -0
- package/src/tui/components/PhaseRunner.js +103 -0
- package/src/tui/components/StatusBar.js +19 -8
- package/src/tui/hooks/useChat.js +52 -0
- package/src/tui/hooks/usePhaseRun.js +79 -0
package/package.json
CHANGED
package/src/lib/constants.js
CHANGED
package/src/tui/App.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
// TUI root — Ink application
|
|
1
|
+
// TUI root — full interactive Ink application
|
|
2
2
|
import React, { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useApp, useInput } from 'ink';
|
|
4
4
|
import { Header } from './components/Header.js';
|
|
5
5
|
import { ProjectList } from './components/ProjectList.js';
|
|
6
|
-
import {
|
|
6
|
+
import { ActionMenu } from './components/ActionMenu.js';
|
|
7
|
+
import { ChatWindow } from './components/ChatWindow.js';
|
|
8
|
+
import { PhaseRunner } from './components/PhaseRunner.js';
|
|
9
|
+
import { ArtifactBrowser } from './components/ArtifactBrowser.js';
|
|
10
|
+
import { CreateProjectForm } from './components/CreateProjectForm.js';
|
|
7
11
|
import { StatusBar } from './components/StatusBar.js';
|
|
8
12
|
import { useProject } from './hooks/useProject.js';
|
|
9
13
|
import { checkHealth, getMe } from '../lib/api-client.js';
|
|
@@ -13,10 +17,12 @@ export default function App() {
|
|
|
13
17
|
const { exit } = useApp();
|
|
14
18
|
const [connected, setConnected] = useState(true);
|
|
15
19
|
const [orgName, setOrgName] = useState(null);
|
|
16
|
-
const [view, setView] = useState('projects'); //
|
|
17
|
-
const { project, phases,
|
|
20
|
+
const [view, setView] = useState('projects'); // projects|create|detail|chat|run|artifacts
|
|
21
|
+
const { project, phases, loading, refresh, projectId } = useProject();
|
|
22
|
+
|
|
23
|
+
// Augment project with phases array for PhaseRunner filtering
|
|
24
|
+
const projectWithPhases = project ? { ...project, phases } : null;
|
|
18
25
|
|
|
19
|
-
// Check connectivity and auth on mount
|
|
20
26
|
useEffect(() => {
|
|
21
27
|
checkHealth().then((h) => setConnected(h.status !== 'unreachable'));
|
|
22
28
|
getCredentials().then(async (creds) => {
|
|
@@ -29,45 +35,99 @@ export default function App() {
|
|
|
29
35
|
});
|
|
30
36
|
}, []);
|
|
31
37
|
|
|
32
|
-
//
|
|
38
|
+
// Global keyboard shortcuts
|
|
33
39
|
useInput((input, key) => {
|
|
34
40
|
if (input === 'q' || (key.ctrl && input === 'c')) {
|
|
35
41
|
exit();
|
|
42
|
+
return;
|
|
36
43
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
setView('projects');
|
|
44
|
+
|
|
45
|
+
if (view === 'projects') {
|
|
46
|
+
if (input === 'n') setView('create');
|
|
47
|
+
if (input === 'r') refresh();
|
|
42
48
|
}
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
|
|
50
|
+
if (view === 'detail') {
|
|
51
|
+
if (input === 'c') setView('chat');
|
|
52
|
+
if (input === 'r') setView('run');
|
|
53
|
+
if (input === 'a') setView('artifacts');
|
|
54
|
+
if (key.escape) setView('projects');
|
|
45
55
|
}
|
|
46
56
|
});
|
|
47
57
|
|
|
58
|
+
function handleProjectSelect() {
|
|
59
|
+
refresh();
|
|
60
|
+
setView('detail');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function handleActionSelect(action) {
|
|
64
|
+
if (action === 'chat') setView('chat');
|
|
65
|
+
else if (action === 'run') setView('run');
|
|
66
|
+
else if (action === 'artifacts') setView('artifacts');
|
|
67
|
+
else if (action === 'back') setView('projects');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleProjectCreated() {
|
|
71
|
+
refresh();
|
|
72
|
+
setView('detail');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderView() {
|
|
76
|
+
if (loading && view !== 'create') {
|
|
77
|
+
return React.createElement(Text, { dimColor: true }, 'Loading…');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
switch (view) {
|
|
81
|
+
case 'projects':
|
|
82
|
+
return React.createElement(ProjectList, { onSelect: handleProjectSelect });
|
|
83
|
+
|
|
84
|
+
case 'create':
|
|
85
|
+
return React.createElement(CreateProjectForm, {
|
|
86
|
+
onCreated: handleProjectCreated,
|
|
87
|
+
onCancel: () => setView('projects'),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
case 'detail':
|
|
91
|
+
return React.createElement(ActionMenu, {
|
|
92
|
+
project: projectWithPhases,
|
|
93
|
+
onSelect: handleActionSelect,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
case 'chat':
|
|
97
|
+
return projectId
|
|
98
|
+
? React.createElement(ChatWindow, {
|
|
99
|
+
projectId,
|
|
100
|
+
onBack: () => setView('detail'),
|
|
101
|
+
})
|
|
102
|
+
: React.createElement(Text, { color: 'red' }, 'No project selected');
|
|
103
|
+
|
|
104
|
+
case 'run':
|
|
105
|
+
return projectId
|
|
106
|
+
? React.createElement(PhaseRunner, {
|
|
107
|
+
project: projectWithPhases,
|
|
108
|
+
onBack: () => setView('detail'),
|
|
109
|
+
onArtifacts: () => setView('artifacts'),
|
|
110
|
+
})
|
|
111
|
+
: React.createElement(Text, { color: 'red' }, 'No project selected');
|
|
112
|
+
|
|
113
|
+
case 'artifacts':
|
|
114
|
+
return projectId
|
|
115
|
+
? React.createElement(ArtifactBrowser, {
|
|
116
|
+
projectId,
|
|
117
|
+
onBack: () => setView('detail'),
|
|
118
|
+
})
|
|
119
|
+
: React.createElement(Text, { color: 'red' }, 'No project selected');
|
|
120
|
+
|
|
121
|
+
default:
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
48
126
|
return React.createElement(Box, { flexDirection: 'column', padding: 1 },
|
|
49
127
|
React.createElement(Header, { connected }),
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
? React.createElement(ProjectList, {
|
|
55
|
-
onSelect: (id) => {
|
|
56
|
-
setView('detail');
|
|
57
|
-
refresh();
|
|
58
|
-
},
|
|
59
|
-
})
|
|
60
|
-
: React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
61
|
-
project && React.createElement(Box, { flexDirection: 'column' },
|
|
62
|
-
React.createElement(Text, { bold: true, color: 'cyan' }, project.name),
|
|
63
|
-
React.createElement(Text, { dimColor: true }, `${project.orchestratorId} · ${project.status}`),
|
|
64
|
-
),
|
|
65
|
-
React.createElement(PhaseTimeline, { phases }),
|
|
66
|
-
artifacts.length > 0 && React.createElement(Text, { dimColor: true },
|
|
67
|
-
`${artifacts.length} artifact(s)`
|
|
68
|
-
),
|
|
69
|
-
),
|
|
70
|
-
|
|
71
|
-
React.createElement(StatusBar, { project, orgName, connected }),
|
|
128
|
+
React.createElement(Box, { marginTop: 1 },
|
|
129
|
+
renderView(),
|
|
130
|
+
),
|
|
131
|
+
React.createElement(StatusBar, { project, orgName, connected, view }),
|
|
72
132
|
);
|
|
73
133
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// TUI component — project action menu (detail view)
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import SelectInput from 'ink-select-input';
|
|
5
|
+
|
|
6
|
+
const ITEMS = [
|
|
7
|
+
{ label: '💬 Idea Chat', value: 'chat', hint: 'Refine your idea with AI' },
|
|
8
|
+
{ label: '▶ Run Phase', value: 'run', hint: 'Execute a phase' },
|
|
9
|
+
{ label: '📦 Artifacts', value: 'artifacts', hint: 'Browse & download outputs' },
|
|
10
|
+
{ label: '← Back', value: 'back', hint: 'Return to project list' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function ActionMenu({ project, onSelect }) {
|
|
14
|
+
if (!project) return null;
|
|
15
|
+
|
|
16
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
17
|
+
React.createElement(Box, { flexDirection: 'column' },
|
|
18
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, project.name),
|
|
19
|
+
React.createElement(Text, { dimColor: true },
|
|
20
|
+
`${project.orchestratorId?.toUpperCase() ?? 'FORGE'} · ${project.status}`
|
|
21
|
+
),
|
|
22
|
+
),
|
|
23
|
+
React.createElement(Box, { borderStyle: 'single', borderColor: 'gray', paddingX: 1, flexDirection: 'column' },
|
|
24
|
+
React.createElement(SelectInput, {
|
|
25
|
+
items: ITEMS,
|
|
26
|
+
onSelect: (item) => onSelect(item.value),
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// TUI component — artifact browser with download
|
|
2
|
+
import React, { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import SelectInput from 'ink-select-input';
|
|
5
|
+
import Spinner from 'ink-spinner';
|
|
6
|
+
import { writeFile } from 'node:fs/promises';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { getProjectArtifacts, getArtifactDownloadUrl } from '../../lib/api-client.js';
|
|
9
|
+
|
|
10
|
+
const TYPE_FILENAME = {
|
|
11
|
+
handoff: 'VECTORA.md',
|
|
12
|
+
prd: 'PRD.md',
|
|
13
|
+
scope: 'SCOPE.md',
|
|
14
|
+
roadmap: 'ROADMAP.md',
|
|
15
|
+
analysis: 'ANALYSIS.json',
|
|
16
|
+
validation: 'VALIDATION.json',
|
|
17
|
+
feedback: 'FEEDBACK.json',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function formatDate(iso) {
|
|
21
|
+
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ArtifactBrowser({ projectId, onBack }) {
|
|
25
|
+
const [artifacts, setArtifacts] = useState([]);
|
|
26
|
+
const [loading, setLoading] = useState(true);
|
|
27
|
+
const [downloading, setDownloading] = useState(false);
|
|
28
|
+
const [savedPath, setSavedPath] = useState(null);
|
|
29
|
+
const [error, setError] = useState(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
getProjectArtifacts(projectId)
|
|
33
|
+
.then((list) => { setArtifacts(list); setLoading(false); })
|
|
34
|
+
.catch((err) => { setError(err.message); setLoading(false); });
|
|
35
|
+
}, [projectId]);
|
|
36
|
+
|
|
37
|
+
useInput((_ch, key) => {
|
|
38
|
+
if (key.escape && !downloading) onBack();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
async function handleSelect(item) {
|
|
42
|
+
setDownloading(true);
|
|
43
|
+
setSavedPath(null);
|
|
44
|
+
setError(null);
|
|
45
|
+
try {
|
|
46
|
+
const { url, filename } = await getArtifactDownloadUrl(item.value);
|
|
47
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
|
|
48
|
+
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`);
|
|
49
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
50
|
+
const outPath = resolve(filename);
|
|
51
|
+
await writeFile(outPath, buffer);
|
|
52
|
+
setSavedPath(outPath);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
setError(err.message ?? 'Download failed');
|
|
55
|
+
} finally {
|
|
56
|
+
setDownloading(false);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (loading) {
|
|
61
|
+
return React.createElement(Box, { gap: 1 },
|
|
62
|
+
React.createElement(Spinner, { type: 'dots' }),
|
|
63
|
+
React.createElement(Text, null, ' Loading artifacts…'),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (downloading) {
|
|
68
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
69
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, '📦 Artifacts'),
|
|
70
|
+
React.createElement(Box, { gap: 1 },
|
|
71
|
+
React.createElement(Spinner, { type: 'dots' }),
|
|
72
|
+
React.createElement(Text, null, ' Downloading…'),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const items = artifacts.map((a) => ({
|
|
78
|
+
label: `${a.type.padEnd(16)} ${formatDate(a.createdAt)}`,
|
|
79
|
+
value: a.id,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
83
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, '📦 Artifacts'),
|
|
84
|
+
|
|
85
|
+
savedPath && React.createElement(Text, { color: 'green' }, `✓ Saved → ${savedPath}`),
|
|
86
|
+
error && React.createElement(Text, { color: 'red' }, `✗ ${error}`),
|
|
87
|
+
|
|
88
|
+
artifacts.length === 0
|
|
89
|
+
? React.createElement(Text, { dimColor: true }, 'No artifacts yet. Run a phase first.')
|
|
90
|
+
: React.createElement(Box, { borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
|
|
91
|
+
React.createElement(SelectInput, { items, onSelect: handleSelect }),
|
|
92
|
+
),
|
|
93
|
+
|
|
94
|
+
React.createElement(Text, { dimColor: true }, 'Enter to download · Esc to go back'),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// TUI component — idea chat view with streaming AI responses
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import Spinner from 'ink-spinner';
|
|
6
|
+
import { useChat } from '../hooks/useChat.js';
|
|
7
|
+
|
|
8
|
+
function ReadinessBar({ value }) {
|
|
9
|
+
if (value == null) return null;
|
|
10
|
+
const pct = Math.min(100, Math.max(0, value));
|
|
11
|
+
const filled = Math.round(pct / 10);
|
|
12
|
+
const bar = '▓'.repeat(filled) + '░'.repeat(10 - filled);
|
|
13
|
+
const color = pct >= 80 ? 'green' : pct >= 50 ? 'yellow' : 'gray';
|
|
14
|
+
return React.createElement(Box, { gap: 1 },
|
|
15
|
+
React.createElement(Text, { dimColor: true }, 'Readiness'),
|
|
16
|
+
React.createElement(Text, { color }, bar),
|
|
17
|
+
React.createElement(Text, { dimColor: true }, `${pct}%`),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ChatWindow({ projectId, onBack }) {
|
|
22
|
+
const { messages, streaming, readiness, error, sendMessage } = useChat(projectId);
|
|
23
|
+
const [input, setInput] = useState('');
|
|
24
|
+
|
|
25
|
+
useInput((ch, key) => {
|
|
26
|
+
if (key.escape && !streaming) onBack();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const visibleMessages = messages.slice(-8); // show last 8 messages
|
|
30
|
+
|
|
31
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
32
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, '💬 Idea Chat'),
|
|
33
|
+
|
|
34
|
+
// Message history
|
|
35
|
+
React.createElement(Box, {
|
|
36
|
+
flexDirection: 'column',
|
|
37
|
+
borderStyle: 'single',
|
|
38
|
+
borderColor: 'gray',
|
|
39
|
+
paddingX: 1,
|
|
40
|
+
minHeight: 10,
|
|
41
|
+
},
|
|
42
|
+
visibleMessages.length === 0
|
|
43
|
+
? React.createElement(Text, { dimColor: true }, 'Start typing to chat with your AI product consultant…')
|
|
44
|
+
: visibleMessages.map((msg, i) =>
|
|
45
|
+
React.createElement(Box, { key: i, flexDirection: 'column', marginBottom: 0 },
|
|
46
|
+
React.createElement(Text, { color: msg.role === 'user' ? 'cyan' : 'white', bold: msg.role === 'user' },
|
|
47
|
+
msg.role === 'user' ? '› ' : ' ',
|
|
48
|
+
msg.text || (streaming && i === visibleMessages.length - 1
|
|
49
|
+
? React.createElement(Spinner, { type: 'dots' })
|
|
50
|
+
: '…')
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
|
|
56
|
+
// Readiness bar
|
|
57
|
+
React.createElement(ReadinessBar, { value: readiness }),
|
|
58
|
+
|
|
59
|
+
// Error
|
|
60
|
+
error && React.createElement(Text, { color: 'red' }, `Error: ${error}`),
|
|
61
|
+
|
|
62
|
+
// Input
|
|
63
|
+
React.createElement(Box, { borderStyle: 'single', borderColor: streaming ? 'gray' : 'cyan', paddingX: 1 },
|
|
64
|
+
React.createElement(Box, null,
|
|
65
|
+
React.createElement(Text, { color: streaming ? 'gray' : 'cyan' }, '› '),
|
|
66
|
+
streaming
|
|
67
|
+
? React.createElement(Text, { dimColor: true }, 'AI is responding…')
|
|
68
|
+
: React.createElement(TextInput, {
|
|
69
|
+
value: input,
|
|
70
|
+
onChange: setInput,
|
|
71
|
+
placeholder: 'Describe your idea…',
|
|
72
|
+
onSubmit: (val) => {
|
|
73
|
+
if (val.trim()) {
|
|
74
|
+
sendMessage(val.trim());
|
|
75
|
+
setInput('');
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
|
|
82
|
+
React.createElement(Text, { dimColor: true }, streaming ? 'Wait for response…' : 'Enter to send · Esc to go back'),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// TUI component — create project form (name → orchestrator → submit)
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import SelectInput from 'ink-select-input';
|
|
6
|
+
import { createProject, getWorkspaces } from '../../lib/api-client.js';
|
|
7
|
+
import { getConfigValue, setConfigValue } from '../../lib/config-store.js';
|
|
8
|
+
|
|
9
|
+
const ORCHESTRATORS = [
|
|
10
|
+
{ label: 'Forge — Idea → MVP pipeline', value: 'forge' },
|
|
11
|
+
{ label: 'Temper — Post-MVP → Scale pipeline', value: 'temper' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function CreateProjectForm({ onCreated, onCancel }) {
|
|
15
|
+
const [stage, setStage] = useState('name'); // 'name' | 'orchestrator' | 'creating'
|
|
16
|
+
const [name, setName] = useState('');
|
|
17
|
+
const [error, setError] = useState(null);
|
|
18
|
+
|
|
19
|
+
async function handleOrchestratorSelect(item) {
|
|
20
|
+
setStage('creating');
|
|
21
|
+
setError(null);
|
|
22
|
+
try {
|
|
23
|
+
let workspaceId = getConfigValue('defaultWorkspace');
|
|
24
|
+
if (!workspaceId) {
|
|
25
|
+
const workspaces = await getWorkspaces();
|
|
26
|
+
if (!workspaces.length) {
|
|
27
|
+
setError('No workspaces found.');
|
|
28
|
+
setStage('orchestrator');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
workspaceId = workspaces[0].id;
|
|
32
|
+
setConfigValue('defaultWorkspace', workspaceId);
|
|
33
|
+
}
|
|
34
|
+
const project = await createProject({ name: name.trim(), workspaceId, orchestratorId: item.value });
|
|
35
|
+
setConfigValue('defaultProject', project.id);
|
|
36
|
+
onCreated(project);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
setError(err.message ?? 'Failed to create project');
|
|
39
|
+
setStage('orchestrator');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (stage === 'name') {
|
|
44
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
45
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, 'New Project'),
|
|
46
|
+
React.createElement(Box, { borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
|
|
47
|
+
React.createElement(Box, null,
|
|
48
|
+
React.createElement(Text, { dimColor: true }, 'Name: '),
|
|
49
|
+
React.createElement(TextInput, {
|
|
50
|
+
value: name,
|
|
51
|
+
onChange: setName,
|
|
52
|
+
placeholder: 'My App…',
|
|
53
|
+
onSubmit: (val) => {
|
|
54
|
+
if (val.trim()) setStage('orchestrator');
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
React.createElement(Text, { dimColor: true }, 'Enter to continue · Esc to cancel'),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (stage === 'orchestrator') {
|
|
64
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
65
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, `New Project: ${name}`),
|
|
66
|
+
error && React.createElement(Text, { color: 'red' }, error),
|
|
67
|
+
React.createElement(Box, { borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
|
|
68
|
+
React.createElement(SelectInput, {
|
|
69
|
+
items: ORCHESTRATORS,
|
|
70
|
+
onSelect: handleOrchestratorSelect,
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// creating
|
|
77
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
78
|
+
React.createElement(Text, { color: 'cyan' }, `Creating "${name}"…`),
|
|
79
|
+
error && React.createElement(Text, { color: 'red' }, error),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// TUI component — phase selection + live execution progress
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import SelectInput from 'ink-select-input';
|
|
5
|
+
import Spinner from 'ink-spinner';
|
|
6
|
+
import { usePhaseRun } from '../hooks/usePhaseRun.js';
|
|
7
|
+
import { FORGE_PHASES, TEMPER_PHASES } from '../../lib/constants.js';
|
|
8
|
+
|
|
9
|
+
function ProgressBar({ pct }) {
|
|
10
|
+
const filled = Math.round(pct / 5); // 20 chars wide
|
|
11
|
+
const bar = '█'.repeat(filled) + '░'.repeat(20 - filled);
|
|
12
|
+
return React.createElement(Box, { gap: 1 },
|
|
13
|
+
React.createElement(Text, { color: 'cyan' }, bar),
|
|
14
|
+
React.createElement(Text, { dimColor: true }, `${pct}%`),
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function PhaseRunner({ project, onBack, onArtifacts }) {
|
|
19
|
+
const [selected, setSelected] = useState(null);
|
|
20
|
+
const { status, step, pct, artifactCount, error, run, reset } = usePhaseRun(project?.id);
|
|
21
|
+
|
|
22
|
+
const orchestratorId = project?.orchestratorId ?? 'forge';
|
|
23
|
+
const phaseList = orchestratorId === 'temper' ? TEMPER_PHASES : FORGE_PHASES;
|
|
24
|
+
|
|
25
|
+
// Build completed phase set for filtering
|
|
26
|
+
const completedPhases = new Set(
|
|
27
|
+
(project?.phases ?? [])
|
|
28
|
+
.filter((p) => p.status === 'completed')
|
|
29
|
+
.map((p) => p.phaseType)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const items = phaseList.map((p) => ({
|
|
33
|
+
label: completedPhases.has(p) ? `✓ ${p}` : ` ${p}`,
|
|
34
|
+
value: p,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
useInput((_ch, key) => {
|
|
38
|
+
if (key.escape) {
|
|
39
|
+
if (status === 'idle' || status === 'completed' || status === 'failed') {
|
|
40
|
+
reset();
|
|
41
|
+
setSelected(null);
|
|
42
|
+
onBack();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (key.escape && status === 'completed' || status === 'failed') {
|
|
46
|
+
reset();
|
|
47
|
+
setSelected(null);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Selecting state
|
|
52
|
+
if (!selected || status === 'idle') {
|
|
53
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
54
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, '▶ Run Phase'),
|
|
55
|
+
React.createElement(Text, { dimColor: true }, `Orchestrator: ${orchestratorId.toUpperCase()}`),
|
|
56
|
+
React.createElement(Box, { borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
|
|
57
|
+
React.createElement(SelectInput, {
|
|
58
|
+
items,
|
|
59
|
+
onSelect: (item) => {
|
|
60
|
+
setSelected(item.value);
|
|
61
|
+
run(item.value);
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
React.createElement(Text, { dimColor: true }, 'Enter to run · Esc to go back'),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Running state
|
|
70
|
+
if (status === 'running') {
|
|
71
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
72
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, `▶ ${selected}`),
|
|
73
|
+
React.createElement(Box, { gap: 1 },
|
|
74
|
+
React.createElement(Spinner, { type: 'dots' }),
|
|
75
|
+
React.createElement(Text, null, ` ${step}`),
|
|
76
|
+
),
|
|
77
|
+
React.createElement(ProgressBar, { pct }),
|
|
78
|
+
React.createElement(Text, { dimColor: true }, 'Running… please wait'),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Completed state
|
|
83
|
+
if (status === 'completed') {
|
|
84
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
85
|
+
React.createElement(Text, { bold: true, color: 'green' }, `✓ ${selected} complete`),
|
|
86
|
+
React.createElement(ProgressBar, { pct: 100 }),
|
|
87
|
+
artifactCount > 0 && React.createElement(Text, null,
|
|
88
|
+
`${artifactCount} artifact(s) generated`,
|
|
89
|
+
),
|
|
90
|
+
React.createElement(Box, { gap: 2, marginTop: 1 },
|
|
91
|
+
artifactCount > 0 && React.createElement(Text, { color: 'cyan' }, `'a' → browse artifacts`),
|
|
92
|
+
React.createElement(Text, { dimColor: true }, `Esc → back`),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Failed state
|
|
98
|
+
return React.createElement(Box, { flexDirection: 'column', gap: 1 },
|
|
99
|
+
React.createElement(Text, { bold: true, color: 'red' }, `✗ ${selected} failed`),
|
|
100
|
+
React.createElement(Text, { color: 'red' }, error ?? 'Unknown error'),
|
|
101
|
+
React.createElement(Text, { dimColor: true }, 'Esc to go back'),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -1,19 +1,30 @@
|
|
|
1
|
-
// TUI component — bottom status bar
|
|
1
|
+
// TUI component — bottom status bar with context-aware shortcuts
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const HINTS = {
|
|
6
|
+
projects: 'n: new project q: quit',
|
|
7
|
+
create: 'Esc: cancel',
|
|
8
|
+
detail: 'c: chat r: run a: artifacts Esc: back',
|
|
9
|
+
chat: 'Enter: send Esc: back',
|
|
10
|
+
run: 'Enter: select Esc: back',
|
|
11
|
+
artifacts:'Enter: download Esc: back',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function StatusBar({ project, orgName, connected, view }) {
|
|
15
|
+
const hint = HINTS[view] ?? 'q: quit';
|
|
16
|
+
|
|
17
|
+
return React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
|
|
7
18
|
React.createElement(Text, { dimColor: true }, '─'.repeat(60)),
|
|
8
19
|
React.createElement(Box, { gap: 2 },
|
|
9
20
|
project
|
|
10
|
-
? React.createElement(Text, { dimColor: true },
|
|
11
|
-
: React.createElement(Text, { dimColor: true }, 'No project
|
|
12
|
-
orgName && React.createElement(Text, { dimColor: true },
|
|
21
|
+
? React.createElement(Text, { dimColor: true }, `${project.name}`)
|
|
22
|
+
: React.createElement(Text, { dimColor: true }, 'No project'),
|
|
23
|
+
orgName && React.createElement(Text, { dimColor: true }, `· ${orgName}`),
|
|
13
24
|
React.createElement(Text, { color: connected ? 'green' : 'red' },
|
|
14
|
-
connected ? '●
|
|
25
|
+
connected ? '● ' : '● '
|
|
15
26
|
),
|
|
16
|
-
React.createElement(Text, { dimColor: true },
|
|
27
|
+
React.createElement(Text, { dimColor: true }, hint),
|
|
17
28
|
),
|
|
18
29
|
);
|
|
19
30
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// TUI hook — idea chat state + SSE streaming
|
|
2
|
+
import { useState, useCallback } from 'react';
|
|
3
|
+
import { streamIdeaChat } from '../../lib/sse-client.js';
|
|
4
|
+
import { getConfig } from '../../lib/config-store.js';
|
|
5
|
+
import { requireToken } from '../../lib/auth-store.js';
|
|
6
|
+
|
|
7
|
+
export function useChat(projectId) {
|
|
8
|
+
const [messages, setMessages] = useState([]);
|
|
9
|
+
const [streaming, setStreaming] = useState(false);
|
|
10
|
+
const [readiness, setReadiness] = useState(null);
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
|
|
13
|
+
const sendMessage = useCallback(async (text) => {
|
|
14
|
+
if (streaming || !projectId) return;
|
|
15
|
+
setError(null);
|
|
16
|
+
setStreaming(true);
|
|
17
|
+
|
|
18
|
+
// Append user message + empty assistant placeholder
|
|
19
|
+
setMessages((prev) => [
|
|
20
|
+
...prev,
|
|
21
|
+
{ role: 'user', text },
|
|
22
|
+
{ role: 'assistant', text: '' },
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const { apiUrl } = getConfig();
|
|
27
|
+
const token = await requireToken();
|
|
28
|
+
|
|
29
|
+
for await (const { event, data } of streamIdeaChat(apiUrl, token, projectId, text)) {
|
|
30
|
+
if (event === 'chat:delta') {
|
|
31
|
+
setMessages((prev) => {
|
|
32
|
+
const copy = [...prev];
|
|
33
|
+
const last = copy[copy.length - 1];
|
|
34
|
+
copy[copy.length - 1] = { ...last, text: last.text + (data.text ?? '') };
|
|
35
|
+
return copy;
|
|
36
|
+
});
|
|
37
|
+
} else if (event === 'chat:complete') {
|
|
38
|
+
if (data.readiness != null) setReadiness(data.readiness);
|
|
39
|
+
setStreaming(false);
|
|
40
|
+
} else if (event === 'chat:error') {
|
|
41
|
+
setError(data.error ?? 'AI error');
|
|
42
|
+
setStreaming(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
setError(err.message ?? 'Connection error');
|
|
47
|
+
setStreaming(false);
|
|
48
|
+
}
|
|
49
|
+
}, [projectId, streaming]);
|
|
50
|
+
|
|
51
|
+
return { messages, streaming, readiness, error, sendMessage };
|
|
52
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// TUI hook — phase execution + SSE progress streaming
|
|
2
|
+
import { useState, useCallback } from 'react';
|
|
3
|
+
import { runPhase } from '../../lib/api-client.js';
|
|
4
|
+
import { streamPhaseProgress } from '../../lib/sse-client.js';
|
|
5
|
+
import { getConfig } from '../../lib/config-store.js';
|
|
6
|
+
import { requireToken } from '../../lib/auth-store.js';
|
|
7
|
+
import { collectWorkspaceSnapshot } from '../../lib/workspace-scanner.js';
|
|
8
|
+
|
|
9
|
+
export function usePhaseRun(projectId) {
|
|
10
|
+
const [status, setStatus] = useState('idle'); // idle | running | completed | failed
|
|
11
|
+
const [step, setStep] = useState('');
|
|
12
|
+
const [pct, setPct] = useState(0);
|
|
13
|
+
const [artifactCount, setArtifactCount] = useState(0);
|
|
14
|
+
const [error, setError] = useState(null);
|
|
15
|
+
|
|
16
|
+
const reset = useCallback(() => {
|
|
17
|
+
setStatus('idle');
|
|
18
|
+
setStep('');
|
|
19
|
+
setPct(0);
|
|
20
|
+
setArtifactCount(0);
|
|
21
|
+
setError(null);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const run = useCallback(async (phase) => {
|
|
25
|
+
if (!projectId) return;
|
|
26
|
+
setStatus('running');
|
|
27
|
+
setStep('Starting…');
|
|
28
|
+
setPct(0);
|
|
29
|
+
setError(null);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const { apiUrl } = getConfig();
|
|
33
|
+
const token = await requireToken();
|
|
34
|
+
const options = {};
|
|
35
|
+
|
|
36
|
+
if (phase === 'analyze-codebase') {
|
|
37
|
+
setStep('Scanning workspace…');
|
|
38
|
+
const snapshot = await collectWorkspaceSnapshot(process.cwd());
|
|
39
|
+
options.snapshot = snapshot;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result = await runPhase(projectId, phase, options);
|
|
43
|
+
const { jobId } = result;
|
|
44
|
+
|
|
45
|
+
setStep('Queued');
|
|
46
|
+
|
|
47
|
+
for await (const { event, data } of streamPhaseProgress(apiUrl, jobId, token)) {
|
|
48
|
+
if (event === 'job:status') {
|
|
49
|
+
setStep(data.status ?? 'processing');
|
|
50
|
+
} else if (event === 'job:progress') {
|
|
51
|
+
setStep(data.step ?? data.message ?? '');
|
|
52
|
+
if (data.pct != null) setPct(data.pct);
|
|
53
|
+
} else if (event === 'job:completed') {
|
|
54
|
+
setArtifactCount(data.output?.artifacts?.length ?? 0);
|
|
55
|
+
setPct(100);
|
|
56
|
+
setStatus('completed');
|
|
57
|
+
return;
|
|
58
|
+
} else if (event === 'job:failed') {
|
|
59
|
+
setError(data.error ?? 'Phase failed');
|
|
60
|
+
setStatus('failed');
|
|
61
|
+
return;
|
|
62
|
+
} else if (event === 'job:timeout') {
|
|
63
|
+
setError('Timed out — phase may still be running on server');
|
|
64
|
+
setStatus('failed');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Stream ended without terminal event
|
|
70
|
+
setError('Stream ended unexpectedly — check status with vectora status');
|
|
71
|
+
setStatus('failed');
|
|
72
|
+
} catch (err) {
|
|
73
|
+
setError(err.message ?? 'Unknown error');
|
|
74
|
+
setStatus('failed');
|
|
75
|
+
}
|
|
76
|
+
}, [projectId]);
|
|
77
|
+
|
|
78
|
+
return { status, step, pct, artifactCount, error, run, reset };
|
|
79
|
+
}
|