Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/neo-tui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# neo-tui

Cross-platform console UI for **neo-cli** (Windows, Linux, macOS).

It starts the same `MainService` node as `neo-cli`, loads plugins from `Plugins/` the same way, then shows the live **show state** dashboard.

- Live **node status** (block height, mempool, peers, sync) — same data as `show state`
- **CONNECTED PEERS** from `LocalNode` (Up/Down to scroll when there are more than five)
- Command **output popup** on this console with **Please wait** until the command finishes, then **Rerun command** or **Close**
- Category **menus** (`M` / Enter from the status screen)
- **Command palette** of every `[ConsoleCommand]`, including plugin commands
- **Line editor** with history, Tab complete, Ctrl+A/E/U
- Direct `System.CommandLine` subcommands (`neo-tui help`, `neo-tui show block 1`, …)

## Run (from repo)

```bash
dotnet run --project src/Neo.ConsoleUI
```

Or after build:

```bash
dotnet build src/Neo.ConsoleUI/Neo.ConsoleUI.csproj
# output: src/Neo.ConsoleUI/bin/Debug/net10.0/neo-tui.dll
dotnet src/Neo.ConsoleUI/bin/Debug/net10.0/neo-tui.dll
```

Startup flags match neo-cli: `--config`/`-c`, `--wallet`/`-w`, `--password`/`-p`, `--db-engine`, `--db-path`, `--plugins`, `--noverify`.

The working directory is the exe folder so `Plugins/`, `config.json`, and auto-install zips resolve the same way as neo-cli. On build, each plugin is copied next to the binary: `bin/$(Configuration)/$(TargetFramework)/Plugins/<Name>/` (the folder is created if it is missing). Extra plugins can still be installed at runtime (`--plugins RpcServer` or the `install` command).

## Screenshots

Wide status dashboard — 2×2 metrics, sync bar, **CONNECTED PEERS** on the right (title on the time row, table aligned with BLOCKCHAIN STATUS):

![Wide status with connected peers](status-wide.png)

Peer list scrolled (`6-10/10`):

![Status with peers scrolled](status-peers-scroll.png)

Narrow window — peers drop below the 2×2:

![Narrow status layout](status-narrow.png)

Command menu (`M` / Enter):

![Command menu](menu.png)

Plugin commands:

![Plugin commands](plugin-commands.png)

Please wait while a command runs:

![Please wait](please-wait.png)

Command output popup — **Rerun command** / **Close**:

![plugins output](command-plugins.png)

![log block 1 output](command-log-block.png)

Menu command parameters:

![Command parameters](command-params.png)

Command line (`C`) — same `neo>` prompt as neo-cli:

![Command line](command-clear.png)
Binary file added docs/neo-tui/command-clear.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/neo-tui/command-log-block.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/neo-tui/command-params.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/neo-tui/command-plugins.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/neo-tui/menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/neo-tui/please-wait.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/neo-tui/plugin-commands.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/neo-tui/status-narrow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/neo-tui/status-peers-scroll.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/neo-tui/status-wide.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
277 changes: 276 additions & 1 deletion neo-node.sln

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions plugins/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,31 @@
</None>
</ItemGroup>

<!-- Copy this plugin next to neo-cli / neo-tui binaries: {TargetDir}Plugins/{Name}/ -->
<PropertyGroup>
<CopyToNodePlugins Condition="'$(CopyToNodePlugins)' == ''">true</CopyToNodePlugins>
</PropertyGroup>

<Target Name="CopyPluginToNodeOutput" AfterTargets="Build" Condition="'$(CopyToNodePlugins)' == 'true'">
<PropertyGroup>
<_CliTargetDir>$(MSBuildThisFileDirectory)..\src\Neo.CLI\bin\$(Configuration)\$(TargetFramework)\</_CliTargetDir>
<_TuiTargetDir>$(MSBuildThisFileDirectory)..\src\Neo.ConsoleUI\bin\$(Configuration)\$(TargetFramework)\</_TuiTargetDir>
<_CliPlugins>$(_CliTargetDir)Plugins\$(MSBuildProjectName)\</_CliPlugins>
<_TuiPlugins>$(_TuiTargetDir)Plugins\$(MSBuildProjectName)\</_TuiPlugins>
</PropertyGroup>
<MakeDir Directories="$(_CliTargetDir)Plugins;$(_CliPlugins);$(_TuiTargetDir)Plugins;$(_TuiPlugins)" />
<ItemGroup>
<_AllDll Include="$(TargetDir)*.dll" />
<_PluginCopyCli Include="@(_AllDll)" Condition="'%(Filename)' == '$(MSBuildProjectName)' OR (!Exists('$(_CliTargetDir)%(Filename)%(Extension)') AND !Exists('$(MSBuildThisFileDirectory)%(Filename)\%(Filename).csproj'))" />
<_PluginCopyTui Include="@(_AllDll)" Condition="'%(Filename)' == '$(MSBuildProjectName)' OR (!Exists('$(_TuiTargetDir)%(Filename)%(Extension)') AND !Exists('$(MSBuildThisFileDirectory)%(Filename)\%(Filename).csproj'))" />
<_PluginCopyCli Include="$(TargetDir)$(MSBuildProjectName).json" Condition="Exists('$(TargetDir)$(MSBuildProjectName).json')" />
<_PluginCopyTui Include="$(TargetDir)$(MSBuildProjectName).json" Condition="Exists('$(TargetDir)$(MSBuildProjectName).json')" />
<_PluginRuntime Include="$(TargetDir)runtimes\**\*" />
</ItemGroup>
<Copy SourceFiles="@(_PluginCopyCli)" DestinationFolder="$(_CliPlugins)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_PluginCopyTui)" DestinationFolder="$(_TuiPlugins)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_PluginRuntime)" DestinationFiles="$(_CliPlugins)runtimes\%(RecursiveDir)%(Filename)%(Extension)" SkipUnchangedFiles="true" Condition="'@(_PluginRuntime)' != ''" />
<Copy SourceFiles="@(_PluginRuntime)" DestinationFiles="$(_TuiPlugins)runtimes\%(RecursiveDir)%(Filename)%(Extension)" SkipUnchangedFiles="true" Condition="'@(_PluginRuntime)' != ''" />
</Target>

</Project>
1 change: 1 addition & 0 deletions plugins/MPTTrie/MPTTrie.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<PackageId>Neo.Cryptography.MPT</PackageId>
<RootNamespace>Neo.Cryptography.MPTTrie</RootNamespace>
<Nullable>enable</Nullable>
<CopyToNodePlugins>false</CopyToNodePlugins>
</PropertyGroup>

</Project>
1 change: 1 addition & 0 deletions plugins/RpcClient/RpcClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<PropertyGroup>
<PackageId>Neo.Network.RPC.RpcClient</PackageId>
<RootNamespace>Neo.Network.RPC</RootNamespace>
<CopyToNodePlugins>false</CopyToNodePlugins>
</PropertyGroup>

<ItemGroup>
Expand Down
50 changes: 50 additions & 0 deletions src/CopyNodePlugins.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<Project>
<Target Name="CopyNodePlugins" AfterTargets="Build">
<PropertyGroup>
<_PluginRoot>$(MSBuildThisFileDirectory)..\plugins</_PluginRoot>
<_PluginOut>$(TargetDir)Plugins</_PluginOut>
</PropertyGroup>
<ItemGroup>
<_PluginAllDll Include="$(_PluginRoot)\%(NodePlugin.Identity)\bin\$(Configuration)\$(TargetFramework)\*.dll">
<PluginName>%(NodePlugin.Identity)</PluginName>
</_PluginAllDll>
<_PluginJson Include="$(_PluginRoot)\%(NodePlugin.Identity)\bin\$(Configuration)\$(TargetFramework)\%(NodePlugin.Identity).json">
<PluginName>%(NodePlugin.Identity)</PluginName>
</_PluginJson>
<_PluginRuntime Include="$(_PluginRoot)\%(NodePlugin.Identity)\bin\$(Configuration)\$(TargetFramework)\runtimes\**\*">
<PluginName>%(NodePlugin.Identity)</PluginName>
</_PluginRuntime>
</ItemGroup>
<ItemGroup>
<!-- Plugin assembly itself. -->
<_PluginOwnDll Include="@(_PluginAllDll)" Condition="Exists('%(FullPath)') AND '%(Filename)' == '%(PluginName)'" />
<!-- Extra dependency dlls: not already next to the exe, and not another plugin project. -->
<_PluginDepDll Include="@(_PluginAllDll)" Condition="Exists('%(FullPath)') AND '%(Filename)' != '%(PluginName)' AND !Exists('$(TargetDir)%(Filename)%(Extension)') AND !Exists('$(_PluginRoot)\%(Filename)\%(Filename).csproj')" />
<_PluginJsonExisting Include="@(_PluginJson)" Condition="Exists('%(FullPath)')" />
<_PluginRuntimeExisting Include="@(_PluginRuntime)" Condition="Exists('%(FullPath)')" />
</ItemGroup>
<MakeDir Directories="$(_PluginOut);@(_PluginOwnDll->'$(_PluginOut)\%(PluginName)')" />
<Copy SourceFiles="@(_PluginOwnDll)" DestinationFolder="$(_PluginOut)\%(PluginName)\" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_PluginDepDll)" DestinationFolder="$(_PluginOut)\%(PluginName)\" SkipUnchangedFiles="true" />
<!-- Plugin.Load(byte[]) resolves managed deps from the exe directory, not only Plugins/<Name>/. -->
<Copy SourceFiles="@(_PluginDepDll)" DestinationFolder="$(TargetDir)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_PluginJsonExisting)" DestinationFolder="$(_PluginOut)\%(PluginName)\" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_PluginRuntimeExisting)" DestinationFiles="$(_PluginOut)\%(PluginName)\runtimes\%(RecursiveDir)%(Filename)%(Extension)" SkipUnchangedFiles="true" />
<Copy SourceFiles="$(_PluginRoot)\MPTTrie\bin\$(Configuration)\$(TargetFramework)\MPTTrie.dll" DestinationFolder="$(_PluginOut)\StateService\" SkipUnchangedFiles="true" Condition="Exists('$(_PluginRoot)\MPTTrie\bin\$(Configuration)\$(TargetFramework)\MPTTrie.dll')" />
<PropertyGroup>
<_HostRid>$([System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier)</_HostRid>
<_CopyRid>$(_HostRid)</_CopyRid>
<_CopyRid Condition="!Exists('$(_PluginRoot)\LevelDBStore\bin\$(Configuration)\$(TargetFramework)\runtimes\$(_CopyRid)\native')">win-x64</_CopyRid>
</PropertyGroup>
<ItemGroup>
<_HostNative Include="$(_PluginRoot)\LevelDBStore\bin\$(Configuration)\$(TargetFramework)\runtimes\$(_CopyRid)\native\*.*" />
<_HostNative Include="$(_PluginRoot)\RocksDBStore\bin\$(Configuration)\$(TargetFramework)\runtimes\$(_CopyRid)\native\*.*" />
<_HostNative Include="$(_PluginRoot)\SQLiteWallet\bin\$(Configuration)\$(TargetFramework)\runtimes\$(_CopyRid)\native\*.*" />
<_HostNativeExisting Include="@(_HostNative)" Condition="Exists('%(FullPath)')" />
<_LevelDbNative Include="$(_PluginRoot)\LevelDBStore\bin\$(Configuration)\$(TargetFramework)\runtimes\**\*" Condition="Exists('$(_PluginRoot)\LevelDBStore\bin\$(Configuration)\$(TargetFramework)\runtimes')" />
</ItemGroup>
<!-- P/Invoke looks next to the exe, not in runtimes/, when LevelDBStore is loaded as a plugin. -->
<Copy SourceFiles="@(_HostNativeExisting)" DestinationFolder="$(TargetDir)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_LevelDbNative)" DestinationFiles="$(TargetDir)runtimes\%(_LevelDbNative.RecursiveDir)%(_LevelDbNative.Filename)%(_LevelDbNative.Extension)" SkipUnchangedFiles="true" Condition="'@(_LevelDbNative)' != ''" />
</Target>
</Project>
4 changes: 2 additions & 2 deletions src/Neo.CLI/CLI/MainService.Node.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ private void OnShowPoolCommand(bool verbose = false)
Console.WriteLine($"total: {NeoSystem.MemPool.Count}, verified: {verifiedCount}, unverified: {unverifiedCount}");
}

private Task CreateBroadcastTask(CancellationToken cancellationToken)
internal Task CreateBroadcastTask(CancellationToken cancellationToken)
{
return Task.Run(async () =>
{
Expand Down Expand Up @@ -633,7 +633,7 @@ private static ConsoleColor GetColorForValue(int value, int lowThreshold, int hi
return ConsoleColor.Red;
}

private uint GetMaxPeerBlockHeight()
internal uint GetMaxPeerBlockHeight()
{
var nodes = LocalNode.GetRemoteNodes().ToArray();
if (nodes.Length == 0) return 0;
Expand Down
17 changes: 17 additions & 0 deletions src/Neo.CLI/CLI/MainService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ public LocalNode LocalNode
private set => _localNode = value;
}

private readonly TaskCompletionSource _startCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously);

internal Task WhenStarted => _startCompleted.Task;

protected override string Prompt => "neo";
public override string ServiceName => "NEO-CLI";

Expand Down Expand Up @@ -329,6 +333,19 @@ void DisplayError(string primaryMessage, string? secondaryMessage = null)
}

public async void Start(CommandLineOptions options)
{
try
{
await StartCore(options).ConfigureAwait(false);
_startCompleted.TrySetResult();
Comment thread
shargon marked this conversation as resolved.
Outdated
}
catch (Exception ex)
{
_startCompleted.TrySetException(ex);
}
}

private async Task StartCore(CommandLineOptions options)
{
if (NeoSystem != null) return;
bool verifyImport = !(options.NoVerify ?? false);
Expand Down
7 changes: 7 additions & 0 deletions src/Neo.CLI/Neo.CLI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
<ProjectReference Include="..\Neo.ConsoleService\Neo.ConsoleService.csproj" />
</ItemGroup>

<Import Project="..\NodePlugins.props" />
<Import Project="..\CopyNodePlugins.targets" />

<ItemGroup>
<InternalsVisibleTo Include="neo-tui" />
</ItemGroup>

<ItemGroup>
<Content Include="neo.ico" />
</ItemGroup>
Expand Down
5 changes: 5 additions & 0 deletions src/Neo.ConsoleService/Neo.ConsoleService.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.9" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Neo.ConsoleUI" />
<InternalsVisibleTo Include="neo-tui" />
</ItemGroup>

</Project>
67 changes: 67 additions & 0 deletions src/Neo.ConsoleUI/CommandCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (C) 2015-2026 The Neo Project.
//
// CommandCatalog.cs file belongs to the neo project and is free
// software distributed under the MIT software license, see the
// accompanying file LICENSE in the main directory of the
// repository or http://www.opensource.org/licenses/mit-license.php
// for more details.
//
// Redistribution and use in source and binary forms with or without
// modifications are permitted.

using Neo.CLI;
using Neo.ConsoleService;
using Neo.Plugins;
using System.Reflection;

namespace Neo.ConsoleUI;

internal sealed record CommandInfo(
string Key,
string Category,
string Description,
MethodInfo Method,
object Instance);

internal static class CommandCatalog
{
public static IReadOnlyList<CommandInfo> Load(MainService service)
{
var list = new List<CommandInfo>();
AddCommands(list, service, service);
AddCommands(list, service, service, typeof(ConsoleServiceBase));
foreach (var plugin in Plugin.Plugins)
AddCommands(list, plugin, plugin);

return list
.OrderBy(c => c.Category, StringComparer.Ordinal)
.ThenBy(c => c.Key, StringComparer.Ordinal)
.ToArray();
}

private static void AddCommands(List<CommandInfo> list, object instance, object owner, Type? type = null)
{
type ??= instance.GetType();
foreach (var method in type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
foreach (var attribute in method.GetCustomAttributes<ConsoleCommandAttribute>(inherit: true))
{
var key = string.Join(' ', attribute.Verbs);
if (list.Any(c => c.Key == key && c.Method == method))
continue;
list.Add(new CommandInfo(
key,
string.IsNullOrWhiteSpace(attribute.Category) ? "Commands" : attribute.Category,
attribute.Description,
method,
owner));
}
}
}

public static IEnumerable<string> Categories(IReadOnlyList<CommandInfo> commands)
=> commands.Select(c => c.Category).Distinct(StringComparer.Ordinal);

public static IReadOnlyList<string> Completions(IReadOnlyList<CommandInfo> commands)
=> commands.Select(c => c.Key).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
Loading
Loading