@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectorasystems/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Vectora CLI — AI-powered project orchestration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  // @vectora/cli — constants
2
2
  // Inlined from @vectora/engine to avoid dependency in API-connected mode.
3
3
 
4
- export const VERSION = '0.1.0';
4
+ export const VERSION = '0.1.3';
5
5
 
6
6
  export const BRAND = {
7
7
  name: 'VECTORA',
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 { PhaseTimeline } from './components/PhaseTimeline.js';
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'); // 'projects' | 'detail'
17
- const { project, phases, artifacts, loading, refresh, projectId } = useProject();
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
- // Keyboard shortcuts
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
- if (input === 'r') {
38
- refresh();
39
- }
40
- if (input === 'p') {
41
- setView('projects');
44
+
45
+ if (view === 'projects') {
46
+ if (input === 'n') setView('create');
47
+ if (input === 'r') refresh();
42
48
  }
43
- if (key.escape) {
44
- setView('projects');
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
- loading
52
- ? React.createElement(Text, { dimColor: true }, 'Loading...')
53
- : view === 'projects'
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
- export function StatusBar({ project, orgName, connected }) {
6
- return React.createElement(Box, { marginTop: 1 },
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 }, `Project: ${project.name}`)
11
- : React.createElement(Text, { dimColor: true }, 'No project selected'),
12
- orgName && React.createElement(Text, { dimColor: true }, `Org: ${orgName}`),
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 ? '● Online' : '● Offline'
25
+ connected ? '● ' : '● '
15
26
  ),
16
- React.createElement(Text, { dimColor: true }, 'q: quit'),
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
+ }