vgxness 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/agents/agent-selector-resolver.js +23 -0
- package/dist/cli/cli-help.js +9 -4
- package/dist/cli/commands/agent-skill-dispatcher.js +26 -1
- package/dist/cli/commands/index.js +1 -1
- package/dist/cli/commands/interactive-entrypoint-dispatcher.js +2 -2
- package/dist/cli/commands/setup-dispatcher.js +63 -0
- package/dist/cli/dispatcher.js +16 -4
- package/dist/cli/home-tui-app.js +310 -0
- package/dist/cli/home-tui-controller.js +217 -0
- package/dist/cli/setup-plan-renderer.js +60 -36
- package/dist/cli/setup-tui-app.js +130 -0
- package/dist/cli/setup-tui-controller.js +89 -0
- package/dist/cli/tui/ink/components.js +38 -0
- package/dist/cli/tui/ink/theme.js +36 -0
- package/dist/mcp/control-plane.js +7 -0
- package/dist/mcp/opencode-handoff-preview.js +41 -14
- package/dist/mcp/schema.js +8 -0
- package/dist/mcp/stdio-server.js +3 -1
- package/dist/mcp/validation.js +14 -0
- package/dist/providers/opencode/manager-payload.js +21 -12
- package/dist/setup/setup-plan.js +4 -2
- package/dist/skills/skill-export-service.js +34 -0
- package/dist/skills/skill-index-service.js +115 -0
- package/dist/skills/skill-payload.js +1 -0
- package/dist/skills/skill-resolver.js +19 -10
- package/docs/architecture.md +10 -9
- package/docs/cli.md +18 -17
- package/docs/mcp.md +7 -2
- package/docs/project-health-audit-v1.10.x.md +2 -0
- package/docs/project-health-audit-v1.14.x.md +79 -0
- package/docs/project-health-audit-v1.9.1.md +1 -1
- package/docs/providers.md +1 -1
- package/docs/roadmap.md +2 -2
- package/package.json +4 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box } from 'ink';
|
|
3
|
+
import { TuiCard, TuiFooter, TuiHeader, TuiSummaryBar, TuiTabs } from './tui/ink/components.js';
|
|
4
|
+
import { inkTuiTheme, normalizeInkTuiWidth } from './tui/ink/theme.js';
|
|
5
|
+
export const homeTuiTabs = ['setup', 'status', 'runs', 'sdd', 'skills', 'provider'];
|
|
6
|
+
const homeTuiTabItems = homeTuiTabs.map((tab) => ({ key: tab, label: labelForHomeTab(tab) }));
|
|
7
|
+
export function HomeTuiApp({ plan, width, selectedTab, view = 'overview', runsReadModel = { state: 'not-loaded' }, skillsReadModel = { state: 'not-loaded' }, sddInput = { change: '' } }) {
|
|
8
|
+
const cardWidth = normalizeInkTuiWidth(width);
|
|
9
|
+
if (view === 'status-focus')
|
|
10
|
+
return _jsx(FocusedStatusView, { plan: plan, width: cardWidth });
|
|
11
|
+
if (view === 'runs-focus')
|
|
12
|
+
return _jsx(FocusedRunsView, { plan: plan, width: cardWidth, runs: runsReadModel });
|
|
13
|
+
if (view === 'sdd-focus')
|
|
14
|
+
return _jsx(FocusedSddView, { plan: plan, width: cardWidth, sddInput: sddInput });
|
|
15
|
+
if (view === 'skills-focus')
|
|
16
|
+
return _jsx(FocusedSkillsView, { plan: plan, width: cardWidth, skills: skillsReadModel });
|
|
17
|
+
if (view === 'provider-focus')
|
|
18
|
+
return _jsx(FocusedProviderView, { plan: plan, width: cardWidth });
|
|
19
|
+
return (_jsxs(Box, { flexDirection: "column", width: cardWidth, paddingX: 1, paddingY: 1, children: [_jsx(TuiHeader, { title: inkTuiTheme.copy.homeTitle, subtitle: inkTuiTheme.copy.homeSubtitle, project: plan.project }), _jsx(TuiTabs, { items: homeTuiTabItems, selectedKey: selectedTab }), _jsx(TuiSummaryBar, { items: summaryItems(plan) }), _jsx(Box, { marginTop: 1, children: _jsx(HomePanel, { plan: plan, selectedTab: selectedTab }) }), _jsx(TuiFooter, { text: "Keys: \u2191/\u2193 or j/k move \u00B7 Tab/Shift+Tab next/back \u00B7 Enter opens focused area \u00B7 q exits \u00B7 read-only" })] }));
|
|
20
|
+
}
|
|
21
|
+
function FocusedSkillsView({ plan, width, skills }) {
|
|
22
|
+
const badge = skills.state === 'ready' ? 'read-only' : skills.state === 'unavailable' ? 'unavailable' : 'loading';
|
|
23
|
+
const tone = skills.state === 'unavailable' ? 'warning' : 'info';
|
|
24
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, paddingX: 1, paddingY: 1, children: [_jsx(TuiHeader, { title: "VGXNESS Skills", subtitle: "Registry guidance cockpit \u00B7 read-only", project: plan.project }), _jsx(TuiSummaryBar, { items: summaryItems(plan) }), _jsx(Box, { marginTop: 1, children: _jsx(TuiCard, { title: "Skills", badge: badge, tone: tone, lines: focusedSkillsLines(plan, skills) }) }), _jsx(TuiFooter, { text: "Keys: Esc/b back to Home \u00B7 q exits \u00B7 read-only \u00B7 no provider skill writes" })] }));
|
|
25
|
+
}
|
|
26
|
+
function FocusedRunsView({ plan, width, runs }) {
|
|
27
|
+
const badge = runs.state === 'ready' ? 'read-only' : runs.state === 'unavailable' ? 'unavailable' : 'loading';
|
|
28
|
+
const tone = runs.state === 'unavailable' ? 'warning' : 'info';
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, paddingX: 1, paddingY: 1, children: [_jsx(TuiHeader, { title: "VGXNESS Runs", subtitle: "Run recovery cockpit \u00B7 read-only", project: plan.project }), _jsx(TuiSummaryBar, { items: summaryItems(plan) }), _jsx(Box, { marginTop: 1, children: _jsx(TuiCard, { title: "Runs", badge: badge, tone: tone, lines: focusedRunsLines(plan, runs) }) }), _jsx(TuiFooter, { text: "Keys: Esc/b back to Home \u00B7 q exits \u00B7 read-only \u00B7 no writes" })] }));
|
|
30
|
+
}
|
|
31
|
+
function FocusedSddView({ plan, width, sddInput }) {
|
|
32
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, paddingX: 1, paddingY: 1, children: [_jsx(TuiHeader, { title: "VGXNESS SDD", subtitle: "Spec-driven development cockpit \u00B7 change required \u00B7 read-only", project: plan.project }), _jsx(TuiSummaryBar, { items: summaryItems(plan) }), _jsx(Box, { marginTop: 1, children: _jsx(TuiCard, { title: "SDD change gate", badge: sddInput.change.length === 0 ? 'change required' : 'change selected', tone: "info", lines: focusedSddLines(plan, sddInput) }) }), _jsx(TuiFooter, { text: "Keys: type change id \u00B7 Backspace edits \u00B7 Esc back \u00B7 Ctrl-C exits \u00B7 local SDD state not opened" })] }));
|
|
33
|
+
}
|
|
34
|
+
function FocusedStatusView({ plan, width }) {
|
|
35
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, paddingX: 1, paddingY: 1, children: [_jsx(TuiHeader, { title: "VGXNESS Status", subtitle: "Focused setup readiness \u00B7 read-only", project: plan.project }), _jsx(TuiSummaryBar, { items: summaryItems(plan) }), _jsx(Box, { marginTop: 1, children: _jsx(TuiCard, { title: "Current setup status", badge: plan.status === 'ready' ? 'ready' : statusTitle(plan.status), tone: plan.status === 'ready' ? 'success' : plan.status === 'conflict' ? 'danger' : 'warning', lines: focusedStatusLines(plan) }) }), _jsx(TuiFooter, { text: "Keys: Esc/b back to Home \u00B7 q exits \u00B7 read-only \u00B7 no provider config writes" })] }));
|
|
36
|
+
}
|
|
37
|
+
function FocusedProviderView({ plan, width }) {
|
|
38
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, paddingX: 1, paddingY: 1, children: [_jsx(TuiHeader, { title: "VGXNESS Provider", subtitle: "Provider setup plan \u00B7 read-only", project: plan.project }), _jsx(TuiSummaryBar, { items: summaryItems(plan) }), _jsx(Box, { marginTop: 1, children: _jsx(TuiCard, { title: "Provider plan", badge: providerTitle(plan.provider), tone: plan.provider === 'opencode' ? 'info' : 'warning', lines: focusedProviderLines(plan) }) }), _jsx(TuiFooter, { text: "Keys: Esc/b back to Home \u00B7 q exits \u00B7 read-only \u00B7 providers were not executed" })] }));
|
|
39
|
+
}
|
|
40
|
+
function HomePanel({ plan, selectedTab }) {
|
|
41
|
+
if (selectedTab === 'setup')
|
|
42
|
+
return _jsx(TuiCard, { title: "Setup", badge: "preview-only", tone: "warning", lines: setupLines(plan) });
|
|
43
|
+
if (selectedTab === 'status')
|
|
44
|
+
return _jsx(TuiCard, { title: "Status", badge: plan.status === 'ready' ? 'ready' : 'blocked', tone: plan.status === 'ready' ? 'success' : 'danger', lines: statusLines(plan) });
|
|
45
|
+
if (selectedTab === 'runs')
|
|
46
|
+
return _jsx(TuiCard, { title: "Runs", badge: "coming-soon", tone: "neutral", lines: runsLines() });
|
|
47
|
+
if (selectedTab === 'sdd')
|
|
48
|
+
return _jsx(TuiCard, { title: "SDD", badge: "manual", tone: "info", lines: sddLines() });
|
|
49
|
+
if (selectedTab === 'skills')
|
|
50
|
+
return _jsx(TuiCard, { title: "Skills", badge: "read-only", tone: "info", lines: skillsLines() });
|
|
51
|
+
return _jsx(TuiCard, { title: "Provider", badge: plan.opencode === undefined ? 'manual' : plan.status === 'ready' ? 'ready' : 'blocked', tone: plan.status === 'ready' ? 'success' : 'warning', lines: providerLines(plan) });
|
|
52
|
+
}
|
|
53
|
+
function setupLines(plan) {
|
|
54
|
+
return [
|
|
55
|
+
'Setup preview',
|
|
56
|
+
'',
|
|
57
|
+
field('Project', plan.project),
|
|
58
|
+
field('Status', plan.status),
|
|
59
|
+
field('Provider', plan.provider),
|
|
60
|
+
field('Database', `${plan.db.mode} (${plan.db.source})`),
|
|
61
|
+
field('Safety', 'read-only preview'),
|
|
62
|
+
'',
|
|
63
|
+
'Press Enter to open the focused setup flow.',
|
|
64
|
+
'CLI fallback: `vgxness init`.',
|
|
65
|
+
'Use `vgxness setup plan` for a copyable text preview.',
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
function statusLines(plan) {
|
|
69
|
+
return [
|
|
70
|
+
field('Setup', plan.status),
|
|
71
|
+
field('Provider', plan.provider),
|
|
72
|
+
field('Actions', plan.actions.length === 0 ? 'none planned' : `${plan.actions.length} preview action(s)`),
|
|
73
|
+
field('Conflicts', plan.conflicts.length === 0 ? 'none' : `${plan.conflicts.length} item(s)`),
|
|
74
|
+
'',
|
|
75
|
+
'This home view is intentionally read-only and does not open provider processes.',
|
|
76
|
+
nextSetupStep(plan),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
function focusedStatusLines(plan) {
|
|
80
|
+
return [
|
|
81
|
+
field('Project', plan.project),
|
|
82
|
+
field('Setup', plan.status),
|
|
83
|
+
field('Provider', providerTitle(plan.provider)),
|
|
84
|
+
field('Database', `${plan.db.mode} (${plan.db.source})`),
|
|
85
|
+
field('Actions', plan.actions.length === 0 ? 'none planned' : `${plan.actions.length} preview action(s)`),
|
|
86
|
+
field('Conflicts', plan.conflicts.length === 0 ? 'none' : `${plan.conflicts.length} item(s)`),
|
|
87
|
+
field('Backups', plan.backupsPlanned.length === 0 ? 'none planned' : `${plan.backupsPlanned.length} planned for future apply`),
|
|
88
|
+
field('Provider writes', String(plan.safety.writesProviderConfig)),
|
|
89
|
+
'',
|
|
90
|
+
nextSetupStep(plan),
|
|
91
|
+
...(plan.conflicts.length === 0 ? [] : ['', 'Blocking conflicts:', ...plan.conflicts.map((conflict) => `- ${conflict.message}`)]),
|
|
92
|
+
'',
|
|
93
|
+
'This focused panel is still a setup-readiness view; it does not open local run/SDD state.',
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
function focusedRunsLines(plan, runs) {
|
|
97
|
+
if (runs.state === 'unavailable') {
|
|
98
|
+
return [
|
|
99
|
+
field('Project', plan.project),
|
|
100
|
+
field('Store', 'unavailable'),
|
|
101
|
+
field('Database', runs.databasePath),
|
|
102
|
+
field('Safety', 'read-only'),
|
|
103
|
+
'',
|
|
104
|
+
runs.message,
|
|
105
|
+
'',
|
|
106
|
+
'Use `vgxness resume --project <name>` to inspect interrupted runs from a Bun-backed runtime.',
|
|
107
|
+
'Use `vgxness runs list --project <name> --status needs-human` for actionable blockers.',
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
if (runs.state === 'ready') {
|
|
111
|
+
return [
|
|
112
|
+
field('Project', plan.project),
|
|
113
|
+
field('Store', 'opened read-only'),
|
|
114
|
+
field('Database', runs.databasePath),
|
|
115
|
+
field('Recent runs', String(runs.recent.length)),
|
|
116
|
+
field('Interrupted', String(runs.interrupted.length)),
|
|
117
|
+
'',
|
|
118
|
+
...runSummarySection('Interrupted runs', runs.interrupted),
|
|
119
|
+
'',
|
|
120
|
+
...runSummarySection('Recent runs', runs.recent),
|
|
121
|
+
'',
|
|
122
|
+
'Inspect one run: `vgxness runs get --id <run-id>`.',
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
return [
|
|
126
|
+
field('Project', plan.project),
|
|
127
|
+
field('Loaded state', 'not opened'),
|
|
128
|
+
field('Safety', 'read-only placeholder'),
|
|
129
|
+
'',
|
|
130
|
+
'This panel does not open the local SQLite store yet.',
|
|
131
|
+
'Use `vgxness resume --project <name>` to inspect interrupted runs.',
|
|
132
|
+
'Use `vgxness runs list --project <name> --status needs-human` for actionable blockers.',
|
|
133
|
+
'',
|
|
134
|
+
'Next implementation slice can wire this panel to the run read model.',
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
function runSummarySection(title, runs) {
|
|
138
|
+
if (runs.length === 0)
|
|
139
|
+
return [title, '- none'];
|
|
140
|
+
return [title, ...runs.map((run) => `- ${shortRunId(run.id)} ${run.status} ${run.workflow}/${run.phase}${run.userIntent === undefined ? '' : ` — ${run.userIntent}`}`)];
|
|
141
|
+
}
|
|
142
|
+
function shortRunId(id) {
|
|
143
|
+
return id.length <= 8 ? id : id.slice(0, 8);
|
|
144
|
+
}
|
|
145
|
+
function focusedSddLines(plan, input) {
|
|
146
|
+
const change = input.change.length === 0 ? '<id>' : input.change;
|
|
147
|
+
return [
|
|
148
|
+
field('Project', plan.project),
|
|
149
|
+
field('Change', input.change.length === 0 ? 'not selected' : input.change),
|
|
150
|
+
field('Input', `${input.change}_`),
|
|
151
|
+
field('Store', 'not opened'),
|
|
152
|
+
field('Acceptance', 'human-only'),
|
|
153
|
+
field('Safety', 'read-only gate'),
|
|
154
|
+
'',
|
|
155
|
+
'This panel intentionally does not open SQLite until a change id is selected.',
|
|
156
|
+
'Allowed input: letters, numbers, dot, underscore, dash; first character must be alphanumeric.',
|
|
157
|
+
'Daily SDD stays in OpenCode through conversation, VGXNESS MCP, hidden SDD subagents, and registry skills.',
|
|
158
|
+
'',
|
|
159
|
+
`Continue: vgxness sdd continue --project ${plan.project} --change ${change}`,
|
|
160
|
+
`Status: vgxness sdd status --project ${plan.project} --change ${change}`,
|
|
161
|
+
'Governance reminder: artifact presence is not acceptance; acceptance is explicit and human-only.',
|
|
162
|
+
'',
|
|
163
|
+
'Next implementation slice can ask for/select a change id before loading SDD state.',
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
function focusedSkillsLines(plan, skills) {
|
|
167
|
+
if (skills.state === 'unavailable') {
|
|
168
|
+
return [
|
|
169
|
+
field('Project', plan.project),
|
|
170
|
+
field('Scope', 'project'),
|
|
171
|
+
field('Store', 'unavailable'),
|
|
172
|
+
field('Database', skills.databasePath),
|
|
173
|
+
'',
|
|
174
|
+
skills.message,
|
|
175
|
+
'',
|
|
176
|
+
'Use `vgxness skills index --project <name> --scope project` from a Bun-backed runtime.',
|
|
177
|
+
'OpenCode native skill installation is not performed by this TUI.',
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
if (skills.state === 'ready') {
|
|
181
|
+
return [
|
|
182
|
+
field('Project', skills.index.project),
|
|
183
|
+
field('Scope', skills.index.scope),
|
|
184
|
+
field('Store', 'opened read-only'),
|
|
185
|
+
field('Skills', String(skills.index.totals.skills)),
|
|
186
|
+
field('Active', String(skills.index.totals.active)),
|
|
187
|
+
field('Preview-only', String(skills.index.totals.previewOnly)),
|
|
188
|
+
field('Provider writes', String(skills.index.safety.providerConfigWrites)),
|
|
189
|
+
'',
|
|
190
|
+
...skillSummarySection(skills.index.skills),
|
|
191
|
+
'',
|
|
192
|
+
'OpenCode native skill installation is not performed by this TUI.',
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
return [
|
|
196
|
+
field('Project', plan.project),
|
|
197
|
+
field('Scope', 'project'),
|
|
198
|
+
field('Provider', 'preview-only'),
|
|
199
|
+
'',
|
|
200
|
+
'Skill registry view is planned and remains read-only here.',
|
|
201
|
+
'Use `vgxness skills index --project <name> --scope project` for the current CLI view.',
|
|
202
|
+
'OpenCode native skill installation is not performed by this TUI.',
|
|
203
|
+
'',
|
|
204
|
+
'Next implementation slice can show registry diagnostics and attached skill summaries.',
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
function skillSummarySection(skills) {
|
|
208
|
+
if (skills.length === 0)
|
|
209
|
+
return ['Indexed skills', '- none'];
|
|
210
|
+
return ['Indexed skills', ...skills.slice(0, 8).map((skill) => `- ${skill.name} ${skill.status}${skill.activeVersion === null ? '' : ` @ ${skill.activeVersion}`}`), ...(skills.length > 8 ? [`- +${skills.length - 8} more`] : [])];
|
|
211
|
+
}
|
|
212
|
+
function runsLines() {
|
|
213
|
+
return ['Run dashboard is not loaded in this first Home TUI slice.', '', 'Use `vgxness status` or `vgxness resume --project <name>` for current CLI diagnostics.', 'Next slice can wire this panel to the run read model.'];
|
|
214
|
+
}
|
|
215
|
+
function sddLines() {
|
|
216
|
+
return [
|
|
217
|
+
'Change: not selected. Press Enter for the focused SDD gate.',
|
|
218
|
+
'',
|
|
219
|
+
'The Home TUI does not open local SDD state until a change id is selected.',
|
|
220
|
+
'Daily SDD should stay in OpenCode through conversation, VGXNESS MCP, and hidden SDD subagents.',
|
|
221
|
+
'',
|
|
222
|
+
'CLI fallback: `vgxness sdd continue --project <name> --change <id>`.',
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
function skillsLines() {
|
|
226
|
+
return ['Skill registry view is planned.', '', 'Read-only CLI today: `vgxness skills index --project <name> --scope project`.'];
|
|
227
|
+
}
|
|
228
|
+
function providerLines(plan) {
|
|
229
|
+
if (plan.opencode === undefined)
|
|
230
|
+
return [field('Provider', plan.provider), 'No provider-specific setup plan is available.'];
|
|
231
|
+
return [
|
|
232
|
+
field('Provider', 'opencode'),
|
|
233
|
+
field('Scope', plan.opencode.scope),
|
|
234
|
+
field('Action', plan.opencode.action),
|
|
235
|
+
field('Installs agents', plan.opencode.installsAgents ? 'yes' : 'no'),
|
|
236
|
+
field('Target', plan.opencode.targetPath ?? 'not applicable'),
|
|
237
|
+
'',
|
|
238
|
+
'Apply remains explicit: `vgxness setup apply --yes` after review.',
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
function focusedProviderLines(plan) {
|
|
242
|
+
if (plan.opencode === undefined) {
|
|
243
|
+
return [
|
|
244
|
+
field('Provider', providerTitle(plan.provider)),
|
|
245
|
+
field('Setup', plan.status),
|
|
246
|
+
field('Provider writes', String(plan.safety.writesProviderConfig)),
|
|
247
|
+
'',
|
|
248
|
+
'No OpenCode-specific setup plan is available for this provider choice.',
|
|
249
|
+
'Use `vgxness setup plan --provider <name>` for copyable CLI guidance.',
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
return [
|
|
253
|
+
field('Provider', 'OpenCode'),
|
|
254
|
+
field('Scope', plan.opencode.scope),
|
|
255
|
+
field('Planned action', plan.opencode.action),
|
|
256
|
+
field('Target', plan.opencode.targetPath ?? 'not applicable'),
|
|
257
|
+
field('Installs agents', plan.opencode.installsAgents ? 'yes' : 'no'),
|
|
258
|
+
field('Agent count', String(plan.opencode.agentNames.length)),
|
|
259
|
+
field('Bash policy', bashPolicySummary(plan.opencode.bashPermissionPolicy)),
|
|
260
|
+
field('Provider writes', String(plan.safety.writesProviderConfig)),
|
|
261
|
+
'',
|
|
262
|
+
plan.opencode.agentNames.length === 0 ? 'Agents: none planned.' : `Agents: ${plan.opencode.agentNames.join(', ')}`,
|
|
263
|
+
'Apply remains explicit: `vgxness setup apply --yes` after review.',
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
function bashPolicySummary(policy) {
|
|
267
|
+
return policy.manager === 'allow' ? `${policy.topLevel} globally, allow for VGXNESS manager` : policy.topLevel;
|
|
268
|
+
}
|
|
269
|
+
function summaryItems(plan) {
|
|
270
|
+
const ready = plan.status === 'ready';
|
|
271
|
+
const hasConflicts = plan.conflicts.length > 0;
|
|
272
|
+
return [
|
|
273
|
+
{ label: 'Setup', value: ready ? 'Ready' : statusTitle(plan.status), tone: ready ? 'success' : hasConflicts ? 'danger' : 'warning' },
|
|
274
|
+
{ label: 'Provider', value: providerTitle(plan.provider), tone: plan.provider === 'opencode' ? 'info' : 'warning' },
|
|
275
|
+
{ label: 'Safety', value: 'Read-only', tone: 'success' },
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
function statusTitle(status) {
|
|
279
|
+
if (status === 'manual-required')
|
|
280
|
+
return 'Manual';
|
|
281
|
+
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
282
|
+
}
|
|
283
|
+
function providerTitle(provider) {
|
|
284
|
+
if (provider === 'opencode')
|
|
285
|
+
return 'OpenCode';
|
|
286
|
+
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
287
|
+
}
|
|
288
|
+
function nextSetupStep(plan) {
|
|
289
|
+
if (plan.status === 'ready')
|
|
290
|
+
return 'Next: review setup details, then apply explicitly only if intended.';
|
|
291
|
+
if (plan.status === 'conflict')
|
|
292
|
+
return 'Next: resolve the provider conflict before applying setup.';
|
|
293
|
+
return 'Next: follow manual setup guidance before applying changes.';
|
|
294
|
+
}
|
|
295
|
+
function labelForHomeTab(tab) {
|
|
296
|
+
if (tab === 'setup')
|
|
297
|
+
return 'Setup';
|
|
298
|
+
if (tab === 'status')
|
|
299
|
+
return 'Status';
|
|
300
|
+
if (tab === 'runs')
|
|
301
|
+
return 'Runs';
|
|
302
|
+
if (tab === 'sdd')
|
|
303
|
+
return 'SDD';
|
|
304
|
+
if (tab === 'skills')
|
|
305
|
+
return 'Skills';
|
|
306
|
+
return 'Provider';
|
|
307
|
+
}
|
|
308
|
+
function field(label, value) {
|
|
309
|
+
return `${label.padEnd(16)} ${value}`;
|
|
310
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { render as renderInk } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { RunService } from '../runs/run-service.js';
|
|
4
|
+
import { SkillRegistryService } from '../skills/skill-registry-service.js';
|
|
5
|
+
import { SkillIndexService } from '../skills/skill-index-service.js';
|
|
6
|
+
import { okText } from './cli-help.js';
|
|
7
|
+
import { HomeTuiApp, homeTuiTabs } from './home-tui-app.js';
|
|
8
|
+
import { renderSetupPlan } from './setup-plan-renderer.js';
|
|
9
|
+
import { runSetupTuiController } from './setup-tui-controller.js';
|
|
10
|
+
import { navigationIntentFromInput } from './tui/keymap.js';
|
|
11
|
+
import { nextItem } from './tui/navigation.js';
|
|
12
|
+
import { openMemoryDatabase } from '../memory/sqlite/database.js';
|
|
13
|
+
const enter = '\r';
|
|
14
|
+
const escape = '\u001B';
|
|
15
|
+
const ctrlC = '\u0003';
|
|
16
|
+
const backspace = '\u007F';
|
|
17
|
+
const ctrlH = '\b';
|
|
18
|
+
const clearScreen = '\u001B[2J\u001B[3J\u001B[H';
|
|
19
|
+
export async function runHomeTuiController(input) {
|
|
20
|
+
const { stdin, stdout } = input.environment;
|
|
21
|
+
if (stdin === undefined || stdout === undefined)
|
|
22
|
+
return okText(renderSetupPlan(input.plan));
|
|
23
|
+
const width = tuiWidth(stdout);
|
|
24
|
+
let selectedTab = 'setup';
|
|
25
|
+
let view = 'overview';
|
|
26
|
+
let runsReadModel = { state: 'not-loaded' };
|
|
27
|
+
let skillsReadModel = { state: 'not-loaded' };
|
|
28
|
+
let sddInput = { change: '' };
|
|
29
|
+
const app = renderInk(React.createElement(HomeTuiApp, { plan: input.plan, width, selectedTab, view, runsReadModel, skillsReadModel, sddInput }), {
|
|
30
|
+
stdin: stdin,
|
|
31
|
+
stdout: stdout,
|
|
32
|
+
interactive: true,
|
|
33
|
+
patchConsole: false,
|
|
34
|
+
exitOnCtrlC: false,
|
|
35
|
+
});
|
|
36
|
+
const rerender = () => {
|
|
37
|
+
app.rerender(React.createElement(HomeTuiApp, { plan: input.plan, width, selectedTab, view, runsReadModel, skillsReadModel, sddInput }));
|
|
38
|
+
};
|
|
39
|
+
stdin.setRawMode?.(true);
|
|
40
|
+
stdin.resume?.();
|
|
41
|
+
const action = await new Promise((resolve) => {
|
|
42
|
+
const listener = (chunk) => {
|
|
43
|
+
const value = chunk.toString();
|
|
44
|
+
if (view === 'sdd-focus') {
|
|
45
|
+
if (value === ctrlC) {
|
|
46
|
+
cleanup();
|
|
47
|
+
resolve('quit');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (value === escape) {
|
|
51
|
+
view = 'overview';
|
|
52
|
+
rerender();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (value === backspace || value === ctrlH) {
|
|
56
|
+
sddInput = { change: sddInput.change.slice(0, -1) };
|
|
57
|
+
rerender();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const nextSddChange = appendSddChangeInput(sddInput.change, value);
|
|
61
|
+
if (nextSddChange !== sddInput.change) {
|
|
62
|
+
sddInput = { change: nextSddChange };
|
|
63
|
+
rerender();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (value === enter || value === '\n')
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const intent = navigationIntentFromInput(value);
|
|
70
|
+
if (value === 'q' || value === 'Q' || value === ctrlC) {
|
|
71
|
+
cleanup();
|
|
72
|
+
resolve('quit');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (view !== 'overview' && (value === escape || value === 'b' || value === 'B' || intent === 'back')) {
|
|
76
|
+
view = 'overview';
|
|
77
|
+
rerender();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (intent === 'cancel') {
|
|
81
|
+
cleanup();
|
|
82
|
+
resolve('quit');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if ((value === enter || value === '\n' || intent === 'select') && selectedTab === 'setup') {
|
|
86
|
+
cleanup();
|
|
87
|
+
resolve('open-setup');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if ((value === enter || value === '\n' || intent === 'select') && selectedTab === 'status') {
|
|
91
|
+
view = 'status-focus';
|
|
92
|
+
rerender();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if ((value === enter || value === '\n' || intent === 'select') && selectedTab === 'runs') {
|
|
96
|
+
runsReadModel = readHomeRuns(input.plan);
|
|
97
|
+
view = 'runs-focus';
|
|
98
|
+
rerender();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if ((value === enter || value === '\n' || intent === 'select') && selectedTab === 'sdd') {
|
|
102
|
+
view = 'sdd-focus';
|
|
103
|
+
rerender();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if ((value === enter || value === '\n' || intent === 'select') && selectedTab === 'skills') {
|
|
107
|
+
skillsReadModel = readHomeSkills(input.plan);
|
|
108
|
+
view = 'skills-focus';
|
|
109
|
+
rerender();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if ((value === enter || value === '\n' || intent === 'select') && selectedTab === 'provider') {
|
|
113
|
+
view = 'provider-focus';
|
|
114
|
+
rerender();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (value === enter || value === '\n' || intent === 'select')
|
|
118
|
+
return;
|
|
119
|
+
if (view !== 'overview')
|
|
120
|
+
return;
|
|
121
|
+
if (intent === 'next' || intent === 'down') {
|
|
122
|
+
selectedTab = nextItem(homeTuiTabs, selectedTab, 1);
|
|
123
|
+
rerender();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (intent === 'back' || intent === 'up') {
|
|
127
|
+
selectedTab = nextItem(homeTuiTabs, selectedTab, -1);
|
|
128
|
+
rerender();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const cleanup = () => {
|
|
132
|
+
if (stdin.off !== undefined)
|
|
133
|
+
stdin.off('data', listener);
|
|
134
|
+
else
|
|
135
|
+
stdin.removeListener?.('data', listener);
|
|
136
|
+
stdin.setRawMode?.(false);
|
|
137
|
+
stdin.pause?.();
|
|
138
|
+
app.unmount();
|
|
139
|
+
};
|
|
140
|
+
stdin.on('data', listener);
|
|
141
|
+
});
|
|
142
|
+
await app.waitUntilExit();
|
|
143
|
+
if (action === 'open-setup') {
|
|
144
|
+
clearTerminalBeforeNestedTui(stdout);
|
|
145
|
+
return runSetupTuiController({ environment: input.environment, plan: input.plan });
|
|
146
|
+
}
|
|
147
|
+
return okText('');
|
|
148
|
+
}
|
|
149
|
+
function appendSddChangeInput(current, input) {
|
|
150
|
+
if (input.length !== 1 || current.length >= 80)
|
|
151
|
+
return current;
|
|
152
|
+
if (current.length === 0)
|
|
153
|
+
return /^[A-Za-z0-9]$/.test(input) ? input : current;
|
|
154
|
+
return /^[A-Za-z0-9._-]$/.test(input) ? `${current}${input}` : current;
|
|
155
|
+
}
|
|
156
|
+
function clearTerminalBeforeNestedTui(stdout) {
|
|
157
|
+
stdout.write(clearScreen);
|
|
158
|
+
}
|
|
159
|
+
function readHomeRuns(plan) {
|
|
160
|
+
const opened = openMemoryDatabase({ path: plan.db.path, readonly: true });
|
|
161
|
+
if (!opened.ok)
|
|
162
|
+
return { state: 'unavailable', databasePath: plan.db.path, message: opened.error.message };
|
|
163
|
+
try {
|
|
164
|
+
const service = new RunService(opened.value);
|
|
165
|
+
const recent = service.listRuns({ project: plan.project, limit: 5 });
|
|
166
|
+
if (!recent.ok)
|
|
167
|
+
return { state: 'unavailable', databasePath: plan.db.path, message: recent.error.message };
|
|
168
|
+
const interrupted = service.listRecentInterruptedRuns({ project: plan.project, limit: 5 });
|
|
169
|
+
if (!interrupted.ok)
|
|
170
|
+
return { state: 'unavailable', databasePath: plan.db.path, message: interrupted.error.message };
|
|
171
|
+
return {
|
|
172
|
+
state: 'ready',
|
|
173
|
+
databasePath: plan.db.path,
|
|
174
|
+
recent: recent.value.map(runRecordSummary),
|
|
175
|
+
interrupted: interrupted.value.map((run) => ({
|
|
176
|
+
id: run.runId,
|
|
177
|
+
status: run.status,
|
|
178
|
+
workflow: run.workflow,
|
|
179
|
+
phase: run.phase,
|
|
180
|
+
...(run.userIntent === undefined ? {} : { userIntent: run.userIntent }),
|
|
181
|
+
})),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
opened.value.close();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function runRecordSummary(run) {
|
|
189
|
+
const userIntent = run.userIntent.trim();
|
|
190
|
+
return {
|
|
191
|
+
id: run.id,
|
|
192
|
+
status: run.status,
|
|
193
|
+
workflow: run.workflow,
|
|
194
|
+
phase: run.phase,
|
|
195
|
+
...(userIntent.length === 0 ? {} : { userIntent }),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function readHomeSkills(plan) {
|
|
199
|
+
const opened = openMemoryDatabase({ path: plan.db.path, readonly: true });
|
|
200
|
+
if (!opened.ok)
|
|
201
|
+
return { state: 'unavailable', databasePath: plan.db.path, message: opened.error.message };
|
|
202
|
+
try {
|
|
203
|
+
const registry = new SkillRegistryService(opened.value);
|
|
204
|
+
const index = new SkillIndexService(registry).getIndex({ project: plan.project, scope: 'project' });
|
|
205
|
+
if (!index.ok)
|
|
206
|
+
return { state: 'unavailable', databasePath: plan.db.path, message: index.error.message };
|
|
207
|
+
return { state: 'ready', databasePath: plan.db.path, index: index.value };
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
opened.value.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function tuiWidth(stdout) {
|
|
214
|
+
const columns = stdout.columns;
|
|
215
|
+
const width = typeof columns === 'number' && Number.isFinite(columns) ? Math.floor(columns) : 88;
|
|
216
|
+
return Math.max(60, Math.min(width, 88));
|
|
217
|
+
}
|
|
@@ -1,55 +1,79 @@
|
|
|
1
|
+
import { joinSections, renderActionLine, renderPanel } from './tui-render-helpers.js';
|
|
1
2
|
export function renderSetupPlan(plan) {
|
|
2
|
-
return [
|
|
3
|
-
'Setup
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
...renderActions(plan),
|
|
10
|
-
...renderBackups(plan),
|
|
11
|
-
...renderConflicts(plan),
|
|
12
|
-
...renderNextCommands(plan),
|
|
3
|
+
return `${joinSections([
|
|
4
|
+
renderPanel('VGXNESS Setup [read-only]', renderOverview(plan), { status: 'preview-only' }),
|
|
5
|
+
renderOpenCode(plan),
|
|
6
|
+
renderPanel('Planned Actions', renderActions(plan), { status: 'preview-only' }),
|
|
7
|
+
renderPanel('Backups', renderBackups(plan), { status: 'requires confirmation' }),
|
|
8
|
+
renderConflicts(plan),
|
|
9
|
+
renderPanel('Next Commands', renderNextCommands(plan), { status: 'manual' }),
|
|
13
10
|
'Safety: [read-only] preview only. No provider config was written. Explicit confirmation is required before writing provider config; apply with --yes only after review.',
|
|
14
|
-
]
|
|
11
|
+
])}\n`;
|
|
15
12
|
}
|
|
16
13
|
function renderOpenCode(plan) {
|
|
17
14
|
if (plan.opencode === undefined)
|
|
18
|
-
return
|
|
15
|
+
return undefined;
|
|
19
16
|
const target = plan.opencode.targetPath === undefined ? 'not applicable' : plan.opencode.targetPath;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
const status = plan.status === 'ready' ? 'ready' : plan.status === 'conflict' ? 'blocked' : 'manual';
|
|
18
|
+
return renderPanel('OpenCode Provider', [
|
|
19
|
+
field('Scope', plan.opencode.scope),
|
|
20
|
+
field('Action', plan.opencode.action),
|
|
21
|
+
field('Target path', target),
|
|
22
|
+
field('Installs agents', yesNo(plan.opencode.installsAgents)),
|
|
23
|
+
field('Reinstall entries', yesNo(plan.opencode.overwriteVgxness === true)),
|
|
24
|
+
field('Bash policy', formatBashPermissionPolicy(plan.opencode.bashPermissionPolicy)),
|
|
25
|
+
field('Agent names', plan.opencode.agentNames.length === 0 ? 'none' : plan.opencode.agentNames.join(', ')),
|
|
26
|
+
], { status });
|
|
29
27
|
}
|
|
30
28
|
function renderActions(plan) {
|
|
31
29
|
if (plan.actions.length === 0)
|
|
32
|
-
return ['
|
|
33
|
-
return
|
|
34
|
-
'
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
return ['No provider config writes are planned for this preview.'];
|
|
31
|
+
return plan.actions.flatMap((action) => renderActionLine(action.id, {
|
|
32
|
+
labels: [action.mutating ? 'writes provider config' : 'no provider writes', action.backupRequired ? 'requires confirmation' : 'preview-only'],
|
|
33
|
+
description: action.targetPath === undefined ? action.description : `${action.description} Target: ${action.targetPath}`,
|
|
34
|
+
}));
|
|
37
35
|
}
|
|
38
36
|
function renderBackups(plan) {
|
|
39
37
|
if (plan.backupsPlanned.length === 0)
|
|
40
|
-
return ['
|
|
41
|
-
return
|
|
38
|
+
return ['No backups are required for this preview.'];
|
|
39
|
+
return plan.backupsPlanned.flatMap((backup) => renderActionLine(backup.targetPath, { labels: ['requires confirmation'], description: backup.reason }));
|
|
42
40
|
}
|
|
43
41
|
function renderConflicts(plan) {
|
|
44
42
|
if (plan.conflicts.length === 0)
|
|
45
|
-
return
|
|
46
|
-
return
|
|
47
|
-
'
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
return undefined;
|
|
44
|
+
return renderPanel('Conflicts & Recovery', plan.conflicts.flatMap((conflict) => renderActionLine(conflict.id, {
|
|
45
|
+
labels: [conflict.severity === 'blocking' ? 'blocked' : 'manual'],
|
|
46
|
+
description: [
|
|
47
|
+
conflict.message,
|
|
48
|
+
conflict.targetPath === undefined ? undefined : `Target: ${conflict.targetPath}`,
|
|
49
|
+
conflict.recovery === undefined ? undefined : `Recovery: ${conflict.recovery}`,
|
|
50
|
+
]
|
|
51
|
+
.filter((value) => value !== undefined)
|
|
52
|
+
.join(' '),
|
|
53
|
+
})), { status: 'blocked' });
|
|
50
54
|
}
|
|
51
55
|
function renderNextCommands(plan) {
|
|
52
56
|
if (plan.nextCommands.length === 0)
|
|
53
|
-
return ['
|
|
54
|
-
return
|
|
57
|
+
return ['No next command is recommended.'];
|
|
58
|
+
return plan.nextCommands.flatMap((command) => renderActionLine(command, { labels: ['manual'] }));
|
|
59
|
+
}
|
|
60
|
+
function renderOverview(plan) {
|
|
61
|
+
return [
|
|
62
|
+
field('Project', plan.project),
|
|
63
|
+
field('Workspace', plan.workspaceRoot),
|
|
64
|
+
field('Database', `${plan.db.mode} — ${plan.db.path}`),
|
|
65
|
+
field('DB source', plan.db.source),
|
|
66
|
+
field('Provider', plan.provider),
|
|
67
|
+
field('Status', plan.status),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
function field(label, value) {
|
|
71
|
+
return `${label.padEnd(18)} ${value}`;
|
|
72
|
+
}
|
|
73
|
+
function yesNo(value) {
|
|
74
|
+
return value ? 'yes' : 'no';
|
|
75
|
+
}
|
|
76
|
+
function formatBashPermissionPolicy(policy) {
|
|
77
|
+
const typedPolicy = policy;
|
|
78
|
+
return `top-level ${typedPolicy.topLevel}; manager ${typedPolicy.manager}`;
|
|
55
79
|
}
|