feat: Phase 9 Serial MCP server + app icon & version title (v0.2.0)
Phase 9 (AI integration) complete -- AI agents can send/receive serial while the user watches live in the GUI, with the GUI as sole COM-port owner. - ETTerms.SerialMcp: stdio MCP server (net8.0 + ModelContextProtocol SDK) exposing serial_list / serial_attach / serial_write / serial_read (waitFor + timeoutMs) / serial_detach; forwards over named pipe. - GUI SerialBridgeServer (pipe etterms-serial) + SerialBridge endpoint; SessionPage.WriteFromAi echoes MCP-sourced TX tagged [AI] (magenta). - App icon: window title bar / taskbar / exe now use Choco_256x256.ico (embedded via AppAssets); title bar shows ETTerms Version vX.Y.Z. - About page: large Choco icon (Zoom, no crop). - Docs: ARCHITECTURE.md + CLAUDE.md mark Phase 9 done; docs/serial-mcp-guide.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ETTerms.SerialMcp</RootNamespace>
|
||||
<AssemblyName>ETTerms.SerialMcp</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
// stdio MCP server:把 ETTerms GUI 持有的 serial session 暴露給 AI(Kiro / Claude CLI)。
|
||||
// 自己不開 COM port,所有收發經 named pipe 轉給 GUI 的 SerialBridgeServer。
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// stdio 傳輸:stdout 專供 JSON-RPC,log 一律走 stderr,否則會污染協議。
|
||||
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithToolsFromAssembly();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ETTerms.SerialMcp;
|
||||
|
||||
/// <summary>
|
||||
/// 連到 ETTerms GUI 的 SerialBridgeServer(named pipe \\.\pipe\etterms-serial)。
|
||||
/// 轉發 list / attach / write / detach,背景累積 RX 供 <c>serial_read</c> 消費。
|
||||
/// 單例:整個 MCP server 生命週期共用同一條 pipe,連線狀態跨工具呼叫保留。
|
||||
/// </summary>
|
||||
public sealed class SerialBridgeClient
|
||||
{
|
||||
public static readonly SerialBridgeClient Instance = new();
|
||||
private const string PipeName = "etterms-serial";
|
||||
|
||||
private readonly SemaphoreSlim _connLock = new(1, 1);
|
||||
private readonly SemaphoreSlim _reqLock = new(1, 1);
|
||||
private readonly object _wlock = new();
|
||||
private readonly object _rxlock = new();
|
||||
private readonly StringBuilder _rx = new();
|
||||
|
||||
private StreamWriter? _writer;
|
||||
private TaskCompletionSource<JsonElement>? _pending;
|
||||
|
||||
private async Task EnsureConnected()
|
||||
{
|
||||
await _connLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_writer != null) return;
|
||||
var pipe = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
await pipe.ConnectAsync(3000);
|
||||
_writer = new StreamWriter(pipe, new UTF8Encoding(false)) { AutoFlush = true };
|
||||
_ = Task.Run(() => ReadLoop(new StreamReader(pipe, new UTF8Encoding(false)), pipe));
|
||||
}
|
||||
finally { _connLock.Release(); }
|
||||
}
|
||||
|
||||
private async Task ReadLoop(StreamReader reader, NamedPipeClientStream pipe)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
JsonElement doc;
|
||||
try { doc = JsonDocument.Parse(line).RootElement.Clone(); }
|
||||
catch { continue; }
|
||||
if (doc.TryGetProperty("op", out var op) && op.GetString() == "rx")
|
||||
{
|
||||
var d = doc.TryGetProperty("data", out var dd) ? dd.GetString() ?? "" : "";
|
||||
lock (_rxlock) _rx.Append(d);
|
||||
}
|
||||
else _pending?.TrySetResult(doc);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally { lock (_wlock) _writer = null; try { pipe.Dispose(); } catch { } }
|
||||
}
|
||||
|
||||
private async Task<string> Request(object req)
|
||||
{
|
||||
await EnsureConnected();
|
||||
await _reqLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending = tcs;
|
||||
lock (_wlock)
|
||||
{
|
||||
if (_writer == null) throw new IOException("bridge not connected; is the ETTerms GUI running?");
|
||||
_writer.WriteLine(JsonSerializer.Serialize(req));
|
||||
}
|
||||
using var cts = new CancellationTokenSource(5000);
|
||||
using var reg = cts.Token.Register(() => tcs.TrySetException(new TimeoutException("no response from ETTerms bridge")));
|
||||
return (await tcs.Task).GetRawText();
|
||||
}
|
||||
finally { _pending = null; _reqLock.Release(); }
|
||||
}
|
||||
|
||||
public Task<string> List() => Request(new { op = "list" });
|
||||
public Task<string> Attach(string portName) => Request(new { op = "attach", session = portName });
|
||||
public Task<string> Write(string text, bool newline) => Request(new { op = "write", data = text, newline });
|
||||
public Task<string> Detach() => Request(new { op = "detach" });
|
||||
|
||||
/// <summary>取出累積的 RX;可等待子字串或逾時。回傳後清空 buffer。</summary>
|
||||
public async Task<string> Read(string? waitFor, int timeoutMs)
|
||||
{
|
||||
await EnsureConnected();
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs <= 0 ? 1000 : timeoutMs);
|
||||
while (true)
|
||||
{
|
||||
string snap;
|
||||
lock (_rxlock) snap = _rx.ToString();
|
||||
bool hit = string.IsNullOrEmpty(waitFor) ? snap.Length > 0 : snap.Contains(waitFor);
|
||||
if (hit || DateTime.UtcNow >= deadline)
|
||||
{
|
||||
lock (_rxlock) _rx.Clear();
|
||||
return snap;
|
||||
}
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ETTerms.SerialMcp;
|
||||
|
||||
/// <summary>
|
||||
/// 暴露給 AI 的 serial 工具。COM port 由 ETTerms GUI 持有,這些工具經 named pipe 橋接過去;
|
||||
/// AI 的寫入會即時顯示在 GUI 終端機(標 [AI])。使用前須先在 GUI 開好該 serial 連線。
|
||||
/// </summary>
|
||||
[McpServerToolType]
|
||||
public static class SerialTools
|
||||
{
|
||||
[McpServerTool, Description("List serial sessions currently open in the ETTerms GUI (name + baud). The GUI owns the COM port.")]
|
||||
public static Task<string> serial_list() => SerialBridgeClient.Instance.List();
|
||||
|
||||
[McpServerTool, Description("Attach to a serial session already open in the ETTerms GUI by COM port name (e.g. COM3). Required before write/read. Does not open the port itself.")]
|
||||
public static Task<string> serial_attach(
|
||||
[Description("COM port name of an already-open GUI session, e.g. COM3")] string portName)
|
||||
=> SerialBridgeClient.Instance.Attach(portName);
|
||||
|
||||
[McpServerTool, Description("Send text to the attached serial session. The GUI displays it live tagged [AI].")]
|
||||
public static Task<string> serial_write(
|
||||
[Description("Text to send")] string text,
|
||||
[Description("Append the session newline (acts like pressing Enter)")] bool appendNewline = true)
|
||||
=> SerialBridgeClient.Instance.Write(text, appendNewline);
|
||||
|
||||
[McpServerTool, Description("Read output accumulated from the attached serial session. Optionally wait for a substring up to timeoutMs. Returns and clears the buffer.")]
|
||||
public static Task<string> serial_read(
|
||||
[Description("Substring to wait for; null/empty returns whatever has arrived")] string? waitFor = null,
|
||||
[Description("Max time to wait in milliseconds")] int timeoutMs = 3000)
|
||||
=> SerialBridgeClient.Instance.Read(waitFor, timeoutMs);
|
||||
|
||||
[McpServerTool, Description("Detach from the serial session. Does NOT close the GUI's port (the GUI keeps owning it).")]
|
||||
public static Task<string> serial_detach() => SerialBridgeClient.Instance.Detach();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Drawing;
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using ETTerms.Infrastructure;
|
||||
|
||||
namespace ETTerms.App;
|
||||
|
||||
@@ -54,6 +55,9 @@ public sealed class AboutView : UserControl
|
||||
p.Controls.Add(MakeLine(" TTL Script Engine (ported from MyTeraTerm)", Theme.UiFont, Theme.TextDim));
|
||||
}));
|
||||
|
||||
// Brand card — large app icon + logo
|
||||
left.Controls.Add(MakeBrandCard());
|
||||
|
||||
// ═══ RIGHT PANEL — Changelog (scrollable) ═══
|
||||
var right = new FlowLayoutPanel
|
||||
{
|
||||
@@ -110,6 +114,37 @@ public sealed class AboutView : UserControl
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>底部品牌卡:大的 Choco 圖示 + ETTerms 標誌圖。</summary>
|
||||
private static Panel MakeBrandCard()
|
||||
{
|
||||
var card = new Panel
|
||||
{
|
||||
Width = 340, Height = 300, Margin = new Padding(0, 0, 0, 12),
|
||||
BackColor = Theme.TabBack, Padding = new Padding(12)
|
||||
};
|
||||
var flow = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
|
||||
WrapContents = false, BackColor = Theme.TabBack, AutoSize = false
|
||||
};
|
||||
|
||||
// 大圖示 (256x256),等比縮放塞滿框、不裁切
|
||||
var iconImg = AppAssets.AppIcon(256);
|
||||
if (iconImg != null)
|
||||
{
|
||||
flow.Controls.Add(new PictureBox
|
||||
{
|
||||
Width = 312, Height = 268, Margin = new Padding(0, 4, 0, 4),
|
||||
SizeMode = PictureBoxSizeMode.Zoom,
|
||||
BackColor = Theme.TabBack, Image = iconImg.ToBitmap()
|
||||
});
|
||||
iconImg.Dispose();
|
||||
}
|
||||
|
||||
card.Controls.Add(flow);
|
||||
return card;
|
||||
}
|
||||
|
||||
private static Label MakeLine(string text, Font font, Color color, ContentAlignment align = ContentAlignment.MiddleLeft)
|
||||
{
|
||||
return new Label
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using ETTerms.App.Workspace;
|
||||
using ETTerms.Infrastructure;
|
||||
@@ -17,16 +18,30 @@ public partial class MainForm : Form
|
||||
private readonly AboutView _about = new();
|
||||
private readonly StatusStrip _status = new();
|
||||
private readonly ToolStripStatusLabel _statusLabel = new();
|
||||
private readonly Sessions.SerialBridgeServer _bridge = new();
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
InitializeComponent();
|
||||
ApplyIconAndTitle();
|
||||
BuildLayout();
|
||||
WireEvents();
|
||||
AppSettings.Instance.ApplyWindowPosition(this);
|
||||
_bridge.Start(); // 本機 named pipe,供 ETTerms.SerialMcp 橋接 serial
|
||||
AppLogger.Info("MainForm initialized");
|
||||
}
|
||||
|
||||
/// <summary>設定視窗 / 工作列圖示,並在標題列加上版本號。</summary>
|
||||
private void ApplyIconAndTitle()
|
||||
{
|
||||
var icon = AppAssets.AppIcon();
|
||||
if (icon != null) Icon = icon;
|
||||
|
||||
var v = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var versionStr = v != null ? $"{v.Major}.{v.Minor}.{v.Build}" : "0.1.0";
|
||||
Text = $"ETTerms Version v{versionStr}";
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
Controls.Add(_workspace); // Fill
|
||||
@@ -74,6 +89,7 @@ public partial class MainForm : Form
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||
{
|
||||
_bridge.Dispose();
|
||||
AppSettings.Instance.SaveWindowPosition(this);
|
||||
AppLogger.LogApplicationClose();
|
||||
base.OnFormClosed(e);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -10,11 +10,19 @@
|
||||
<AssemblyName>ETTerms</AssemblyName>
|
||||
|
||||
<!-- 版本資訊 -->
|
||||
<Version>0.1.3</Version>
|
||||
<Version>0.2.0</Version>
|
||||
<Product>ETTerms</Product>
|
||||
<Company>ETTerms Project</Company>
|
||||
|
||||
<!-- 視窗 / 工作列 / exe 圖示 -->
|
||||
<ApplicationIcon>Assets\Choco_256x256.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 視窗圖示 (title bar / taskbar / About 大圖),內嵌進組件 -->
|
||||
<EmbeddedResource Include="Assets\Choco_256x256.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
|
||||
<PackageReference Include="SnmpSharpNet" Version="0.9.7">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Drawing;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ETTerms.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// 存取內嵌進組件的圖示(見 ETTerms.csproj 的 EmbeddedResource)。
|
||||
/// Manifest 名稱規則:RootNamespace.資料夾.檔名 → ETTerms.Assets.xxx。
|
||||
/// </summary>
|
||||
public static class AppAssets
|
||||
{
|
||||
private const string IconResource = "ETTerms.Assets.Choco_256x256.ico";
|
||||
|
||||
/// <summary>視窗 / 工作列圖示。找不到資源時回傳 null。</summary>
|
||||
public static Icon? AppIcon() => LoadIcon(IconResource);
|
||||
|
||||
/// <summary>Choco 圖示,指定尺寸(About 用大圖)。</summary>
|
||||
public static Icon? AppIcon(int size)
|
||||
{
|
||||
var icon = LoadIcon(IconResource);
|
||||
if (icon == null) return null;
|
||||
var sized = new Icon(icon, new Size(size, size));
|
||||
icon.Dispose();
|
||||
return sized;
|
||||
}
|
||||
|
||||
private static Icon? LoadIcon(string name)
|
||||
{
|
||||
using var s = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
|
||||
return s == null ? null : new Icon(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ETTerms.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// 一個可被 MCP 橋接的 serial session 端點:提供寫入(含 [AI] echo 回 GUI)與 RX 轉發。
|
||||
/// 由 <see cref="SessionPage"/> 建立並註冊到 <see cref="SerialBridge"/>。
|
||||
/// </summary>
|
||||
public sealed class SerialBridgeEndpoint
|
||||
{
|
||||
private readonly Action<string, bool> _write;
|
||||
|
||||
public string Name { get; }
|
||||
public int BaudRate { get; }
|
||||
|
||||
/// <summary>實體 port 收到的資料(背景緒觸發),由 pipe server 廣播給 MCP client。</summary>
|
||||
public event Action<byte[]>? Rx;
|
||||
|
||||
public SerialBridgeEndpoint(string name, int baudRate, Action<string, bool> write)
|
||||
{
|
||||
Name = name;
|
||||
BaudRate = baudRate;
|
||||
_write = write;
|
||||
}
|
||||
|
||||
public void Write(string text, bool appendNewline) => _write(text, appendNewline);
|
||||
|
||||
public void FeedRx(byte[] data) => Rx?.Invoke(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GUI 目前開著、可供 MCP 橋接的 serial session 登錄表。port 永遠由 GUI 持有,
|
||||
/// MCP(ETTerms.SerialMcp)經 named pipe attach 到這裡的端點來收發。
|
||||
/// </summary>
|
||||
public static class SerialBridge
|
||||
{
|
||||
private static readonly object _lock = new();
|
||||
private static readonly List<SerialBridgeEndpoint> _eps = new();
|
||||
|
||||
public static void Register(SerialBridgeEndpoint ep) { lock (_lock) _eps.Add(ep); }
|
||||
public static void Unregister(SerialBridgeEndpoint ep) { lock (_lock) _eps.Remove(ep); }
|
||||
public static SerialBridgeEndpoint[] All { get { lock (_lock) return _eps.ToArray(); } }
|
||||
|
||||
public static SerialBridgeEndpoint? Find(string name) =>
|
||||
All.FirstOrDefault(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ETTerms.Infrastructure;
|
||||
|
||||
namespace ETTerms.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// 本機 named pipe server:把 GUI 持有的 serial session 橋接給 MCP client(ETTerms.SerialMcp)。
|
||||
/// 協議=換行分隔 JSON(list / attach / write / detach),RX 由本端主動以 {"op":"rx"} 推送。
|
||||
/// 一次服務一個 client(單一 AI agent 已足夠),斷線後自動回到等待下一個連線。
|
||||
/// </summary>
|
||||
public sealed class SerialBridgeServer : IDisposable
|
||||
{
|
||||
public const string PipeName = "etterms-serial";
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_loop != null) return;
|
||||
_loop = Task.Run(AcceptLoop);
|
||||
AppLogger.Info($"SerialBridge server started on pipe \\\\.\\pipe\\{PipeName}");
|
||||
}
|
||||
|
||||
private async Task AcceptLoop()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pipe = new NamedPipeServerStream(PipeName, PipeDirection.InOut, 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||
await pipe.WaitForConnectionAsync(_cts.Token);
|
||||
await ServeClient(pipe);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { AppLogger.LogWarning($"SerialBridge accept error: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeClient(NamedPipeServerStream pipe)
|
||||
{
|
||||
using (pipe)
|
||||
{
|
||||
var writer = new StreamWriter(pipe, new UTF8Encoding(false)) { AutoFlush = true };
|
||||
var reader = new StreamReader(pipe, new UTF8Encoding(false));
|
||||
var wlock = new object();
|
||||
void Send(object o) { lock (wlock) { try { writer.WriteLine(JsonSerializer.Serialize(o)); } catch { } } }
|
||||
|
||||
SerialBridgeEndpoint? attached = null;
|
||||
Action<byte[]>? rx = null;
|
||||
void Detach() { if (attached != null && rx != null) attached.Rx -= rx; attached = null; rx = null; }
|
||||
|
||||
try
|
||||
{
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
Req? req;
|
||||
try { req = JsonSerializer.Deserialize<Req>(line); } catch { continue; }
|
||||
if (req == null) continue;
|
||||
|
||||
switch (req.op)
|
||||
{
|
||||
case "list":
|
||||
Send(new { sessions = SerialBridge.All.Select(e => new { name = e.Name, baud = e.BaudRate }) });
|
||||
break;
|
||||
case "attach":
|
||||
Detach();
|
||||
attached = SerialBridge.Find(req.session ?? "");
|
||||
if (attached == null) { Send(new { ok = false, error = $"no open serial session '{req.session}' in GUI" }); break; }
|
||||
rx = data => Send(new { op = "rx", data = Encoding.UTF8.GetString(data) });
|
||||
attached.Rx += rx;
|
||||
Send(new { ok = true, name = attached.Name });
|
||||
break;
|
||||
case "write":
|
||||
if (attached == null) { Send(new { ok = false, error = "not attached" }); break; }
|
||||
attached.Write(req.data ?? "", req.newline);
|
||||
Send(new { ok = true });
|
||||
break;
|
||||
case "detach":
|
||||
Detach();
|
||||
Send(new { ok = true });
|
||||
break;
|
||||
default:
|
||||
Send(new { ok = false, error = $"unknown op '{req.op}'" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { AppLogger.LogWarning($"SerialBridge client error: {ex.Message}"); }
|
||||
finally { Detach(); }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Req
|
||||
{
|
||||
public string? op { get; set; }
|
||||
public string? session { get; set; }
|
||||
public string? data { get; set; }
|
||||
public bool newline { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _cts.Cancel(); } catch { }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ public sealed class SerialChannel : ISessionChannel
|
||||
|
||||
public string LogName => _portName;
|
||||
|
||||
/// <summary>目前傳輸速率(供 MCP 橋接列出 session 用)。</summary>
|
||||
public int BaudRate => _port.BaudRate;
|
||||
|
||||
/// <summary>送出換行序列(AI 寫入時附加,與手打 Enter 一致)。</summary>
|
||||
public string NewLine => _port.NewLine;
|
||||
|
||||
public SerialChannel(SerialSettings s)
|
||||
{
|
||||
_portName = s.PortName;
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class SessionPage : UserControl
|
||||
private readonly Button _run, _stop, _log;
|
||||
private bool _started;
|
||||
private SessionLogger? _logger;
|
||||
private SerialBridgeEndpoint? _bridge; // Serial 才有:供 MCP 橋接
|
||||
|
||||
/// <summary>底層連線通道。</summary>
|
||||
public ISessionChannel Channel => _channel;
|
||||
@@ -216,6 +217,11 @@ public sealed class SessionPage : UserControl
|
||||
{
|
||||
_channel.Open();
|
||||
SessionManager.Register(_channel);
|
||||
if (_channel is SerialChannel sc)
|
||||
{
|
||||
_bridge = new SerialBridgeEndpoint(sc.LogName, sc.BaudRate, WriteFromAi);
|
||||
SerialBridge.Register(_bridge);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -228,17 +234,28 @@ public sealed class SessionPage : UserControl
|
||||
{
|
||||
// 側錄不依賴 UI thread,直接在背景緒寫檔(SessionLogger 內部自鎖)。
|
||||
_logger?.Write(data);
|
||||
_bridge?.FeedRx(data); // 轉發給 MCP(若有 attach)
|
||||
if (IsDisposed || !IsHandleCreated) return;
|
||||
if (InvokeRequired) BeginInvoke(() => _term.Feed(data));
|
||||
else _term.Feed(data);
|
||||
}
|
||||
|
||||
/// <summary>MCP 來源寫入:送出實體 port,並把內容以 [AI] 標色 echo 回終端機,使用者即時可見。</summary>
|
||||
private void WriteFromAi(string text, bool appendNewline)
|
||||
{
|
||||
if (_channel is not SerialChannel sc) return;
|
||||
_channel.Write(System.Text.Encoding.UTF8.GetBytes(appendNewline ? text + sc.NewLine : text));
|
||||
var echo = System.Text.Encoding.UTF8.GetBytes($"\x1b[35m[AI]\x1b[0m {text}\r\n");
|
||||
Ui(() => _term.Feed(echo));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && _started)
|
||||
{
|
||||
_runner.Cancel();
|
||||
_channel.DataReceived -= OnDataReceived;
|
||||
if (_bridge != null) { SerialBridge.Unregister(_bridge); _bridge = null; }
|
||||
_logger?.Dispose();
|
||||
_logger = null;
|
||||
SessionManager.Unregister(_channel);
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Drawing;
|
||||
namespace ETTerms.Terminal;
|
||||
|
||||
[Flags]
|
||||
public enum CellAttr : byte { None = 0, Bold = 1, Underline = 2, Inverse = 4 }
|
||||
public enum CellAttr : byte { None = 0, Bold = 1, Underline = 2, Inverse = 4, Wide = 8, WideTrail = 16 }
|
||||
|
||||
public struct Cell
|
||||
{
|
||||
@@ -80,11 +80,37 @@ public sealed class ScreenBuffer
|
||||
// ── 輸出字元 ─────────────────────────────────────────────
|
||||
public void Print(char ch)
|
||||
{
|
||||
int w = CharWidth(ch);
|
||||
if (_wrapPending) { _wrapPending = false; CursorCol = 0; LineFeed(); }
|
||||
if (CursorRow < 0 || CursorRow >= Rows) CursorRow = Math.Clamp(CursorRow, 0, Rows - 1);
|
||||
_screen[CursorRow][CursorCol] = new Cell { Ch = ch, Fg = PenFg, Bg = PenBg, Attr = PenAttr };
|
||||
if (CursorCol >= Cols - 1) { if (AutoWrap) _wrapPending = true; }
|
||||
else CursorCol++;
|
||||
|
||||
// 寬字(全形 CJK)占 2 格;行尾剩 1 格放不下 → 先換行
|
||||
if (w == 2 && CursorCol == Cols - 1) { CursorCol = 0; LineFeed(); }
|
||||
|
||||
_screen[CursorRow][CursorCol] = new Cell { Ch = ch, Fg = PenFg, Bg = PenBg, Attr = w == 2 ? PenAttr | CellAttr.Wide : PenAttr };
|
||||
if (w == 2 && CursorCol + 1 < Cols)
|
||||
_screen[CursorRow][CursorCol + 1] = new Cell { Ch = '\0', Fg = PenFg, Bg = PenBg, Attr = PenAttr | CellAttr.WideTrail };
|
||||
|
||||
if (CursorCol + w >= Cols) { if (AutoWrap) _wrapPending = true; else CursorCol = Cols - 1; }
|
||||
else CursorCol += w;
|
||||
}
|
||||
|
||||
/// <summary>東亞全形 / 寬字回傳 2,其餘 1(BMP 範圍,足夠涵蓋常見中日韓)。</summary>
|
||||
private static int CharWidth(char ch)
|
||||
{
|
||||
if (ch < 0x1100) return 1;
|
||||
return (ch <= 0x115F) // Hangul Jamo
|
||||
|| (ch >= 0x2E80 && ch <= 0x303E) // CJK 部首 / 康熙 / 符號
|
||||
|| (ch >= 0x3041 && ch <= 0x33FF) // 平假名 / 片假名 / CJK 符號
|
||||
|| (ch >= 0x3400 && ch <= 0x4DBF) // CJK 擴充 A
|
||||
|| (ch >= 0x4E00 && ch <= 0x9FFF) // CJK 基本
|
||||
|| (ch >= 0xA000 && ch <= 0xA4CF) // 彝文
|
||||
|| (ch >= 0xAC00 && ch <= 0xD7A3) // 韓文音節
|
||||
|| (ch >= 0xF900 && ch <= 0xFAFF) // CJK 相容表意
|
||||
|| (ch >= 0xFE30 && ch <= 0xFE4F) // CJK 相容形式
|
||||
|| (ch >= 0xFF00 && ch <= 0xFF60) // 全形 ASCII
|
||||
|| (ch >= 0xFFE0 && ch <= 0xFFE6) // 全形符號
|
||||
? 2 : 1;
|
||||
}
|
||||
|
||||
// ── 游標 / 換行 ──────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Windows.Forms;
|
||||
|
||||
@@ -30,12 +31,13 @@ public sealed class TerminalView : UserControl
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
|
||||
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Color.FromArgb(18, 18, 22);
|
||||
ImeMode = ImeMode.NoControl; // 容器控制項預設關 IME,這裡明確開啟讓使用者可切中文
|
||||
_font = new Font(profile.FontFamily, profile.FontSize);
|
||||
using (var g = CreateGraphics())
|
||||
{
|
||||
var sz = TextRenderer.MeasureText(g, "W", _font, Size.Empty, TextFormatFlags.NoPadding);
|
||||
_cellW = Math.Max(1, sz.Width);
|
||||
_cellH = Math.Max(1, _font.Height);
|
||||
_cellH = Math.Max(1, _font.Height + 2); // +2 行距,避免 g/y 下緣與中文底部被裁切
|
||||
}
|
||||
_buf = new ScreenBuffer(profile.Cols, profile.Rows,
|
||||
Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines);
|
||||
@@ -90,13 +92,26 @@ public sealed class TerminalView : UserControl
|
||||
while (c < line.Length)
|
||||
{
|
||||
var cell = line[c];
|
||||
if ((cell.Attr & CellAttr.WideTrail) != 0) { c++; continue; } // 寬字第二格,由前格覆蓋
|
||||
ResolveColors(cell, out var fg, out var bg, abs, c);
|
||||
// 合併同屬性連續格
|
||||
|
||||
// 寬字(全形 CJK):單獨繪製,占 2 格寬
|
||||
if ((cell.Attr & CellAttr.Wide) != 0)
|
||||
{
|
||||
var wr = new Rectangle(c * _cellW, y, _cellW * 2, _cellH);
|
||||
using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, wr);
|
||||
DrawRun(g, cell.Ch == '\0' ? " " : cell.Ch.ToString(), cell.Attr, wr, fg);
|
||||
c++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 合併同屬性連續的窄字
|
||||
int start = c;
|
||||
var sb = new StringBuilder();
|
||||
while (c < line.Length)
|
||||
{
|
||||
var cur = line[c];
|
||||
if ((cur.Attr & (CellAttr.Wide | CellAttr.WideTrail)) != 0) break;
|
||||
ResolveColors(cur, out var f2, out var b2, abs, c);
|
||||
if (f2 != fg || b2 != bg || (cur.Attr & CellAttr.Bold) != (cell.Attr & CellAttr.Bold)) break;
|
||||
sb.Append(cur.Ch == '\0' ? ' ' : cur.Ch);
|
||||
@@ -104,14 +119,19 @@ public sealed class TerminalView : UserControl
|
||||
}
|
||||
var rect = new Rectangle(start * _cellW, y, (c - start) * _cellW, _cellH);
|
||||
using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, rect);
|
||||
var style = (cell.Attr & CellAttr.Bold) != 0 ? FontStyle.Bold : FontStyle.Regular;
|
||||
if ((cell.Attr & CellAttr.Underline) != 0) style |= FontStyle.Underline;
|
||||
using var fnt = style == FontStyle.Regular ? _font : new Font(_font, style);
|
||||
TextRenderer.DrawText(g, sb.ToString(), fnt, rect, fg,
|
||||
TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left);
|
||||
DrawRun(g, sb.ToString(), cell.Attr, rect, fg);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawRun(Graphics g, string text, CellAttr attr, Rectangle rect, Color fg)
|
||||
{
|
||||
var style = (attr & CellAttr.Bold) != 0 ? FontStyle.Bold : FontStyle.Regular;
|
||||
if ((attr & CellAttr.Underline) != 0) style |= FontStyle.Underline;
|
||||
using var fnt = style == FontStyle.Regular ? _font : new Font(_font, style);
|
||||
TextRenderer.DrawText(g, text, fnt, rect, fg,
|
||||
TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left);
|
||||
}
|
||||
|
||||
private void ResolveColors(Cell cell, out Color fg, out Color bg, int abs, int col)
|
||||
{
|
||||
fg = cell.Fg.A == 0 ? _buf.DefaultFg : cell.Fg;
|
||||
@@ -223,7 +243,10 @@ public sealed class TerminalView : UserControl
|
||||
int from = abs == a.row ? a.col : 0;
|
||||
int to = abs == b.row ? b.col : line.Length;
|
||||
for (int c = from; c < Math.Min(to, line.Length); c++)
|
||||
{
|
||||
if ((line[c].Attr & CellAttr.WideTrail) != 0) continue;
|
||||
sb.Append(line[c].Ch == '\0' ? ' ' : line[c].Ch);
|
||||
}
|
||||
if (abs < b.row) sb.Append("\r\n");
|
||||
}
|
||||
var text = sb.ToString();
|
||||
@@ -239,4 +262,83 @@ public sealed class TerminalView : UserControl
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ── IME(中文 / 日文 / 韓文輸入)─────────────────────────
|
||||
// 自繪控制項預設不處理 IME 組字,故攔截 WM_IME_COMPOSITION 取「結果字串」直接送出 UTF-8。
|
||||
private const int WM_IME_STARTCOMPOSITION = 0x010D;
|
||||
private const int WM_IME_COMPOSITION = 0x010F;
|
||||
private const int GCS_RESULTSTR = 0x0800;
|
||||
private const int CFS_POINT = 0x0002;
|
||||
private const int IACE_DEFAULT = 0x0010;
|
||||
|
||||
// UserControl(容器)預設不啟用 IME,使用者無法切中文。把預設 IME context 綁回此視窗,
|
||||
// 並在取得焦點後(WinForms 可能於 OnGotFocus 內關掉)再綁一次,確保可切換輸入法。
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
ImmAssociateContextEx(Handle, IntPtr.Zero, IACE_DEFAULT);
|
||||
}
|
||||
|
||||
protected override void OnGotFocus(EventArgs e)
|
||||
{
|
||||
base.OnGotFocus(e);
|
||||
ImmAssociateContextEx(Handle, IntPtr.Zero, IACE_DEFAULT);
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
if (m.Msg == WM_IME_STARTCOMPOSITION) MoveImeWindowToCursor();
|
||||
else if (m.Msg == WM_IME_COMPOSITION && ((long)m.LParam & GCS_RESULTSTR) != 0)
|
||||
{
|
||||
var s = ReadImeResult();
|
||||
if (!string.IsNullOrEmpty(s))
|
||||
{
|
||||
SendData?.Invoke(Encoding.UTF8.GetBytes(s));
|
||||
return; // 消費此訊息,避免預設再轉成 WM_CHAR 造成重複輸入
|
||||
}
|
||||
}
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
private string ReadImeResult()
|
||||
{
|
||||
IntPtr hImc = ImmGetContext(Handle);
|
||||
if (hImc == IntPtr.Zero) return "";
|
||||
try
|
||||
{
|
||||
int len = ImmGetCompositionStringW(hImc, GCS_RESULTSTR, null, 0);
|
||||
if (len <= 0) return "";
|
||||
var buf = new byte[len];
|
||||
ImmGetCompositionStringW(hImc, GCS_RESULTSTR, buf, len);
|
||||
return Encoding.Unicode.GetString(buf);
|
||||
}
|
||||
finally { ImmReleaseContext(Handle, hImc); }
|
||||
}
|
||||
|
||||
private void MoveImeWindowToCursor()
|
||||
{
|
||||
IntPtr hImc = ImmGetContext(Handle);
|
||||
if (hImc == IntPtr.Zero) return;
|
||||
try
|
||||
{
|
||||
int vr = _buf.CursorRow + _scrollOffset;
|
||||
var cf = new COMPOSITIONFORM
|
||||
{
|
||||
dwStyle = CFS_POINT,
|
||||
ptCurrentPos = new POINT { x = _buf.CursorCol * _cellW, y = vr * _cellH }
|
||||
};
|
||||
ImmSetCompositionWindow(hImc, ref cf);
|
||||
}
|
||||
finally { ImmReleaseContext(Handle, hImc); }
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int x, y; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct RECT { public int left, top, right, bottom; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct COMPOSITIONFORM { public int dwStyle; public POINT ptCurrentPos; public RECT rcArea; }
|
||||
|
||||
[DllImport("imm32.dll")] private static extern IntPtr ImmGetContext(IntPtr hWnd);
|
||||
[DllImport("imm32.dll")] private static extern bool ImmAssociateContextEx(IntPtr hWnd, IntPtr hIMC, int dwFlags);
|
||||
[DllImport("imm32.dll")] private static extern bool ImmReleaseContext(IntPtr hWnd, IntPtr hIMC);
|
||||
[DllImport("imm32.dll", CharSet = CharSet.Unicode)] private static extern int ImmGetCompositionStringW(IntPtr hIMC, int dwIndex, byte[]? lpBuf, int dwBufLen);
|
||||
[DllImport("imm32.dll")] private static extern bool ImmSetCompositionWindow(IntPtr hIMC, ref COMPOSITIONFORM lpCompForm);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user