@vectorasystems/cli 0.1.0 → 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/bin/vectora.js +18 -0
- package/package.json +1 -1
- package/src/commands/artifacts.js +35 -2
- package/src/commands/projects.js +29 -1
- package/src/lib/api-client.js +4 -0
- 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/bin/vectora.js
CHANGED
|
@@ -69,6 +69,15 @@ projects
|
|
|
69
69
|
await m.show(id, opts);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
projects
|
|
73
|
+
.command('edit [id]')
|
|
74
|
+
.description('Edit project metadata (defaults to active project)')
|
|
75
|
+
.option('-n, --name <name>', 'New project name')
|
|
76
|
+
.action(async (id, opts) => {
|
|
77
|
+
const m = await import('../src/commands/projects.js');
|
|
78
|
+
await m.edit(id, opts);
|
|
79
|
+
});
|
|
80
|
+
|
|
72
81
|
projects
|
|
73
82
|
.command('select <id>')
|
|
74
83
|
.description('Set active project')
|
|
@@ -139,6 +148,15 @@ artifacts
|
|
|
139
148
|
await m.show(id, opts);
|
|
140
149
|
});
|
|
141
150
|
|
|
151
|
+
artifacts
|
|
152
|
+
.command('download <id>')
|
|
153
|
+
.description('Download artifact content to a file')
|
|
154
|
+
.option('-o, --out <path>', 'Output file path (defaults to artifact filename in cwd)')
|
|
155
|
+
.action(async (id, opts) => {
|
|
156
|
+
const m = await import('../src/commands/artifacts.js');
|
|
157
|
+
await m.download(id, opts);
|
|
158
|
+
});
|
|
159
|
+
|
|
142
160
|
// ── Usage ─────────────────────────────────────────────────────────────────────
|
|
143
161
|
program
|
|
144
162
|
.command('usage')
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// @vectora/cli — artifact commands
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
3
|
+
import { writeFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { getProjectArtifacts, getArtifact, getArtifactDownloadUrl } from '../lib/api-client.js';
|
|
4
6
|
import { getConfig, getConfigValue } from '../lib/config-store.js';
|
|
5
7
|
import { handleError } from '../lib/errors.js';
|
|
6
|
-
import { renderTable, renderJson, renderTime, warn, info } from '../lib/output.js';
|
|
8
|
+
import { renderTable, renderJson, renderTime, warn, info, success } from '../lib/output.js';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* vectora artifacts list [--project <id>] [--type <type>]
|
|
@@ -86,3 +88,34 @@ export async function show(id, opts) {
|
|
|
86
88
|
handleError(err);
|
|
87
89
|
}
|
|
88
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* vectora artifacts download <id> [--out <path>]
|
|
94
|
+
*/
|
|
95
|
+
export async function download(id, opts) {
|
|
96
|
+
try {
|
|
97
|
+
if (!id) {
|
|
98
|
+
console.error(chalk.red('Error — artifact ID is required'));
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Get presigned URL + suggested filename from API
|
|
104
|
+
const { url, filename } = await getArtifactDownloadUrl(id);
|
|
105
|
+
|
|
106
|
+
const outPath = resolve(opts.out ?? filename);
|
|
107
|
+
|
|
108
|
+
// Fetch directly from R2 via presigned URL
|
|
109
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
throw new Error(`Download failed: HTTP ${res.status}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
115
|
+
await writeFile(outPath, buffer);
|
|
116
|
+
|
|
117
|
+
success(`Saved to ${chalk.bold(outPath)} ${chalk.dim(`(${(buffer.length / 1024).toFixed(1)} KB)`)}`);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
handleError(err);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/commands/projects.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vectora/cli — project commands
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { getProjects, createProject, getProject, deleteProject, getWorkspaces } from '../lib/api-client.js';
|
|
3
|
+
import { getProjects, createProject, getProject, patchProject, deleteProject, getWorkspaces } from '../lib/api-client.js';
|
|
4
4
|
import { getConfig, setConfigValue, getConfigValue } from '../lib/config-store.js';
|
|
5
5
|
import { handleError } from '../lib/errors.js';
|
|
6
6
|
import { renderTable, renderJson, renderTime, success, info, warn } from '../lib/output.js';
|
|
@@ -137,6 +137,34 @@ export async function show(id, opts) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* vectora projects edit [<id>] --name <name>
|
|
142
|
+
*/
|
|
143
|
+
export async function edit(id, opts) {
|
|
144
|
+
try {
|
|
145
|
+
const projectId = id ?? getConfigValue('defaultProject');
|
|
146
|
+
if (!projectId) {
|
|
147
|
+
warn('No project specified. Use --project <id> or select one with: vectora projects select <id>');
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const updates = {};
|
|
153
|
+
if (opts.name !== undefined) updates.name = opts.name;
|
|
154
|
+
|
|
155
|
+
if (Object.keys(updates).length === 0) {
|
|
156
|
+
warn('Nothing to update. Use --name <name> to rename the project.');
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const project = await patchProject(projectId, updates);
|
|
162
|
+
success(`Updated project ${chalk.bold(project.name)} ${chalk.dim(`(${project.id})`)}`);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
handleError(err);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
140
168
|
/**
|
|
141
169
|
* vectora projects select <id>
|
|
142
170
|
*/
|
package/src/lib/api-client.js
CHANGED
|
@@ -121,6 +121,10 @@ export async function getArtifact(artifactId) {
|
|
|
121
121
|
return res.artifact;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
export async function getArtifactDownloadUrl(artifactId) {
|
|
125
|
+
return authedFetch(`/v1/artifacts/${artifactId}/download`);
|
|
126
|
+
}
|
|
127
|
+
|
|
124
128
|
// ─── Handoff ─────────────────────────────────────────────────────────────────
|
|
125
129
|
|
|
126
130
|
export async function triggerHandoff(projectId, target) {
|
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
|
+
}
|