18 Commits
Author SHA1 Message Date
etwen 9628609eea merge: keep terminal focus after activity (v0.3.3) 2026-06-16 16:24:46 +08:00
etwenandClaude Opus 4.8 fc69adfae4 fix(terminal): keep keyboard focus on terminal after activity (v0.3.3)
The terminal's DarkScrollBar derives from Control, which is selectable by
default. Once enough output accumulated to create scrollback, the scrollbar
became enabled and was the only selectable child of the TerminalView
(a ContainerControl). On window re-activation (minimize/restore, click away
and back) WinForms restored focus down the container chain onto the scrollbar,
which has no keyboard handling, so typing/Enter went nowhere until the session
was reopened. This affected both Serial and PowerShell tabs (shared TerminalView),
and only after output appeared — matching the reported symptom.

Make the scrollbar mouse-only: SetStyle(Selectable, false) + TabStop=false, so
it never takes keyboard focus and the TerminalView keeps focus itself, exactly
as it already did when idle (scrollbar disabled).

Bump version to 0.3.3 and add the About changelog entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:24:37 +08:00
etwen 260f5b57d5 merge: Status page with PDU outlet control buttons (v0.3.2) 2026-06-11 09:03:41 +08:00
etwen 1263514586 feat(status): add Status page with PDU outlet on/off buttons (v0.3.2)
- New left-rail Status view (StatusView.cs) hosting the PDU tab; PDU panel moved out of SettingsView and auto-polls all 12 outlets every 3s on a background thread (no manual Refresh).

- Each outlet row gets a Control button (DataGridViewButtonColumn) that toggles the port via SNMP off the UI thread, re-reads after ~400ms, and resets the grid on disconnect.

- fix(shell): ShellChannel falls back to the user home folder when StartupDirectory no longer exists (avoids CreateProcess 267).

- docs: update ARCHITECTURE / CLAUDE / README / README.zh-TW and About changelog; bump version to 0.3.2.
2026-06-11 09:03:32 +08:00
etwen 9addc5f2d9 docs(readme): update to v0.3.1; add v0.2.2 / v0.3.0 / v0.3.1 history & terminal feature notes 2026-06-08 16:14:03 +08:00
etwen a9050e31ea merge: terminal scrollbar, paste & copy fixes (v0.3.1) 2026-06-08 16:09:48 +08:00
etwen 4bc15ff529 fix(terminal): dark scrollbar, bracketed paste, clear selection on copy (v0.3.1)
- Add owner-drawn DarkScrollBar (slim, dark, rounded thumb) to TerminalView; content width excludes the bar so text isn't covered; synced on feed/wheel/resize.
- Support bracketed paste (DEC mode 2004): wrap multi-line paste in ESC[200~..ESC[201~ when the app enables it (PSReadLine/Kiro CLI), so pasted lines aren't each submitted immediately.
- Clear text selection after right-click copy so the user knows it worked.
- Bump version to 0.3.1; rewrite v0.3.0 changelog in plain language and add v0.3.1 entry; update ARCHITECTURE.md / CLAUDE.md.
2026-06-08 16:09:38 +08:00
etwen 6a85a48e6d merge: PDU MCP server (v0.3.0) 2026-06-08 10:12:24 +08:00
etwen 02f4ac49e8 feat(pdu): add PDU MCP server for AI-driven SNMP power control
New ETTerms.PduMcp stdio MCP server lets AI agents (Kiro/Claude CLI)
control SNMP PDU outlets directly. Unlike serial, SNMP is non-exclusive
so it talks to the PDU directly without bridging through the GUI.

Tools: pdu_connect / pdu_list / pdu_set_port / pdu_get_port / pdu_status
/ pdu_power_cycle / pdu_disconnect.

- McpRegistrar now registers both etterms-serial and etterms-pdu in one click
- Settings -> AI MCP shows both server paths
- publish target (PublishMcpServers) bundles both MCP servers
- bump version to 0.3.0, update About changelog, ARCHITECTURE.md, CLAUDE.md
2026-06-08 10:12:15 +08:00
etwen 89ec175669 docs: credit KKTerm (by ryantsai, MIT) in Acknowledgments 2026-06-05 13:54:29 +08:00
etwen 776509cc6c merge: high-DPI scaling & layout fixes (v0.2.2) 2026-06-05 13:33:33 +08:00
etwen a66d2a19e4 fix(ui): high-DPI scaling and layout clipping (v0.2.2)
Switch to AutoScaleMode.Dpi (PerMonitorV2) so control sizes scale by the exact DPI ratio, matching point-font rendering and eliminating text/button clipping at 125%/150%. Make toolbar, settings, sidebar buttons and About labels AutoSize; align left tab bar with workspace toolbar (both 40px); About cards now auto-grow and the left column scrolls. ActivityRail cell size is DPI-aware. No version bump.
2026-06-05 13:33:25 +08:00
etwen 68b727f38a merge: terminal resize/Shift+Enter bugfixes (v0.2.2) 2026-06-05 11:02:45 +08:00
etwen e5c10afd94 fix(terminal): stop minimize/switch from freezing ConPTY; add Shift+Enter newline (v0.2.2)
OnSizeChanged now ignores degenerate sizes (minimized / smaller than one cell), so ConPTY no longer receives a 1x1 size that collapsed full-screen TUIs like Kiro CLI. ScreenBuffer.Resize reflows both the live and saved alt-screen buffers instead of discarding the main-screen backup. TerminalInput.Map sends LF for Shift+Enter (CR for plain Enter) so the shell inserts a newline for multi-line input. Bump version to 0.2.2 and add About changelog entry.
2026-06-05 11:02:39 +08:00
etwen fc97e04846 build: add self-contained portable publish variant (v0.2.1)
PublishSerialMcp target now inherits the GUI's SelfContained setting, so a --self-contained true publish also produces a runtime-free ETTerms.SerialMcp. Document both publish variants in ARCHITECTURE.md / CLAUDE.md: framework-dependent ETTerms_v{Version}\ and portable ETTerms_v{Version}_portable\ (no .NET runtime install needed).
2026-06-05 09:33:24 +08:00
etwen 2ac260489f merge: AI MCP one-click setup (v0.2.1) 2026-06-05 08:58:20 +08:00
etwen 7ba3fadb87 feat: AI MCP one-click setup in Settings (v0.2.1)
Add Settings -> AI MCP tab with McpRegistrar to one-click register/remove the Serial MCP server into Claude Code (~/.claude.json) and Kiro (~/.kiro/settings/mcp.json); read-modify-write preserves other servers, atomic write-back. Each card shows a CLI verify command.

Add PublishSerialMcp MSBuild target so a single GUI publish auto-bundles ETTerms.SerialMcp into the \ETTerms.SerialMcp\ subfolder, aligning with McpRegistrar.ResolveServerExe(). Bump version to 0.2.1. Update ARCHITECTURE/CLAUDE/README/README.zh-TW and About changelog.
2026-06-05 08:58:02 +08:00
etwenandClaude Opus 4.8 10ec900386 docs: add v0.2.0 changelog & publish convention; drop "Version" label from title
- About: add v0.2.0 "Beta Version Release" changelog entry.
- MainForm: title bar now shows "ETTerms  vX.Y.Z" (drop the word "Version").
- ARCHITECTURE.md + CLAUDE.md: document the publish convention --
  output to src\ETTerms\Publish\ETTerms_v{Version}\, main exe renamed to
  "ETTerms v{Version}.exe", ETTerms.SerialMcp published into a subfolder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:39:38 +08:00
26 changed files with 1609 additions and 206 deletions
+116 -8
View File
@@ -32,7 +32,7 @@ ETTerms 是一個給工程師 / 韌體 / 硬體驗證人員用的**單一視窗
| 祕密儲存 | **Windows Credential Manager**DPAPI / CredMan | 連線密碼、SSH key passphrase,不落地明碼 |
| PDU 控制(選用) | **SnmpSharpNet** | 沿用 MyTeraTerm PDU 控制(`pductrl` / `pduconnect` |
| 日誌 | 自製 **AppLogger**(從 MyTeraTerm 移植) | 檔案 + Debug 雙輸出 |
| AI / MCP 整合(選用) | **stdio MCP server**(官方 C# SDK `ModelContextProtocol` | 獨立行程,但不自己開 port——經本機 named pipe 橋接 GUI 持有的 serial session,把收發暴露成 AI 可呼叫工具(Kiro CLI / Claude CLI),見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp-server) |
| AI / MCP 整合(選用) | **stdio MCP server**(官方 C# SDK `ModelContextProtocol` | 兩個獨立 server`ETTerms.SerialMcp`不自己開 port經本機 named pipe 橋接 GUI 持有的 serial session)與 `ETTerms.PduMcp`(直接打 SNMP 控制 PDU 插座,不需 GUI);把收發 / 電源控制暴露成 AI 可呼叫工具(Kiro CLI / Claude CLI),見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp--pdu-mcp-server) |
| 打包 | `dotnet publish` + (選用)Inno Setup / MSIX | 單機安裝,current-user |
> **與舊版 MyTeraTerm 的關鍵差異:** 舊版是把真正的 `ttermpro.exe`TeraTerm)嵌進 Panel,靠 **com0com 虛擬 COM 對**攔截 serial 來跑腳本。ETTerms 改走**全原生**SSH.NET 做 SSH、`System.IO.Ports` 做 serial、自繪 VT100 控制項做終端機畫面,**不再依賴外部 TeraTerm exe,也不再需要 com0com**。腳本引擎從「驅動 com0com bridge」改成「驅動原生 `ISessionChannel`」。
@@ -118,8 +118,11 @@ ETTerms/
│ ├── MainForm.cs # 主視窗:Rail + Sidebar + Workspace(含深色標題列 DWM
│ ├── MainForm.Designer.cs
│ ├── Theme.cs # 全域深色配色 (KKTerm 風格)
│ ├── ActivityRail.cs # 左側圖示列 (Terminal/Scripts/Settings)
│ ├── ActivityRail.cs # 左側圖示列 (Terminal/Status/Settings/About)
│ ├── ConnectionSidebar.cs# 仿 KKTerm 可編輯資料夾樹(搜尋/CRUD/拖曳分類)
│ ├── StatusView.cs # Status 檢視:分頁式(PDU…),PDU 連線後每 3 秒背景輪詢插座狀態,並可用表格內 Control 鈕直接開關各 Port
│ ├── SettingsView.cs # Settings 檢視:分頁式(Terminal / AI MCP
│ ├── AboutView.cs # About 檢視(版本 / 連結)
│ ├── Dialogs/ # ── 深色對話框 ──
│ │ ├── DarkDialog.cs # 對話框基底(深色 + DWM 標題列)
│ │ ├── TextPromptDialog.cs # 單行輸入(資料夾命名 / 改名)
@@ -131,7 +134,8 @@ ETTerms/
├── Terminal/ # ── 終端機渲染 ──
│ ├── TerminalView.cs # 自繪 VT100 控制項 (owner-drawn)
│ ├── AnsiParser.cs # ANSI/VT100 escape 解析狀態機
│ ├── DarkScrollBar.cs # 自繪深色垂直捲軸 (細長/圓角滑塊, 配深色主題)
│ ├── AnsiParser.cs # ANSI/VT100 escape 解析狀態機 (含 DEC 2004 bracketed paste)
│ ├── ScreenBuffer.cs # 字格緩衝 (rows×cols, 屬性/顏色)
│ └── TerminalInput.cs # 鍵盤 → byte 序列 (含特殊鍵)
@@ -139,7 +143,7 @@ ETTerms/
│ ├── ISessionChannel.cs # Write(byte[]) + event DataReceived
│ ├── SshChannel.cs # SSH.NET 實作 (ShellStream)
│ ├── SerialChannel.cs # System.IO.Ports 實作
│ ├── ShellChannel.cs # Windows ConPTY 本機 Shell
│ ├── ShellChannel.cs # Windows ConPTY 本機 ShellStartupDirectory 不存在時 fallback 使用者家目錄)
│ ├── SessionPage.cs # 一個分頁 = TerminalView + Channel + 狀態
│ ├── SessionManager.cs # 開 / 關 / 列舉所有 active session
│ ├── SerialBridgeServer.cs# ✅ 本機 named pipe server:把 serial session 的讀寫橋接給 MCPPhase 9
@@ -160,6 +164,7 @@ ETTerms/
└── Infrastructure/
├── AppLogger.cs # 日誌 (port 自 MyTeraTerm)
├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json)
├── McpRegistrar.cs # ✅ 一鍵把 Serial MCP 註冊/移除到 Claude Code / Kiro 設定檔(Settings → AI MCP
└── NativeTheme.cs # 深色標題列 (DWM)
└── ETTerms.SerialMcp/ # ✅ Serial MCP serverstdio)——不自己開 port,經 named pipe 橋接 GUI
@@ -294,6 +299,9 @@ ETTerms 是單視窗多分頁,沒有「路由」,以下以**功能面板**
- 接收 channel bytes → `AnsiParser``ScreenBuffer` → 繪製
- 鍵盤輸入 → `TerminalInput` → byte 序列 → channel
- 支援:scrollback、選取 / 複製、貼上、字型 / 配色(從 `TerminalProfile`
- **捲動:** 滑鼠滾輪或右側自繪深色捲軸 `DarkScrollBar`(拖曳滑塊 / 點軌道翻頁);`ContentWidth` 扣掉捲軸寬避免文字被蓋,捲軸範圍 / 位置在 `Feed` / 滾輪 / resize 時同步
- **貼上:** 對方啟用 bracketed pasteDEC mode 2004,如 PSReadLine / Kiro CLI)時,整段以 `ESC[200~ … ESC[201~` 包夾送出,避免多行貼上被逐行 Enter 立即送出;未啟用則逐字送出
- **複製:** 右鍵複製選取內容後自動清除反白(提示已複製)
### Script Editor / RunnerScripts 檢視)
- 載入 / 編輯 `.ttl` 腳本(語法沿用 MyTeraTerm
@@ -355,7 +363,16 @@ ScriptRunner.RunAsync(scriptText, activeChannel)
---
## AI / MCP IntegrationSerial MCP Server
## AI / MCP IntegrationSerial MCP + PDU MCP Server
ETTerms 提供**兩個獨立的 stdio MCP server**給 AI agentKiro CLI / Claude CLI):
- **`ETTerms.SerialMcp`** — 收發 serial。COM port 獨佔,故由 GUI 唯一持有、MCP 經本機 named pipe 橋接(見下方)。
- **`ETTerms.PduMcp`** — 控制 SNMP PDU 電源插座。SNMP(UDP) 非獨佔,故 MCP **直接打 SNMP**,不需 GUI 在跑、也不經 pipe。
兩者都能用 GUI **Settings → AI MCP** 一鍵 Setup`McpRegistrar` 會同時註冊 `etterms-serial``etterms-pdu`)。
### Serial MCP Server
> 讓 **Kiro CLI / Claude CLI** 等 AI agent 收發 serial**且使用者能在 ETTerms GUI 即時看到 AI 的每筆收發**。
>
@@ -411,7 +428,11 @@ ETTerms GUI(單一行程,唯一開 COM3 的人)
- **可視性:** AI 的 TX 在 GUI 以 `[AI]` 標色,與使用者手打的輸入區分;RX 兩邊同源。
- **安全:** 本機、無雲、不碰 credential;pipe 僅限本機行程,只搬 serial bytes。
### 註冊(Kiro CLI
### 註冊(兩種方式
**方式 A — 一鍵設定(推薦,v0.2.1):** GUI **Settings → AI MCP** 分頁,對 Claude Code / Kiro 各按 **Setup** 即可。`McpRegistrar` 以 read-modify-write 把 `etterms-serial` 寫進該 CLI 的使用者層級設定檔(Claude Code:`~/.claude.json`Kiro`~/.kiro/settings/mcp.json`),保留檔內其他既有 MCP server,原子寫回避免壞檔。註冊的執行檔路徑指向 `<ETTerms.exe>\ETTerms.SerialMcp\ETTerms.SerialMcp.exe`(與 publish 慣例對齊,必定存在);卡片同時顯示 CLI 驗證指令,按 **Remove** 可移除。
**方式 B — 手動 CLI**
```powershell
kiro-cli mcp add --name serial --command dotnet `
@@ -420,6 +441,33 @@ kiro-cli mcp add --name serial --command dotnet `
或寫進 agent.json 的 `mcpServers`Claude CLI 則用其對應的 `mcpServers` 設定。**使用前提:先在 ETTerms GUI 開好要操作的 serial 連線**,AI 才能 `serial_attach` 上去。註冊後即可對 AI 說「列出目前 serial session → 接上 COM3 → 送指令看回應」。
### PDU MCP Serverv0.3.0
> 讓 AI agent 直接控制 SNMP PDU 的電源插座,**典型用途:測試中自動 power-cycle DUT**。
**關鍵設計:直接打 SNMP,不經 GUI 橋接。** 與 serial 不同,PDU 走 SNMPUDP)**非獨佔**——多個行程可同時對同一台 PDU 下命令。因此 `ETTerms.PduMcp` 不需要像 serial 那樣繞 GUI 的 named pipe,而是內含一份精簡版 `PduController`OID 邏輯複製自 GUI 的 `Scripting/Pdu/PduController`,診斷改走 stderr 以免污染 stdio JSON-RPC)直接與 PDU 對話。**好處:GUI 不必開著,AI 也能控制 PDU;最少程式碼、最穩。**
```
Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
└─ SnmpSharpNet ──(SNMP/UDP 161)──► PDUiPoMan II/III
```
連線狀態(device IP → controller)以行程內單例 `PduRegistry` 保存,跨工具呼叫保留,直到 `pdu_disconnect` 或行程結束。
**暴露的工具:**
| 工具 | 參數 | 說明 |
|------|------|------|
| `pdu_connect` | ip | 以 SNMP 連線並驗證 PDU 回應,成功回傳 model name;控制前必須先呼叫 |
| `pdu_list` | — | 列出本 session 已連線的 PDU(依 IP |
| `pdu_set_port` | ip, port, on | 將某插座開(on=true)/關(off=false) |
| `pdu_get_port` | ip, port | 讀單一插座的狀態 / 電流(mA) / 功率(W) |
| `pdu_status` | ip | 讀全部 12 個插座的狀態 / 電流 / 功率 |
| `pdu_power_cycle` | ip, port, offSeconds? | 關 → 等 offSeconds → 開(重啟 DUT |
| `pdu_disconnect` | ip | 解除本 session 的 PDU 連線(不改變插座狀態) |
> 所有工具回傳統一的 `{ "ok": bool, "result"/"error": ... }` JSON。SNMP community 目前沿用 GUI 版的 `"private"`。
---
## Key Constraints & Business Rules
@@ -480,13 +528,73 @@ cd F:\10_AI\ETTerms
dotnet build
dotnet run --project src\ETTerms\ETTerms.csproj
# 5. 打包(後期 Phase
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false
# 5. 打包(見下方「Publish / 打包慣例」
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false `
-o src\ETTerms\Publish\ETTerms_v<Version>
# 後續可用 Inno Setup / MSIX 做安裝程式
```
---
## Publish / 打包慣例
> 每次發 Release 都依此規則產出,方便辨識版本與一併攜帶 MCP server。
**輸出位置(固定):** `src\ETTerms\Publish\ETTerms_v{Version}\`
- `{Version}` 取自 `src\ETTerms\ETTerms.csproj``<Version>`(例:`0.2.0`)。
- 資料夾命名:`ETTerms_v{Version}`(前綴 `v`,例:`ETTerms_v0.2.0`)。
- `Publish/` 已 gitignore`publish/`),**不入 git**。
**內容結構:**
```
ETTerms_v0.3.0\
├── ETTerms v0.3.0.exe # 主程式 apphost,改名為「ETTerms v{Version}.exe」
├── ETTerms.dll + 各相依 dll # SSH.NET / SQLite / SnmpSharpNet / System.IO.Ports …
├── ETTerms.SerialMcp\ # Serial MCP server,獨立發佈到子資料夾(相依 dll 與 GUI 隔離)
│ ├── ETTerms.SerialMcp.exe
│ └── ETTerms.SerialMcp.dll + 相依
└── ETTerms.PduMcp\ # PDU MCP serverv0.3.0),同樣獨立發佈到子資料夾
├── ETTerms.PduMcp.exe
└── ETTerms.PduMcp.dll + 相依(含 SnmpSharpNet
```
**規則:**
1. **GUI publish 會自動帶上兩個 MCP server**`ETTerms.csproj``PublishMcpServers` target`AfterTargets="Publish"`),會把 `ETTerms.SerialMcp``ETTerms.PduMcp` 一併發佈到各自的 `<publish>\<server>\` **子資料夾**(與 GUI 相依 dll 隔離)。因此**只要發佈 GUI 一個指令**即可,不必再單獨發 MCP。
- 對齊 `McpRegistrar.ResolveServerExe()`:它解析的 `<ETTerms.exe>\<server>\<server>.exe` 因此**必定存在**,AI MCP 一鍵設定寫進去的路徑才不會落空。
- MCP 子發佈會**跟隨 GUI 的 `SelfContained` 設定**target 內以 `$(SelfContained)` 傳入):框架相依版的 MCP 也框架相依;portable 版的 MCP 也免 runtime。
2. **主 exe 改名**`dotnet publish` 產生的 `ETTerms.exe` 重新命名為 **`ETTerms v{Version}.exe`**。
- 可安全改名:.NET apphost 內部記錄要載入的 `ETTerms.dll`,**不靠自身檔名**,改名後仍正常啟動。
### 兩種發佈版本(兩個都要產出)
| 版本 | 資料夾 | 指令旗標 | 需 .NET Runtime | 用途 |
|------|--------|----------|-------------------|------|
| **A. 框架相依(預設)** | `ETTerms_v{Version}\` | `--self-contained false` | ✅ 需先裝 .NET 8 Desktop Runtime | 體積小;給已具備 runtime 的環境 |
| **B. Portable(免安裝)** | `ETTerms_v{Version}_portable\` | `--self-contained true` | ❌ 不需要,runtime 已內含 | 體積大(約 240MB);給受 MIS 管控、不便裝 runtime 的環境,解壓即用、免系統管理員權限 |
> ⚠️ **不要開 trimming**`PublishTrimmed`):WinForms 大量用反射,trim 後易在執行時出錯。
**PowerShell 範例(一次產出兩種):**
```powershell
$ver = ([regex]::Match((Get-Content src\ETTerms\ETTerms.csproj -Raw), '<Version>([^<]+)</Version>')).Groups[1].Value
# A. 框架相依版 → ETTerms_v{Version}\
$root = "src\ETTerms\Publish\ETTerms_v$ver"
if (Test-Path $root) { Remove-Item $root -Recurse -Force }
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false -o $root
Rename-Item (Join-Path $root "ETTerms.exe") "ETTerms v$ver.exe"
# B. Portable 免安裝版 → ETTerms_v{Version}_portable\runtime 已內含;MCP 也跟著 self-contained
$proot = "src\ETTerms\Publish\ETTerms_v${ver}_portable"
if (Test-Path $proot) { Remove-Item $proot -Recurse -Force }
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained true -o $proot
Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
```
> 兩版的 `ETTerms.SerialMcp\` 與 `ETTerms.PduMcp\` 子資料夾都由 `PublishMcpServers` target 自動產生;portable 版的 MCP 也是 self-contained,故 AI MCP 功能在無 runtime 環境同樣可用。
---
## Development Phases
> 依使用者要求「**前面先把 GUI 做出來,後面再慢慢補功能**」排序:
+27 -3
View File
@@ -10,6 +10,14 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
**進度:** Phase 15 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server**GUI 持有 COM portMCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)。打包待指示。
**v0.3.2** **(1) 新增左側 `Status` rail view** — 位於 Terminal 與 Settings 之間(圖示 `⚡`,新增 `ActivityRail.RailView.Status` 列舉與 `Items`),獨立 `App/StatusView.cs`,沿用 `SettingsView` 的自繪 tab strip + panel 切換風格(避免 TabControl 白邊);`MainForm``_statusView` 欄位、佈局與 rail 切換可見性。目前只有 **PDU 分頁**,未來會再加分頁(`MakeTab(...)` 即可擴充)。**(2) PDU 搬到 Status 並改自動輪詢** — PDU 從 `SettingsView` 移到 `StatusView`**移除手動 Refresh 按鈕**,連線成功後以 `System.Threading.Timer`period 3000ms)在**背景執行緒**讀 SNMP、再 `BeginInvoke` 回 UI 更新表格(不卡 UI);`Interlocked` 旗標防止前一輪未讀完就重入;斷線 / 控制項 Dispose 時自動停掉 timer 與 `PduController`;表格下方顯示「last update HH:mm:ss」。`SettingsView` 移除 `BuildPduTab`/`RefreshPduGrid``ETTerms.Scripting.Pdu` import,現只剩 **Terminal / AI MCP** 兩分頁。**(2b) PDU 分頁加手動開關鈕** — 狀態表格新增 **Control** 欄(`DataGridViewButtonColumn`),每個 Port 一顆鈕,文字隨狀態切換(ON→「Turn OFF」、OFF→「Turn ON」、未知→「—」);點擊在背景執行緒呼叫 `PduController.SetPortOn/SetPortOff`(不卡 UI),等 ~400ms 後回讀刷新整表,失敗跳 `MessageBox`;未連線點擊會提示先 Connect,斷線時清空表格避免顯示過時狀態。**(3) 修 `ShellChannel` 啟動目錄 fallback** — 本機 Shell 的 `StartupDirectory` 若已不存在(外接碟拔除 / 資料夾被刪)會 `CreateProcess failed: 267 (ERROR_DIRECTORY)`;改為「為空 **或** `Directory.Exists` 為 false」即 fallback 到使用者家目錄(`Environment.SpecialFolder.UserProfile`,隨登入者變動,例如 `C:\Users\et_wen`),與 Windows PowerShell 行為一致。
**v0.3.1** 終端機體驗修正(皆在 `src/ETTerms/Terminal/`)。**(1) 深色垂直捲軸** — 新增自繪 `DarkScrollBar`(細長、無箭頭、圓角滑塊,配合 KKTerm 深色主題),`TerminalView` 右側 `Dock=Right` 掛上,`ContentWidth` 扣掉捲軸寬避免文字被蓋,`UpdateScrollBar()``Feed` / 滾輪 / resize 時同步滑塊範圍與位置,滾輪與拖曳互通。**(2) 多行貼上修正** — `AnsiParser` 新增 `BracketedPaste`DEC mode 2004);`TerminalView.Paste()` 在對方啟用 bracketed pastePSReadLine / Kiro CLI 等)時以 `ESC[200~ … ESC[201~` 包夾整段,視為「單次貼上」而非逐行 Enter 立即送出;未啟用時退回原本逐字送出。**(3) 右鍵複製清反白** — 右鍵複製後清掉 `_hasSel` 並重繪,讓使用者知道已複製。
**v0.3.0** 新增 **`ETTerms.PduMcp`**stdio MCP server):讓 AI agent 直接控制 SNMP PDU 插座。與 serial 不同,PDU 走 SNMP(UDP) **非獨佔**,故 PduMcp **直接打 SNMP、不經 GUI 橋接**(內含精簡版 `PduController`OID 邏輯複製自 GUI 版,log 走 stderr),GUI 不開著也能用。工具:`pdu_connect` / `pdu_list` / `pdu_set_port` / `pdu_get_port` / `pdu_status` / `pdu_power_cycle` / `pdu_disconnect`,回傳統一 `{ok, result/error}` JSON;連線狀態以行程內單例 `PduRegistry`IP→controller)保存。`McpRegistrar` 改為多 server**Settings → AI MCP 一鍵同時註冊 `etterms-serial``etterms-pdu`**`ETTerms.csproj` 的 publish target 更名 `PublishMcpServers`GUI publish 會把兩個 MCP 各自帶到 `\ETTerms.SerialMcp\``\ETTerms.PduMcp\` 子資料夾。
**v0.2.1** 新增 GUI **Settings → AI MCP** 分頁(`McpRegistrar`):對 Claude Code`~/.claude.json`)與 Kiro`~/.kiro/settings/mcp.json`**一鍵 Setup / Remove** 註冊 `etterms-serial` MCP serverread-modify-write 保留檔內其他設定、原子寫回;卡片附 CLI 驗證指令。`ETTerms.csproj` 加 publish target`AfterTargets=Publish`),GUI publish 會自動把 MCP server 帶到子資料夾,與 `McpRegistrar.ResolveServerExe()` 解析路徑對齊。
**v0.2.0** Phase 9 完成 — `ETTerms.SerialMcp`stdio MCP server+ GUI `SerialBridgeServer`named pipe `\\.\pipe\etterms-serial`)上線,提供 `serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach` 五個工具,AI 的 TX 在 GUI 以 `[AI]` 標色即時 echo;視窗 / 工作列 / About 改用 Choco 圖示,標題列顯示版本號。見 [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md)。
**v0.1.2** 新增 `sprintf2`TeraTerm 相容 C printf 格式化);`wait` 改為命中關鍵字後須等裝置安靜(`SettleMs` 預設 300ms)才接受並取「最後一次」出現,排除輸出中途的指令回顯(避免腳本搶跑)。
@@ -25,7 +33,7 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
- **連線儲存:** SQLite`Microsoft.Data.Sqlite`
- **密碼儲存:** Windows Credential Manager(不落地明碼)
- **PDU** SnmpSharpNetiPoMan II/III via SNMP
- **AI / MCP(選用):** stdio MCP server`ETTerms.SerialMcp`官方 C# SDK `ModelContextProtocol`)。**不自己開 COM port**而是經本機 named pipe 接上 GUI 持有的 serial session把 serial 收發暴露給 Kiro CLI / Claude CLIAI 的 TX/RX 同步顯示在 GUI
- **AI / MCP(選用):** 兩個 stdio MCP server(官方 C# SDK `ModelContextProtocol`)。`ETTerms.SerialMcp`**不自己開 COM port**,經本機 named pipe 接上 GUI 持有的 serial sessionAI 的 TX/RX 同步顯示在 GUI。`ETTerms.PduMcp`v0.3.0):**直接打 SNMP** 控制 PDU 插座,非獨佔故不需 GUI 在跑。皆暴露給 Kiro CLI / Claude CLI
- **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json`
## 常用指令
@@ -38,16 +46,31 @@ dotnet run --project src\ETTerms\ETTerms.csproj
# 加套件
dotnet add src\ETTerms package SSH.NET
# 打包
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false
# 打包(見「Publish / 打包慣例」)—— 兩種版本都產出,輸出到 src\ETTerms\Publish\
# GUI publish 會「自動」把 ETTerms.SerialMcp 與 ETTerms.PduMcp 一併發到各自的子資料夾
# ETTerms.csproj 的 PublishMcpServers targetAfterTargets=Publish),且 MCP 跟隨 GUI 的 self-contained 設定。
$ver = ([regex]::Match((Get-Content src\ETTerms\ETTerms.csproj -Raw), '<Version>([^<]+)</Version>')).Groups[1].Value
# A. 框架相依版(需目標機已裝 .NET 8 Desktop Runtime)→ ETTerms_v{Version}\
$root = "src\ETTerms\Publish\ETTerms_v$ver"
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false -o $root
Rename-Item (Join-Path $root "ETTerms.exe") "ETTerms v$ver.exe"
# B. Portable 免安裝版(runtime 內含,免裝、免管理員)→ ETTerms_v{Version}_portable\
$proot = "src\ETTerms\Publish\ETTerms_v${ver}_portable"
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained true -o $proot
Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
# 註冊 Serial MCP server(給 AI agent 操作 serial
# 推薦:GUI Settings → AI MCP 分頁,對 Claude Code / Kiro 按 Setup 一鍵註冊(McpRegistrar)。
# 或手動 CLI
kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj"
```
## 開發慣例
- **命名:** PascalCase 類別 / 方法,`_camelCase` 私有欄位;檔名 = 類別名。
- **Publish / 打包:** 輸出到 `src\ETTerms\Publish\`;主 exe 改名為 `ETTerms v{Version}.exe``ETTerms.SerialMcp``ETTerms.PduMcp` 一併發到其下 `ETTerms.SerialMcp\``ETTerms.PduMcp\` 子資料夾(且跟隨 GUI 的 self-contained 設定)。**兩種版本都產出**:框架相依 `ETTerms_v{Version}\``--self-contained false`,需裝 .NET 8 Desktop Runtime)+ portable 免安裝 `ETTerms_v{Version}_portable\``--self-contained true`runtime 內含)。不要開 trimmingWinForms 反射)。詳見 [ARCHITECTURE.md](ARCHITECTURE.md#publish--打包慣例)。
- **分層:** UI`App/`)只認 `ISessionChannel` 抽象,不直接相依 SSH.NET / SerialPort。
- **執行緒:** channel I/O 在背景;所有 UI 更新一律 `Control.Invoke` 回 UI thread。
- **commit** 走 Conventional Commits`feat:` / `fix:` / `refactor:` …)。
@@ -68,5 +91,6 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm
- **`src/ETTerms/`** — 主應用程式(WinForms 視窗外殼 + 連線 / 終端機 / 腳本引擎)。
- **`src/ETTerms.SerialMcp/`** — ✅ stdio MCP server(給 AI agent 收發 serial)。獨立行程,但**不直接開 COM port**:經本機 named pipe 連到 GUI 的 `SerialBridgeServer`,由 GUI 代為讀寫實體 portnet8.0 console + `ModelContextProtocol` SDK。
- **`src/ETTerms.PduMcp/`** — ✅ stdio MCP server(給 AI agent 控制 SNMP PDUv0.3.0)。獨立行程,**直接打 SNMP**(內含精簡版 `PduController`),不經 GUI、GUI 不開著也能用;net8.0 console + `ModelContextProtocol` + `SnmpSharpNet`
- **`For_AI/`** — AI 協作素材與**參考專案**(`KKTerm-main` UI 參考、`MyTeraTerm` Script 參考)。整個資料夾 gitignored,僅供開發對照。
- 本專案**無 `secret/` 資料夾**:沒有伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager。
+1
View File
@@ -2,5 +2,6 @@
<Folder Name="/src/">
<Project Path="src/ETTerms/ETTerms.csproj" />
<Project Path="src/ETTerms.SerialMcp/ETTerms.SerialMcp.csproj" />
<Project Path="src/ETTerms.PduMcp/ETTerms.PduMcp.csproj" />
</Folder>
</Solution>
+53 -15
View File
@@ -4,7 +4,7 @@
> A native Windows terminal workspace (C# .NET 8 WinForms) — **SSH**, **Serial Port**, and **local Shell (ConPTY)** in one window, with a **TTL scripting engine** ported from MyTeraTerm for automation, plus an optional **Serial MCP server** that lets AI agents (Kiro CLI / Claude CLI) drive the serial port directly. Standalone, no cloud, no login.
![version](https://img.shields.io/badge/version-0.1.0-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-WIP-orange.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg)
![version](https://img.shields.io/badge/version-0.3.2-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-beta-yellow.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg)
---
@@ -63,12 +63,14 @@
-**PDU power control (optional)**
- Control PDU outlets over SNMP (`SnmpSharpNet`) to power-cycle devices during tests
- **Status → PDU** tab: connect to a PDU, auto-poll outlet state every 3 s, and toggle any port on/off with per-row **Control** buttons
- Script commands: `pduconnect` / `pductrl`
- 🤖 **AI / MCP integration (optional, planned)**
- 🤖 **AI / MCP integration (optional)**
- The **GUI owns the COM port**; a standalone stdio **Serial MCP server** bridges to it over a local named pipe for **Kiro CLI / Claude CLI**
- Tools: `serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach`
- AI's serial TX/RX shows live in the GUI tagged `[AI]` — open the port in the GUI first, then let the AI attach
- **One-click setup** in **Settings → AI MCP**: register the server into Claude Code / Kiro with a single button
- 📊 **Session RX logging**
- `logopen` / `logwrite` / `logclose` write session output to file
@@ -76,9 +78,10 @@
### Terminal Rendering
- 🎨 Owner-drawn VT100 / ANSI control, double-buffered cell grid
- 🌑 KKTerm-style dark theme (incl. DWM dark title bar)
- 🌑 KKTerm-style dark theme (incl. DWM dark title bar + dark terminal scrollbar)
- 🔤 Configurable font / size / palette / scrollback
- 📋 Select / copy / paste
- 🖱️ Scroll back through long output with the mouse wheel or the dark scrollbar
- 📋 Select / copy / paste — right-click copy clears the highlight; multi-line paste uses **bracketed paste** so it isn't submitted line-by-line
---
@@ -271,7 +274,7 @@ wait 'login: '
## 🤖 AI / MCP Integration
> 🔜 **Planned (Phase 9).** Let AI agents (**Kiro CLI / Claude CLI**) send/receive on a serial port **while you watch it live in the ETTerms GUI**.
> Let AI agents (**Kiro CLI / Claude CLI**) send/receive on a serial port **while you watch it live in the ETTerms GUI**.
**Key design: the GUI owns the COM port; the MCP server never opens it.** A COM port can be opened by only one process at a time, so rather than the MCP server grabbing it, the GUI is the sole owner and runs a local **named pipe server** (`SerialBridgeServer`). The standalone `ETTerms.SerialMcp` (launched by Kiro/Claude CLI) is a thin client: every `write` / `read` is forwarded over the pipe to the GUI, which does the real port I/O. AI's TX is echoed into the terminal tagged `[AI]`, so RX/TX flow through the GUI channel and what you see is exactly what the AI sees.
@@ -287,7 +290,11 @@ Full closed loop — all inside the ETTerms GUI:
| `serial_read` | waitFor?, timeoutMs? | Drain accumulated RX; optionally wait for a substring / timeout |
| `serial_detach` | — | Unbind (does **not** close the GUI's port) |
Register in Kiro CLI:
### One-click setup (recommended)
In **Settings → AI MCP**, click **Setup** on the Claude Code or Kiro card to register the Serial MCP server into that CLI's user-level config (`~/.claude.json` / `~/.kiro/settings/mcp.json`) with a single button. Your other MCP servers are preserved, and each card shows the CLI command to verify the connection. **Remove** unregisters it.
### Manual setup
```powershell
kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj"
@@ -377,8 +384,8 @@ ETTerms/
│ ├── Sessions/ # Connection abstraction: ISessionChannel / SshChannel / SerialChannel / ShellChannel
│ ├── Connections/ # Connection data: Connection / ConnectionStore(SQLite) / CredentialVault
│ ├── Scripting/ # TTL engine: TTLInterpreter / ScriptRunner / GroupSyncContext / Pdu
│ └── Infrastructure/ # AppLogger / AppSettings / NativeTheme
└── src/ETTerms.SerialMcp/ # 🔜 Serial MCP server (stdio) — lets AI agents drive the serial port
│ └── Infrastructure/ # AppLogger / AppSettings / McpRegistrar / NativeTheme
└── src/ETTerms.SerialMcp/ # Serial MCP server (stdio) — lets AI agents drive the serial port
```
**Core design:** every connection implements `ISessionChannel` (`Write(byte[])` + `event DataReceived`). `TerminalView` and `TTLInterpreter` only know this abstraction, so SSH / Serial / Shell look identical to upper layers — the key to "one script engine driving multiple connection types." See [ARCHITECTURE.md](ARCHITECTURE.md).
@@ -397,18 +404,49 @@ ETTerms/
## 📜 Version History
### v0.1.0 (in development)
### v0.3.2
- **Status → PDU tab** — a dedicated `Status` rail view hosts the PDU panel: connect by IP, auto-poll all 12 outlets every 3 s (background thread, no manual Refresh), with live current / power readouts
- **Manual outlet control buttons** — each row has a **Control** button that toggles that port on/off over SNMP; the label tracks state (ON → "Turn OFF", OFF → "Turn ON"), the SNMP set runs off the UI thread, and the grid refreshes after the command
### v0.3.1
- **Terminal usability fixes** — added a **dark vertical scrollbar** (drag or click-to-page through long output instead of long mouse-wheel scrolling)
- **Multi-line paste** into Kiro CLI / PSReadLine now pastes as a single block (**bracketed paste**, DEC mode 2004) instead of submitting each line immediately
- Right-click copy now **clears the highlight** so you know it worked
### v0.3.0
- **AI-driven PDU power control (MCP)** — new `ETTerms.PduMcp` server lets AI agents (Kiro CLI / Claude CLI) control an SNMP PDU directly: outlets on/off, status, and power-cycle a DUT during automated tests
- PDU runs over SNMP (UDP, non-exclusive), so the AI talks to it directly — the ETTerms GUI does not need to be running
- Tools: `pdu_connect` / `pdu_list` / `pdu_set_port` / `pdu_get_port` / `pdu_status` / `pdu_power_cycle` / `pdu_disconnect`
- **Settings → AI MCP** now registers both `etterms-serial` and `etterms-pdu` with one click; publishing the GUI auto-bundles both MCP servers
### v0.2.2
- **Terminal stability** — the terminal no longer freezes after minimizing or switching tabs (most noticeable with full-screen TUIs like Kiro CLI in PowerShell); a degenerate 1×1 size is no longer sent to the pseudo-console
- **Shift+Enter** inserts a newline in the shell, so you can type multi-line commands (plain Enter still submits)
- **High-DPI fixes** — text and buttons no longer clipped at 125% / 150% scaling (PerMonitorV2); toolbar / settings / sidebar / About now scale adaptively
### v0.2.1
- **AI MCP one-click setup** — new **Settings → AI MCP** tab registers the Serial MCP server into Claude Code (`~/.claude.json`) or Kiro (`~/.kiro/settings/mcp.json`) with a single button; existing MCP servers are preserved, and each card shows the verify command
- Publish now auto-bundles `ETTerms.SerialMcp` into the app folder, so the registered path always resolves
### v0.2.0
- **Phase 9: Serial MCP server** (`ETTerms.SerialMcp`) — AI agents (Kiro CLI / Claude CLI) send/receive on the serial port while you watch it live in the GUI
- GUI is the sole COM-port owner; the MCP server bridges over a local named pipe (`SerialBridgeServer`) and never opens the port itself
- Tools: `serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach`; AI's TX echoed into the terminal tagged `[AI]`
- New app icon (window / taskbar / executable); version shown in the title bar
### v0.1.0
- Phases 18 complete: window shell, connection sidebar, tiling workspace, Serial / SSH / local Shell (ConPTY) / SFTP
- Owner-drawn VT100 terminal rendering
- TTL scripting engine (ported from MyTeraTerm) + Group sync execution
- PDU power control (SNMP)
- Settings / About
- Packaging TBD
**Planned**
- Phase 9: Serial MCP server (`ETTerms.SerialMcp`) — let AI agents (Kiro CLI / Claude CLI) drive the serial port directly
---
@@ -420,7 +458,7 @@ Licensed under the **MIT License** — see [LICENSE](LICENSE).
## 🙏 Acknowledgments
- **[KKTerm](https://github.com/)** — UI design reference (Activity Rail + tabbed workspace + Saved Connections)
- **[KKTerm](https://github.com/ryantsai/KKTerm)** — by ryantsai (MIT) — UI design reference (Activity Rail + tabbed workspace + Saved Connections)
- **MyTeraTerm** — source of the TTL scripting engine and `AppLogger`
- **[SSH.NET](https://github.com/sshnet/SSH.NET)**, **[SnmpSharpNet](http://www.snmpsharpnet.com/)** — open-source connectivity / SNMP libraries
- **Microsoft** — .NET 8, WinForms, ConPTY, Credential Manager
+53 -15
View File
@@ -4,7 +4,7 @@
> 原生 Windows 終端機工作台(C# .NET 8 WinForms)—— 一個視窗整合 **SSH**、**Serial Port**、**本機 Shell (ConPTY)** 連線,內建從 MyTeraTerm 移植的 **TTL 腳本引擎**做自動化,並提供選用的 **Serial MCP server**,讓 AI agentKiro CLI / Claude CLI)直接操作 serial port。單機、無雲、無登入系統。
![version](https://img.shields.io/badge/version-0.1.0-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-WIP-orange.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg)
![version](https://img.shields.io/badge/version-0.3.2-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-beta-yellow.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg)
---
@@ -63,12 +63,14 @@
-**PDU 電源控制(選用)**
- 透過 SNMP`SnmpSharpNet`)控制 PDU 插座,測試中遠端電源循環
- **Status → PDU** 分頁:連線 PDU 後每 3 秒自動輪詢插座狀態,並可用各列的 **Control** 鈕直接開 / 關該 Port
- 腳本指令:`pduconnect` / `pductrl`
- 🤖 **AI / MCP 整合(選用,規劃中**
- 🤖 **AI / MCP 整合(選用)**
- **COM port 由 GUI 持有**;獨立的 stdio **Serial MCP server** 經本機 named pipe 橋接過去,供 **Kiro CLI / Claude CLI** 使用
- 工具:`serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach`
- AI 的 serial 收發會即時顯示在 GUI(標 `[AI]`)——先在 GUI 開好 port,再讓 AI attach
- **一鍵設定**:在 **設定 → AI MCP** 一個按鈕就把 server 註冊進 Claude Code / Kiro
- 📊 **連線 RX 日誌**
- `logopen` / `logwrite` / `logclose` 將工作階段輸出寫檔
@@ -76,9 +78,10 @@
### 終端機渲染
- 🎨 自繪 VT100 / ANSI 控制項(owner-drawn),雙緩衝繪字格
- 🌑 KKTerm 風格深色主題(含 DWM 深色標題列)
- 🌑 KKTerm 風格深色主題(含 DWM 深色標題列 + 深色終端機捲軸
- 🔤 可調字型 / 字級 / 配色 / scrollback 行數
- 📋 選取 / 複製 / 貼上
- 🖱️ 用滑鼠滾輪或深色捲軸往回捲長輸出
- 📋 選取 / 複製 / 貼上 —— 右鍵複製會清掉反白;多行貼上採 **bracketed paste**,不會被逐行送出
---
@@ -271,7 +274,7 @@ wait 'login: '
## 🤖 AI / MCP 整合
> 🔜 **規劃中(Phase 9)。** 讓 AI agent**Kiro CLI / Claude CLI**)收發 serial**而且你能在 ETTerms GUI 即時看到 AI 的每筆收發**。
> 讓 AI agent**Kiro CLI / Claude CLI**)收發 serial**而且你能在 ETTerms GUI 即時看到 AI 的每筆收發**。
**關鍵設計:COM port 由 GUI 唯一持有,MCP server 不自己開 port。** 一個 COM port 同時只能被一個行程開啟;因此不讓 MCP server 自己開,而是 **GUI 當 port 的唯一擁有者**,在 GUI 內跑一支本機 **named pipe server**`SerialBridgeServer`)。獨立的 `ETTerms.SerialMcp`(由 Kiro/Claude CLI 啟動)退化成瘦客戶端:所有 `write` / `read` 都經 pipe 轉發給 GUI,由 GUI 代為讀寫實體 port。AI 的 TX 會以 `[AI]` 標色 echo 進終端機,RX/TX 都流經 GUI channel**你看到的就是 AI 看到的**。
@@ -287,7 +290,11 @@ wait 'login: '
| `serial_read` | waitFor?, timeoutMs? | 取出累積的 RX;可等待子字串或逾時 |
| `serial_detach` | — | 解除綁定(**不會**關閉 GUI 的 port |
在 Kiro CLI 註冊:
### 一鍵設定(推薦)
**設定 → AI MCP**,對 Claude Code 或 Kiro 卡片按 **Setup**,一個按鈕就把 Serial MCP server 寫進該 CLI 的使用者層級設定檔(`~/.claude.json` / `~/.kiro/settings/mcp.json`)。你既有的其他 MCP server 會被保留,卡片並顯示用來驗證連線的 CLI 指令。按 **Remove** 即可移除。
### 手動設定
```powershell
kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj"
@@ -377,8 +384,8 @@ ETTerms/
│ ├── Sessions/ # 連線抽象:ISessionChannel / SshChannel / SerialChannel / ShellChannel
│ ├── Connections/ # 連線資料:Connection / ConnectionStore(SQLite) / CredentialVault
│ ├── Scripting/ # TTL 引擎:TTLInterpreter / ScriptRunner / GroupSyncContext / Pdu
│ └── Infrastructure/ # AppLogger / AppSettings / NativeTheme
└── src/ETTerms.SerialMcp/ # 🔜 Serial MCP serverstdio)—— 讓 AI agent 直接操作 serial port
│ └── Infrastructure/ # AppLogger / AppSettings / McpRegistrar / NativeTheme
└── src/ETTerms.SerialMcp/ # Serial MCP serverstdio)—— 讓 AI agent 直接操作 serial port
```
**核心設計:** 所有連線都實作 `ISessionChannel``Write(byte[])` + `event DataReceived`)。`TerminalView``TTLInterpreter` 只認得這個抽象,因此 SSH / Serial / Shell 對上層完全一致——這是「同一套腳本引擎驅動多種連線」的關鍵。詳見 [ARCHITECTURE.md](ARCHITECTURE.md)。
@@ -397,18 +404,49 @@ ETTerms/
## 📜 版本紀錄
### v0.1.0(開發中)
### v0.3.2
- **Status → PDU 分頁** —— 新增 `Status` rail 檢視,PDU 面板移到此處:輸入 IP 連線後每 3 秒自動輪詢全部 12 個插座(背景執行緒,免手動 Refresh),即時顯示電流 / 功率
- **手動插座開關鈕** —— 每列新增 **Control** 鈕,按一下即透過 SNMP 切換該 Port 開 / 關;按鈕文字隨狀態變動(ON →「Turn OFF」、OFF →「Turn ON」),SNMP 設定在 UI 執行緒外執行,下命令後自動回讀刷新表格
### v0.3.1
- **終端機體驗修正** —— 新增**深色垂直捲軸**(拖曳滑塊或點軌道翻頁,不必狂滾滑鼠看長輸出)
- 貼多行到 Kiro CLI / PSReadLine 現在會**整段一次貼上****bracketed paste**DEC mode 2004),不再逐行立即送出
- 右鍵複製後會**清掉反白**,讓你知道已複製
### v0.3.0
- **AI 控制 PDU 電源(MCP** —— 新增 `ETTerms.PduMcp` server,讓 AI agentKiro CLI / Claude CLI)直接控制 SNMP PDU:插座開 / 關、讀狀態、自動化測試時 power-cycle 一台 DUT
- PDU 走 SNMPUDP,非獨佔),AI 直接打 PDU —— ETTerms GUI 不需開著
- 工具:`pdu_connect` / `pdu_list` / `pdu_set_port` / `pdu_get_port` / `pdu_status` / `pdu_power_cycle` / `pdu_disconnect`
- **設定 → AI MCP** 一鍵同時註冊 `etterms-serial``etterms-pdu`publish 會自動把兩個 MCP server 一併打包
### v0.2.2
- **終端機穩定性** —— 視窗最小化或切換分頁後終端機不再卡住(跑全螢幕 TUI 如 PowerShell 裡的 Kiro CLI 最明顯);不再把退化的 1×1 尺寸送給 pseudo-console
- **Shift+Enter** 在 shell 插入換行,可輸入多行指令(單純 Enter 仍是送出)
- **高 DPI 修正** —— 125% / 150% 縮放下文字與按鈕不再被裁切(PerMonitorV2);toolbar / 設定 / 側欄 / About 改為自適應縮放
### v0.2.1
- **AI MCP 一鍵設定** —— 新增 **設定 → AI MCP** 分頁,一個按鈕就把 Serial MCP server 註冊進 Claude Code`~/.claude.json`)或 Kiro`~/.kiro/settings/mcp.json`);保留檔內其他既有 MCP server,卡片並顯示驗證指令
- Publish 會自動把 `ETTerms.SerialMcp` 一併打包進 app 資料夾,註冊寫入的路徑必定存在
### v0.2.0
- **Phase 9Serial MCP server**`ETTerms.SerialMcp`)—— AI agentKiro CLI / Claude CLI)可在 serial port 收發,而你在 GUI 即時看著
- COM port 由 GUI 唯一持有;MCP server 經本機 named pipe`SerialBridgeServer`)橋接,自己不開 port
- 工具:`serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach`AI 的 TX 在終端機以 `[AI]` 標色 echo
- 新增 app 圖示(視窗 / 工作列 / 執行檔);標題列顯示版本號
### v0.1.0
- Phase 1–8 完成:視窗外殼、連線側欄、平鋪工作區、Serial / SSH / 本機 Shell (ConPTY) / SFTP
- 自繪 VT100 終端機渲染
- TTL 腳本引擎(移植自 MyTeraTerm+ Group 同步執行
- PDU 電源控制(SNMP
- Settings / About
- 打包待規劃
**規劃中**
- Phase 9Serial MCP server`ETTerms.SerialMcp`)—— 讓 AI agentKiro CLI / Claude CLI)直接操作 serial port
---
@@ -420,7 +458,7 @@ ETTerms/
## 🙏 致謝
- **[KKTerm](https://github.com/)** — UI 設計參考(Activity Rail + 分頁工作區 + Saved Connections
- **[KKTerm](https://github.com/ryantsai/KKTerm)** — by ryantsaiMITUI 設計參考(Activity Rail + 分頁工作區 + Saved Connections
- **MyTeraTerm** — TTL 腳本引擎與 `AppLogger` 來源
- **[SSH.NET](https://github.com/sshnet/SSH.NET)**、**[SnmpSharpNet](http://www.snmpsharpnet.com/)** — 開源連線 / SNMP 函式庫
- **Microsoft** — .NET 8、WinForms、ConPTY、Credential Manager
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ETTerms.PduMcp</RootNamespace>
<AssemblyName>ETTerms.PduMcp</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="SnmpSharpNet" Version="0.9.7">
<NoWarn>NU1701</NoWarn>
</PackageReference>
</ItemGroup>
</Project>
+92
View File
@@ -0,0 +1,92 @@
using System.Net;
using SnmpSharpNet;
namespace ETTerms.PduMcp;
/// <summary>
/// PDU controller for iPoMan II/III models via SNMP (slim copy of the GUI's
/// ETTerms.Scripting.Pdu.PduController, ported into the standalone MCP server).
///
/// Differences from the GUI version:
/// • No dependency on ETTerms.Infrastructure.AppLogger — diagnostics go to stderr
/// (stdout is reserved for the MCP JSON-RPC stream).
/// • Adds <see cref="GetModelName"/> so tools can surface the device identity.
///
/// SNMP is connectionless (UDP) and non-exclusive, so this talks to the PDU directly
/// without bridging through the GUI.
/// </summary>
public sealed class PduController : IDisposable
{
private readonly string _ip;
private const string Community = "private";
private const int SnmpPort = 161;
private const int Timeout = 3000;
public string Ip => _ip;
public PduController(string ip) => _ip = ip;
/// <summary>Reads the PDU model/name OID; non-empty containing "PDU" means reachable.</summary>
public string? GetModelName() => SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4");
public bool CheckConnection()
{
var name = GetModelName();
Log($"CheckConnection {_ip}: name='{name ?? "<null>"}'");
return !string.IsNullOrEmpty(name) && name.Contains("PDU");
}
public bool SetPortOn(int port) => SnmpSet(PortControlOid(port), new Integer32(3));
public bool SetPortOff(int port) => SnmpSet(PortControlOid(port), new Integer32(4));
/// <summary>true = on, false = off, null = unknown/unreachable.</summary>
public bool? GetPortState(int port)
{
var r = SnmpGet(PortStateOid(port));
return r == "3" ? true : (r == "2" || r == "4") ? false : null;
}
public int? GetPortCurrent(int port) => int.TryParse(SnmpGet(PortCurrentOid(port)), out int v) ? v : null;
public double? GetPortPowerWatts(int port) => int.TryParse(SnmpGet(PortPowerOid(port)), out int v) ? v / 10.0 : null;
private static string PortControlOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.4.1.2.{port}";
private static string PortStateOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.2.{port}";
private static string PortCurrentOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.3.{port}";
private static string PortPowerOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.5.{port}";
private bool SnmpSet(string oid, AsnType value)
{
try
{
var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
var pdu = new SnmpSharpNet.Pdu(PduType.Set);
pdu.VbList.Add(new Oid(oid), value);
var result = (SnmpV1Packet)target.Request(pdu, param);
return result?.Pdu.ErrorStatus == 0;
}
catch (Exception ex) { Log($"SNMP SET {oid} exception: {ex.Message}"); return false; }
}
private string? SnmpGet(string oid)
{
try
{
var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
var pdu = new SnmpSharpNet.Pdu(PduType.Get);
pdu.VbList.Add(new Oid(oid));
var result = (SnmpV1Packet)target.Request(pdu, param);
if (result == null) { Log($"SNMP GET {oid}: no response (timeout)"); return null; }
if (result.Pdu.ErrorStatus != 0) { Log($"SNMP GET {oid}: ErrorStatus={result.Pdu.ErrorStatus}"); return null; }
foreach (var v in result.Pdu.VbList) return v.Value.ToString();
}
catch (Exception ex) { Log($"SNMP GET {oid} exception: {ex.Message}"); }
return null;
}
// stdio MCP serverstdout 專供 JSON-RPC,診斷一律走 stderr。
private static void Log(string msg) => Console.Error.WriteLine($"[PDU] {msg}");
public void Dispose() { }
}
+31
View File
@@ -0,0 +1,31 @@
using System.Collections.Concurrent;
namespace ETTerms.PduMcp;
/// <summary>
/// Process-wide registry of connected PDUs, keyed by IP. The MCP server is long-lived,
/// so connections established by <c>pdu_connect</c> persist across tool calls.
/// </summary>
public sealed class PduRegistry
{
public static readonly PduRegistry Instance = new();
/// <summary>iPoMan II/III outlet count.</summary>
public const int PortCount = 12;
private readonly ConcurrentDictionary<string, PduController> _pdus = new(StringComparer.OrdinalIgnoreCase);
public PduController GetOrAdd(string ip) =>
_pdus.GetOrAdd(ip.Trim(), static k => new PduController(k));
public bool TryGet(string ip, out PduController pdu) =>
_pdus.TryGetValue(ip.Trim(), out pdu!);
public bool Remove(string ip)
{
if (_pdus.TryRemove(ip.Trim(), out var pdu)) { pdu.Dispose(); return true; }
return false;
}
public IReadOnlyCollection<string> ConnectedIps => _pdus.Keys.ToArray();
}
+132
View File
@@ -0,0 +1,132 @@
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using ModelContextProtocol.Server;
namespace ETTerms.PduMcp;
/// <summary>
/// MCP tools exposed to the AI for controlling an SNMP PDU (iPoMan II/III).
///
/// Unlike the serial bridge, the PDU is reached directly over SNMP — the ETTerms GUI
/// does NOT need to be running. Connections are held per-IP and persist for the lifetime
/// of this MCP server. Always <c>pdu_connect</c> an IP first, then control its ports.
/// </summary>
[McpServerToolType]
public static class PduTools
{
private static readonly JsonSerializerOptions Json = new() { WriteIndented = false };
[McpServerTool, Description("Connect to a PDU over SNMP by IP and verify it responds. Required before controlling ports. Returns the model name on success.")]
public static Task<string> pdu_connect(
[Description("PDU IP address, e.g. 192.168.1.21")] string ip)
{
ip = (ip ?? "").Trim();
if (ip.Length == 0) return Err("ip is required");
var pdu = PduRegistry.Instance.GetOrAdd(ip);
var model = pdu.GetModelName();
bool ok = !string.IsNullOrEmpty(model) && model.Contains("PDU");
if (!ok)
{
PduRegistry.Instance.Remove(ip);
return Err($"no SNMP response from PDU at {ip} (check IP/network/community)");
}
return Ok(new { ip, model });
}
[McpServerTool, Description("List PDUs currently connected in this MCP session (by IP).")]
public static Task<string> pdu_list()
=> Ok(new { connected = PduRegistry.Instance.ConnectedIps });
[McpServerTool, Description("Turn a PDU outlet on or off. The PDU must be connected first via pdu_connect.")]
public static Task<string> pdu_set_port(
[Description("PDU IP address")] string ip,
[Description("Outlet/port number (1-12)")] int port,
[Description("true = ON, false = OFF")] bool on)
{
if (!TryResolve(ip, port, out var pdu, out var error)) return Err(error);
bool ok = on ? pdu.SetPortOn(port) : pdu.SetPortOff(port);
if (!ok) return Err($"SNMP set failed for {ip} port {port}");
return Ok(new { ip, port, state = on ? "on" : "off" });
}
[McpServerTool, Description("Read a single outlet's state, current (mA) and power (W).")]
public static Task<string> pdu_get_port(
[Description("PDU IP address")] string ip,
[Description("Outlet/port number (1-12)")] int port)
{
if (!TryResolve(ip, port, out var pdu, out var error)) return Err(error);
return Ok(PortSnapshot(pdu, port));
}
[McpServerTool, Description("Read the state, current (mA) and power (W) of all outlets on the PDU.")]
public static Task<string> pdu_status(
[Description("PDU IP address")] string ip)
{
if (!PduRegistry.Instance.TryGet(ip, out var pdu))
return Err($"PDU {ip} not connected. Call pdu_connect first.");
var ports = new List<object>();
for (int p = 1; p <= PduRegistry.PortCount; p++)
ports.Add(PortSnapshot(pdu, p));
return Ok(new { ip, ports });
}
[McpServerTool, Description("Power-cycle an outlet: turn it OFF, wait offSeconds, then turn it ON. Useful for rebooting a DUT.")]
public static async Task<string> pdu_power_cycle(
[Description("PDU IP address")] string ip,
[Description("Outlet/port number (1-12)")] int port,
[Description("Seconds to stay off before powering back on")] int offSeconds = 5)
{
if (!TryResolve(ip, port, out var pdu, out var error)) return await Err(error);
if (offSeconds < 0) offSeconds = 0;
if (!pdu.SetPortOff(port)) return await Err($"SNMP off failed for {ip} port {port}");
await Task.Delay(offSeconds * 1000);
if (!pdu.SetPortOn(port)) return await Err($"SNMP on failed for {ip} port {port}");
return await Ok(new { ip, port, action = "power_cycle", offSeconds, state = "on" });
}
[McpServerTool, Description("Disconnect a PDU from this MCP session (does not change outlet states).")]
public static Task<string> pdu_disconnect(
[Description("PDU IP address")] string ip)
=> Ok(new { ip, removed = PduRegistry.Instance.Remove(ip) });
// ── helpers ──
private static bool TryResolve(string ip, int port, out PduController pdu, out string error)
{
pdu = null!;
error = "";
if (port < 1 || port > PduRegistry.PortCount)
{
error = $"port must be 1-{PduRegistry.PortCount}";
return false;
}
if (!PduRegistry.Instance.TryGet(ip, out pdu))
{
error = $"PDU {ip} not connected. Call pdu_connect first.";
return false;
}
return true;
}
private static object PortSnapshot(PduController pdu, int port)
{
var state = pdu.GetPortState(port);
return new
{
port,
state = state == true ? "on" : state == false ? "off" : "unknown",
currentMilliAmps = pdu.GetPortCurrent(port),
powerWatts = pdu.GetPortPowerWatts(port)
};
}
private static Task<string> Ok(object payload)
{
var node = new { ok = true, result = payload };
return Task.FromResult(JsonSerializer.Serialize(node, Json));
}
private static Task<string> Err(string message)
=> Task.FromResult(JsonSerializer.Serialize(new { ok = false, error = message }, Json));
}
+17
View File
@@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
// stdio MCP server:把 SNMP PDU 控制暴露給 AIKiro / Claude CLI)。
// 與 ETTerms.SerialMcp 不同,PDU 走 SNMP(UDP) 非獨佔,故直接打 SNMP,不需 GUI 在跑。
var builder = Host.CreateApplicationBuilder(args);
// stdio 傳輸:stdout 專供 JSON-RPClog 一律走 stderr,否則會污染協議。
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
+66 -20
View File
@@ -21,7 +21,7 @@ public sealed class AboutView : UserControl
var left = new FlowLayoutPanel
{
Dock = DockStyle.Left, Width = 380, FlowDirection = FlowDirection.TopDown,
WrapContents = false, AutoScroll = false, Padding = new Padding(20),
WrapContents = false, AutoScroll = true, Padding = new Padding(20),
BackColor = Theme.WorkspaceBack
};
@@ -73,7 +73,7 @@ public sealed class AboutView : UserControl
{
var header = new Label
{
AutoSize = false, Width = 500, Height = 22,
AutoSize = true,
Text = $"● v{entry.Version} · {entry.Title} ({entry.Date:yyyy-MM-dd})",
ForeColor = Theme.Accent, Font = Theme.UiFontBold,
Margin = new Padding(0, 8, 0, 2)
@@ -83,7 +83,7 @@ public sealed class AboutView : UserControl
{
right.Controls.Add(new Label
{
AutoSize = false, Width = 500, Height = 20,
AutoSize = true,
Text = $" • {change}",
ForeColor = Theme.Text, Font = Theme.UiFont,
Margin = new Padding(0)
@@ -99,18 +99,17 @@ public sealed class AboutView : UserControl
private static Panel MakeCard(int width, int height, Action<FlowLayoutPanel> build)
{
var card = new Panel
// 卡片本身就是 TopDown 的 AutoSize FlowLayoutPanel:高度永遠長到容得下內容
// (高 DPI 字變大也不會被裁),MinimumSize 維持 100% 的設計尺寸。
var card = new FlowLayoutPanel
{
Width = width, Height = height, Margin = new Padding(0, 0, 0, 12),
FlowDirection = FlowDirection.TopDown, WrapContents = false,
AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(width, height),
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
};
build(flow);
card.Controls.Add(flow);
build(card);
return card;
}
@@ -149,24 +148,31 @@ public sealed class AboutView : UserControl
{
return new Label
{
AutoSize = false, Width = 310, Height = 20,
AutoSize = true,
Text = text, Font = font, ForeColor = color,
TextAlign = align, Margin = new Padding(0)
TextAlign = align, Margin = new Padding(0, 1, 0, 1)
};
}
private static Panel MakeRow(string label, string value)
private static Control MakeRow(string label, string value)
{
var row = new Panel { Width = 310, Height = 20, Margin = new Padding(0, 2, 0, 0), BackColor = Color.Transparent };
var row = new FlowLayoutPanel
{
AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
FlowDirection = FlowDirection.LeftToRight, WrapContents = false,
Margin = new Padding(0, 2, 0, 0), BackColor = Color.Transparent
};
row.Controls.Add(new Label
{
Text = value, AutoSize = false, Width = 230, Height = 20, Dock = DockStyle.Right,
ForeColor = Theme.Text, Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleRight
Text = label, AutoSize = true, MinimumSize = new Size(70, 0),
ForeColor = Theme.TextDim, Font = Theme.UiFont,
TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 0, 8, 0)
});
row.Controls.Add(new Label
{
Text = label, AutoSize = false, Width = 70, Height = 20, Dock = DockStyle.Left,
ForeColor = Theme.TextDim, Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleLeft
Text = value, AutoSize = true,
ForeColor = Theme.Text, Font = Theme.UiFont,
TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0)
});
return row;
}
@@ -177,6 +183,46 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog =
[
new("0.3.3", new DateOnly(2026, 6, 16), "Bugfix — terminal input no longer dies after activity",
[
"Fixed: after the terminal had printed output (Serial or PowerShell), switching away and back — minimizing, or clicking another window — left the terminal unable to accept any typing or Enter, forcing you to reopen the session.",
"Cause: once enough output scrolled by, the terminal's scrollbar could steal the keyboard focus when the window regained focus. The scrollbar is now mouse-only and never takes focus, so input keeps working.",
]),
new("0.3.2", new DateOnly(2026, 6, 11), "New Status page — control PDU outlets with buttons",
[
"New Status page (the ⚡ icon on the left) with a PDU tab — connect to your PDU by IP and see every outlet at a glance.",
"Each outlet now has its own on/off button: the button shows \"Turn ON\" or \"Turn OFF\" depending on the current state, so one click flips it.",
"Outlet status, current and power refresh automatically every 3 seconds — no need to hit Refresh anymore.",
"Fixed: the local Shell no longer fails to start when its saved folder is gone (e.g. an unplugged USB drive); it now falls back to your home folder.",
]),
new("0.3.1", new DateOnly(2026, 6, 8), "Bugfix — terminal usability",
[
"Added a scrollbar on the right of the terminal — just drag it to look back through long output, instead of spinning the mouse wheel for ages.",
"Fixed pasting multiple lines into Kiro CLI (and similar tools): the whole block now pastes in one go, instead of each line being sent off immediately.",
"After you right-click to copy, the highlight now clears so you can tell the copy actually worked.",
]),
new("0.3.0", new DateOnly(2026, 6, 8), "AI power control for PDU outlets",
[
"AI assistants (Kiro / Claude) can now switch your PDU power outlets on and off for you.",
"Great for automated testing — the AI can power-cycle (restart) a connected device by itself.",
"Works even when the main ETTerms window isn't open.",
"One click in Settings → AI MCP now sets up both the Serial and PDU AI helpers at the same time.",
]),
new("0.2.2", new DateOnly(2026, 6, 5), "Bugfix — terminal stability",
[
"Fixed: the terminal no longer freezes after minimizing or switching tabs (notably in PowerShell / Kiro).",
"Added: Shift+Enter inserts a newline in the shell, so you can type multi-line commands.",
]),
new("0.2.1", new DateOnly(2026, 6, 4), "AI MCP one-click setup",
[
"New Settings → AI MCP tab: register the Serial MCP server into Claude Code or Kiro with one click.",
"Lets AI agents drive the serial port for you.",
]),
new("0.2.0", new DateOnly(2026, 6, 4), "Beta Version Release",
[
"AI agents can now send/receive on the serial port while you watch it live in the GUI.",
"New app icon and version shown in the title bar.",
]),
new("0.1.0", new DateOnly(2026, 6, 3), "Initial Release",
[
"Beta Version: expect bugs and missing features. Feedback welcome!",
+18 -2
View File
@@ -10,17 +10,20 @@ namespace ETTerms.App;
/// </summary>
public sealed class ActivityRail : UserControl
{
public enum RailView { Terminal, Settings, About }
public enum RailView { Terminal, Status, Settings, About }
public event EventHandler<RailView>? ViewSelected;
private RailView _active = RailView.Terminal;
private RailView? _hover;
private const int ItemSize = 56;
private const int BaseItemSize = 56;
/// <summary>依目前螢幕 DPI 縮放的格子大小(owner-draw 不會自動縮放像素,需自己換算)。</summary>
private int ItemSize => (int)Math.Round(BaseItemSize * (DeviceDpi / 96.0));
private static readonly (RailView view, string glyph, string tip)[] Items =
{
(RailView.Terminal, "▤", "Terminal"),
(RailView.Status, "⚡", "Status"),
(RailView.Settings, "⚙", "Settings"),
(RailView.About, "", "About"),
};
@@ -37,6 +40,19 @@ public sealed class ActivityRail : UserControl
Cursor = Cursors.Hand;
}
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
Width = ItemSize; // handle 建立後 DeviceDpi 才正確,依實際 DPI 重設寬度
}
protected override void OnDpiChangedAfterParent(EventArgs e)
{
base.OnDpiChangedAfterParent(e);
Width = ItemSize; // 拖到不同縮放的螢幕時跟著調整
Invalidate();
}
[System.ComponentModel.DesignerSerializationVisibility(
System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public RailView ActiveView
+9 -4
View File
@@ -49,10 +49,11 @@ public sealed class ConnectionSidebar : UserControl
BackColor = Theme.SidebarBack;
// ── Tab bar (Sessions / SFTP) ──
// 高度 40 與右側 Workspace 工具列對齊,讓「CONNECTIONS / 連線清單」與右側分頁列同一條基準線
var tabBar = new FlowLayoutPanel
{
Dock = DockStyle.Top, Height = 30, BackColor = Theme.RailBack,
Padding = new Padding(4, 3, 4, 0), WrapContents = false
Dock = DockStyle.Top, Height = 40, BackColor = Theme.RailBack,
Padding = new Padding(4, 7, 4, 0), WrapContents = false
};
var sessionsPanel = new Panel { Dock = DockStyle.Fill, BackColor = Theme.SidebarBack };
@@ -63,7 +64,9 @@ public sealed class ConnectionSidebar : UserControl
{
var b = new Button
{
Text = text, Width = 70, Height = 24, FlatStyle = FlatStyle.Flat,
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(70, 24), Padding = new Padding(8, 2, 8, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand
};
@@ -217,7 +220,9 @@ public sealed class ConnectionSidebar : UserControl
var sshCombo = new ComboBox { Width = 120, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont };
var connectBtn = new Button
{
Text = "Connect", Width = 64, Height = 24, FlatStyle = FlatStyle.Flat,
Text = "Connect", AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(64, 24), Padding = new Padding(8, 2, 8, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand, Margin = new Padding(4, 0, 0, 0)
};
connectBtn.FlatAppearance.BorderColor = Theme.SerialColor;
+4 -2
View File
@@ -18,8 +18,10 @@ partial class MainForm
{
SuspendLayout();
AutoScaleDimensions = new SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
// 用 Dpi 自動縮放:尺寸縮放比例 = 螢幕 DPI 比例,與點數字型的實際渲染比例一致,
// 避免「字放大了、容器沒放大」造成的高 DPI 文字/按鈕被裁切。
AutoScaleDimensions = new SizeF(96F, 96F);
AutoScaleMode = AutoScaleMode.Dpi;
ClientSize = new Size(1100, 700);
MinimumSize = new Size(720, 480);
BackColor = Theme.WorkspaceBack;
+5 -1
View File
@@ -14,6 +14,7 @@ public partial class MainForm : Form
private readonly ActivityRail _rail = new();
private readonly ConnectionSidebar _sidebar = new();
private readonly WorkspaceView _workspace = new();
private readonly StatusView _statusView = new();
private readonly SettingsView _settings = new();
private readonly AboutView _about = new();
private readonly StatusStrip _status = new();
@@ -39,17 +40,19 @@ public partial class MainForm : Form
var v = Assembly.GetExecutingAssembly().GetName().Version;
var versionStr = v != null ? $"{v.Major}.{v.Minor}.{v.Build}" : "0.1.0";
Text = $"ETTerms Version v{versionStr}";
Text = $"ETTerms v{versionStr}";
}
private void BuildLayout()
{
Controls.Add(_workspace); // Fill
Controls.Add(_statusView); // Fill (hidden)
Controls.Add(_settings); // Fill (hidden)
Controls.Add(_about); // Fill (hidden)
Controls.Add(_sidebar); // Left (內側)
Controls.Add(_rail); // Left (最外側)
_statusView.Visible = false;
_settings.Visible = false;
_about.Visible = false;
@@ -68,6 +71,7 @@ public partial class MainForm : Form
_statusLabel.Text = $"View: {view}";
_sidebar.Visible = view == ActivityRail.RailView.Terminal;
_workspace.Visible = view == ActivityRail.RailView.Terminal;
_statusView.Visible = view == ActivityRail.RailView.Status;
_settings.Visible = view == ActivityRail.RailView.Settings;
_about.Visible = view == ActivityRail.RailView.About;
AppLogger.LogInfo($"View selected: {view}");
+172 -110
View File
@@ -1,11 +1,10 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.Infrastructure;
using ETTerms.Scripting.Pdu;
namespace ETTerms.App;
/// <summary>Settings page with tabs: Terminal / PDU.</summary>
/// <summary>Settings page with tabs: Terminal / AI MCP.</summary>
public sealed class SettingsView : UserControl
{
public SettingsView()
@@ -20,22 +19,22 @@ public sealed class SettingsView : UserControl
Padding = new Padding(4, 4, 4, 0), WrapContents = false
};
var terminalPage = BuildTerminalTab();
var pduPage = BuildPduTab();
terminalPage.Dock = DockStyle.Fill;
pduPage.Dock = DockStyle.Fill;
pduPage.Visible = false;
var body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack };
body.Controls.Add(terminalPage);
body.Controls.Add(pduPage);
var pages = new List<Panel>();
Button? activeBtn = null;
Button MakeTab(string text, Panel page)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
body.Controls.Add(page);
pages.Add(page);
var b = new Button
{
Text = text, AutoSize = false, Width = 80, Height = 26, FlatStyle = FlatStyle.Flat,
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(70, 26), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand
};
@@ -43,8 +42,7 @@ public sealed class SettingsView : UserControl
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
b.Click += (_, _) =>
{
terminalPage.Visible = page == terminalPage;
pduPage.Visible = page == pduPage;
foreach (var p in pages) p.Visible = p == page;
if (activeBtn != null) activeBtn.BackColor = Theme.TabBack;
b.BackColor = Theme.TabActiveBack;
activeBtn = b;
@@ -52,17 +50,15 @@ public sealed class SettingsView : UserControl
return b;
}
var termBtn = MakeTab("Terminal", terminalPage);
var pduBtn = MakeTab("PDU", pduPage);
var termBtn = MakeTab("Terminal", BuildTerminalTab());
tabBar.Controls.Add(termBtn);
tabBar.Controls.Add(pduBtn);
// Set initial active
termBtn.BackColor = Theme.TabActiveBack;
activeBtn = termBtn;
tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab()));
Controls.Add(body);
Controls.Add(tabBar);
// Set initial active tab
termBtn.PerformClick();
}
// ═══ Terminal Tab ═══
@@ -175,8 +171,8 @@ public sealed class SettingsView : UserControl
return page;
}
// ═══ PDU Tab ═══
private Panel BuildPduTab()
// ═══ AI MCP Tab ═══
private Panel BuildAiMcpTab()
{
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
@@ -186,115 +182,179 @@ public sealed class SettingsView : UserControl
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
};
// Connection row
var ipBox = new TextBox { Width = 160, Text = "192.168.1.21", BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont };
var connectBtn = MakeButton("Connect", Theme.SerialColor);
var statusLabel = new Label { AutoSize = true, Text = "Disconnected", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(8, 8, 0, 0) };
var connRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Width = 500, Height = 36, WrapContents = false, Margin = new Padding(0, 0, 0, 8) };
connRow.Controls.Add(new Label { Text = "PDU IP:", AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0) });
connRow.Controls.Add(ipBox);
connRow.Controls.Add(connectBtn);
connRow.Controls.Add(statusLabel);
flow.Controls.Add(connRow);
// Port status grid
var grid = new DataGridView
flow.Controls.Add(new Label
{
Width = 480, Height = 310, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border,
BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal,
DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text },
ColumnHeadersDefaultCellStyle = { BackColor = Theme.RailBack, ForeColor = Theme.Accent, Font = Theme.UiFontBold },
ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single,
EnableHeadersVisualStyles = false, RowHeadersVisible = false,
AllowUserToAddRows = false, AllowUserToDeleteRows = false, ReadOnly = true,
AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
ScrollBars = ScrollBars.None, Font = Theme.UiFont,
RowTemplate = { Height = 24 },
Margin = new Padding(0, 8, 0, 8)
};
grid.Columns.Add("Port", "Port");
grid.Columns.Add("Status", "Status");
grid.Columns.Add("Current", "Current (mA)");
grid.Columns.Add("Power", "Power (W)");
for (int i = 1; i <= 12; i++)
grid.Rows.Add($"Port {i}", "—", "—", "—");
flow.Controls.Add(grid);
// Refresh button
var refreshBtn = MakeButton("Refresh", Theme.Accent);
flow.Controls.Add(refreshBtn);
// Logic
PduController? pdu = null;
connectBtn.Click += (_, _) =>
Text = "AI MCP Integration", AutoSize = true,
ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4)
});
flow.Controls.Add(new Label
{
if (pdu != null) { pdu.Dispose(); pdu = null; statusLabel.Text = "Disconnected"; statusLabel.ForeColor = Theme.TextDim; connectBtn.Text = "Connect"; return; }
var ip = ipBox.Text.Trim();
var p = new PduController(ip);
if (p.CheckConnection())
Text = "One-click register the ETTerms MCP servers (Serial + PDU) into your AI CLI's\n" +
"user-level config. Serial: ETTerms owns the COM port, the AI drives it through a\n" +
"local named pipe (open a Serial session first). PDU: the AI controls outlets\n" +
"directly over SNMP — no GUI session required.",
AutoSize = false, Width = 600, Height = 64,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
});
// Resolved MCP server exes (serial + pdu)
foreach (var (name, exe, exists) in McpRegistrar.ServerInfos())
{
pdu = p;
statusLabel.Text = $"Connected to {ip}";
statusLabel.ForeColor = Theme.SerialColor;
connectBtn.Text = "Disconnect";
RefreshPduGrid(pdu, grid);
flow.Controls.Add(new Label
{
Text = $"{name}: {exe}",
AutoSize = false, Width = 600, Height = 20,
ForeColor = exists ? Theme.SerialColor : Color.FromArgb(210, 150, 120),
Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2)
});
}
else
if (!McpRegistrar.ServerExeExists())
{
p.Dispose();
MessageBox.Show(this, $"Failed to connect to PDU at {ip}", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Warning);
flow.Controls.Add(new Label
{
Text = "⚠ Some servers not built yet — publish the app (or build the MCP projects). Setup still writes the expected paths.",
AutoSize = false, Width = 600, Height = 20,
ForeColor = Color.FromArgb(210, 150, 120), Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 4)
});
}
};
refreshBtn.Click += (_, _) =>
{
if (pdu == null) { MessageBox.Show(this, "Connect to PDU first.", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Information); return; }
RefreshPduGrid(pdu, grid);
};
flow.Controls.Add(MakeSpacer(10));
flow.Controls.Add(BuildMcpTargetCard(McpTarget.Claude));
flow.Controls.Add(MakeSpacer(10));
flow.Controls.Add(BuildMcpTargetCard(McpTarget.Kiro));
page.Controls.Add(flow);
return page;
}
private static void RefreshPduGrid(PduController pdu, DataGridView grid)
/// <summary>單一 AI 目標(Claude / Kiro)的設定卡:狀態 + Setup / Remove + CLI 驗證指令。</summary>
private Panel BuildMcpTargetCard(McpTarget target)
{
for (int i = 0; i < 12; i++)
var card = new Panel
{
int port = i + 1;
var state = pdu.GetPortState(port);
var current = pdu.GetPortCurrent(port);
var power = pdu.GetPortPowerWatts(port);
var row = grid.Rows[i];
row.Cells["Status"].Value = state == true ? "ON" : state == false ? "OFF" : "—";
row.Cells["Current"].Value = current.HasValue ? $"{current.Value}" : "—";
row.Cells["Power"].Value = power.HasValue ? $"{power.Value:F1}" : "—";
row.DefaultCellStyle.BackColor = state == true ? Color.FromArgb(40, 80, 40) : state == false ? Color.FromArgb(60, 40, 40) : Theme.TabBack;
Width = 600, Height = 196, BackColor = Theme.TabBack,
Padding = new Padding(14), Margin = new Padding(0, 0, 0, 4)
};
var col = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.TabBack, AutoSize = false
};
var title = new Label
{
Text = McpRegistrar.DisplayName(target), AutoSize = true,
ForeColor = Theme.Text, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 2)
};
var status = new Label { AutoSize = true, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2) };
var pathLbl = new Label
{
Text = $"Config: {McpRegistrar.ConfigPath(target)}",
AutoSize = false, Width = 560, Height = 18,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 6)
};
var setupBtn = MakeButton("Setup", Theme.Accent);
var removeBtn = MakeButton("Remove", Color.FromArgb(210, 120, 120));
setupBtn.Margin = new Padding(0, 0, 8, 0);
var btnRow = new FlowLayoutPanel
{
FlowDirection = FlowDirection.LeftToRight, AutoSize = true,
WrapContents = false, BackColor = Theme.TabBack, Margin = new Padding(0, 0, 0, 8)
};
btnRow.Controls.Add(setupBtn);
btnRow.Controls.Add(removeBtn);
var verifyLbl = new Label
{
Text = "Verify in your CLI:", AutoSize = true,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2)
};
var verifyBox = new TextBox
{
Multiline = true, ReadOnly = true, Width = 560, Height = 56,
BackColor = Color.FromArgb(20, 20, 24), ForeColor = Color.FromArgb(200, 200, 200),
BorderStyle = BorderStyle.FixedSingle, Font = new Font("Cascadia Mono", 9f),
Text = McpRegistrar.VerifyHint(target)
};
void Refresh()
{
bool reg = McpRegistrar.IsRegistered(target);
status.Text = reg ? "● Configured" : "○ Not configured";
status.ForeColor = reg ? Theme.SerialColor : Theme.TextDim;
removeBtn.Enabled = reg;
}
setupBtn.Click += (_, _) =>
{
try
{
McpRegistrar.Register(target);
Refresh();
MessageBox.Show(this,
$"{McpRegistrar.DisplayName(target)} is now configured.\n\n" +
"Restart your AI CLI (or open a new session), then run the verify command shown below.",
"AI MCP", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, $"Failed to write config:\n{ex.Message}",
"AI MCP", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
};
removeBtn.Click += (_, _) =>
{
try
{
McpRegistrar.Unregister(target);
Refresh();
MessageBox.Show(this,
$"Removed from {McpRegistrar.DisplayName(target)}.\nRestart your AI CLI for it to take effect.",
"AI MCP", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, $"Failed to update config:\n{ex.Message}",
"AI MCP", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
};
Refresh();
col.Controls.Add(title);
col.Controls.Add(status);
col.Controls.Add(pathLbl);
col.Controls.Add(btnRow);
col.Controls.Add(verifyLbl);
col.Controls.Add(verifyBox);
card.Controls.Add(col);
return card;
}
// ── Helpers ──
private const int LabelWidth = 150; // 標籤欄固定寬度
private const int LabelWidth = 150; // 標籤欄最小寬度
private const int InputWidth = 200; // 所有輸入框統一寬度
private static Panel MakeRow(string label, Control control)
private static Control MakeRow(string label, Control control)
{
var row = new Panel { Width = LabelWidth + InputWidth, Height = 32, Margin = new Padding(0, 3, 0, 3) };
// 統一輸入框寬度 + 左緣對齊(不再 Dock.Right,避免右對齊造成左緣參差)
control.Width = InputWidth;
int top = (row.Height - control.Height) / 2;
control.Location = new Point(LabelWidth, top < 0 ? 0 : top);
// 用 FlowLayout + AutoSize 標籤取代絕對定位,讓高 DPI 下標籤變寬也不會被裁、且對齊穩定
var row = new FlowLayoutPanel
{
AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
FlowDirection = FlowDirection.LeftToRight, WrapContents = false,
Margin = new Padding(0, 3, 0, 3), BackColor = Color.Transparent
};
var lbl = new Label
{
Text = label, AutoSize = true, MinimumSize = new Size(LabelWidth, 0),
ForeColor = Theme.Text, Font = Theme.UiFont,
TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 6, 8, 0)
};
if (control.Width <= 0) control.Width = InputWidth;
row.Controls.Add(lbl);
row.Controls.Add(control);
row.Controls.Add(new Label
{
Text = label, AutoSize = false, Width = LabelWidth, Height = row.Height,
Location = new Point(0, 0), ForeColor = Theme.Text, Font = Theme.UiFont,
TextAlign = ContentAlignment.MiddleLeft
});
return row;
}
@@ -302,7 +362,9 @@ public sealed class SettingsView : UserControl
{
var b = new Button
{
Text = text, Width = 90, Height = 28, FlatStyle = FlatStyle.Flat,
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(90, 28), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Cursor = Cursors.Hand, Margin = new Padding(8, 0, 0, 0)
};
+288
View File
@@ -0,0 +1,288 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.Scripting.Pdu;
namespace ETTerms.App;
/// <summary>
/// Status page with tabs: PDU (more views to come).
/// 風格參考 <see cref="SettingsView"/>:自繪 tab strip + panel 切換,避免 TabControl 白邊。
/// PDU 分頁連線後每 3 秒於背景自動輪詢插座狀態(不需手動 Refresh)。
/// </summary>
public sealed class StatusView : UserControl
{
public StatusView()
{
Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack;
var tabBar = new FlowLayoutPanel
{
Dock = DockStyle.Top, Height = 32, BackColor = Theme.RailBack,
Padding = new Padding(4, 4, 4, 0), WrapContents = false
};
var body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack };
var pages = new List<Panel>();
Button? activeBtn = null;
Button MakeTab(string text, Panel page)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
body.Controls.Add(page);
pages.Add(page);
var b = new Button
{
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(70, 26), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand
};
b.FlatAppearance.BorderColor = Theme.Border;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
b.Click += (_, _) =>
{
foreach (var p in pages) p.Visible = p == page;
if (activeBtn != null) activeBtn.BackColor = Theme.TabBack;
b.BackColor = Theme.TabActiveBack;
activeBtn = b;
};
return b;
}
var pduBtn = MakeTab("PDU", BuildPduTab());
tabBar.Controls.Add(pduBtn);
Controls.Add(body);
Controls.Add(tabBar);
pduBtn.PerformClick();
}
// ═══ PDU Tab ═══
private Panel BuildPduTab()
{
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
var flow = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
};
// Connection row
var ipBox = new TextBox { Width = 160, Text = "192.168.1.21", BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont };
var connectBtn = MakeButton("Connect", Theme.SerialColor);
var statusLabel = new Label { AutoSize = true, Text = "Disconnected", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(8, 8, 0, 0) };
var connRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Width = 500, Height = 36, WrapContents = false, Margin = new Padding(0, 0, 0, 8) };
connRow.Controls.Add(new Label { Text = "PDU IP:", AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0) });
connRow.Controls.Add(ipBox);
connRow.Controls.Add(connectBtn);
connRow.Controls.Add(statusLabel);
flow.Controls.Add(connRow);
// Port status grid
var grid = new DataGridView
{
Width = 480, Height = 310, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border,
BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal,
DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text },
ColumnHeadersDefaultCellStyle = { BackColor = Theme.RailBack, ForeColor = Theme.Accent, Font = Theme.UiFontBold },
ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single,
EnableHeadersVisualStyles = false, RowHeadersVisible = false,
AllowUserToAddRows = false, AllowUserToDeleteRows = false, ReadOnly = true,
AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
ScrollBars = ScrollBars.None, Font = Theme.UiFont,
RowTemplate = { Height = 24 },
Margin = new Padding(0, 8, 0, 8)
};
grid.Columns.Add("Port", "Port");
grid.Columns.Add("Status", "Status");
grid.Columns.Add("Current", "Current (mA)");
grid.Columns.Add("Power", "Power (W)");
// 控制按鈕欄:按一下切換該 Port 的 ON/OFF
var actionCol = new DataGridViewButtonColumn
{
Name = "Action", HeaderText = "Control",
UseColumnTextForButtonValue = false, FlatStyle = FlatStyle.Flat,
FillWeight = 80,
DefaultCellStyle =
{
BackColor = Theme.TabBack, ForeColor = Theme.Text,
SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text,
Alignment = DataGridViewContentAlignment.MiddleCenter
}
};
grid.Columns.Add(actionCol);
for (int i = 1; i <= 12; i++)
grid.Rows.Add($"Port {i}", "—", "—", "—", "—");
flow.Controls.Add(grid);
// 連線後每 3 秒自動輪詢(背景執行緒讀 SNMP,Invoke 回 UI 更新)
var pollLabel = new Label { AutoSize = true, Text = "", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 4, 0, 0) };
flow.Controls.Add(pollLabel);
// Logic
PduController? pdu = null;
System.Threading.Timer? timer = null;
int polling = 0; // 0=idle, 1=in-flight(避免上一次未讀完又疊一次)
void StopPolling()
{
timer?.Dispose();
timer = null;
}
void PollOnce()
{
// 已在輪詢中就跳過這一輪
if (System.Threading.Interlocked.Exchange(ref polling, 1) == 1) return;
var current = pdu;
if (current == null) { System.Threading.Volatile.Write(ref polling, 0); return; }
try
{
var rows = new (bool? state, int? current, double? power)[12];
for (int i = 0; i < 12; i++)
{
int port = i + 1;
rows[i] = (current.GetPortState(port), current.GetPortCurrent(port), current.GetPortPowerWatts(port));
}
if (!IsDisposed && IsHandleCreated)
{
BeginInvoke(new Action(() =>
{
if (pdu != current) return; // 期間已斷線
ApplyPduRows(grid, rows);
pollLabel.Text = $"Auto-refresh every 3s · last update {DateTime.Now:HH:mm:ss}";
}));
}
}
catch { /* 輪詢失敗忽略,下一輪再試 */ }
finally { System.Threading.Volatile.Write(ref polling, 0); }
}
connectBtn.Click += (_, _) =>
{
if (pdu != null)
{
StopPolling();
pdu.Dispose(); pdu = null;
statusLabel.Text = "Disconnected"; statusLabel.ForeColor = Theme.TextDim;
connectBtn.Text = "Connect";
pollLabel.Text = "";
// 斷線後清空表格,避免顯示過時的狀態
foreach (DataGridViewRow row in grid.Rows)
{
row.Cells["Status"].Value = "—";
row.Cells["Current"].Value = "—";
row.Cells["Power"].Value = "—";
row.Cells["Action"].Value = "—";
row.DefaultCellStyle.BackColor = Theme.TabBack;
}
return;
}
var ip = ipBox.Text.Trim();
var p = new PduController(ip);
if (p.CheckConnection())
{
pdu = p;
statusLabel.Text = $"Connected to {ip}";
statusLabel.ForeColor = Theme.SerialColor;
connectBtn.Text = "Disconnect";
// 立即讀一次,之後每 3 秒一次
timer = new System.Threading.Timer(_ => PollOnce(), null,
dueTime: 0, period: 3000);
}
else
{
p.Dispose();
MessageBox.Show(this, $"Failed to connect to PDU at {ip}", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
};
// 控制項銷毀時收掉計時器與連線
Disposed += (_, _) => { StopPolling(); pdu?.Dispose(); };
// 按下 Control 欄按鈕:切換該 Port 的 ON/OFF(SNMP Set 在背景執行,避免卡 UI)
grid.CellContentClick += (_, e) =>
{
if (e.RowIndex < 0 || e.ColumnIndex < 0) return;
if (grid.Columns[e.ColumnIndex].Name != "Action") return;
var current = pdu;
if (current == null)
{
MessageBox.Show(this, "PDU is not connected. Please connect first.",
"PDU", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
int port = e.RowIndex + 1;
var statusVal = grid.Rows[e.RowIndex].Cells["Status"].Value?.ToString();
if (statusVal != "ON" && statusVal != "OFF") return; // 狀態未知時不動作
bool turnOn = statusVal != "ON"; // 目前 ON → 關;其餘 → 開
var actionCell = grid.Rows[e.RowIndex].Cells["Action"];
actionCell.Value = "…";
System.Threading.Tasks.Task.Run(() =>
{
bool ok = turnOn ? current.SetPortOn(port) : current.SetPortOff(port);
System.Threading.Thread.Sleep(400); // 等 PDU 套用後再讀回確認
PollOnce(); // 背景讀 SNMP 後 Invoke 回 UI 更新整張表
if (!ok && !IsDisposed && IsHandleCreated)
{
BeginInvoke(new Action(() =>
MessageBox.Show(this,
$"Failed to turn {(turnOn ? "ON" : "OFF")} Port {port}",
"PDU", MessageBoxButtons.OK, MessageBoxIcon.Error)));
}
});
};
page.Controls.Add(flow);
return page;
}
private static void ApplyPduRows(DataGridView grid, (bool? state, int? current, double? power)[] rows)
{
for (int i = 0; i < rows.Length && i < grid.Rows.Count; i++)
{
var (state, current, power) = rows[i];
var row = grid.Rows[i];
row.Cells["Status"].Value = state == true ? "ON" : state == false ? "OFF" : "—";
row.Cells["Current"].Value = current.HasValue ? $"{current.Value}" : "—";
row.Cells["Power"].Value = power.HasValue ? $"{power.Value:F1}" : "—";
// 按鈕文字代表「按下後會做的動作」:ON 時顯示 Turn OFF,反之亦然
row.Cells["Action"].Value = state == true ? "Turn OFF" : state == false ? "Turn ON" : "—";
row.DefaultCellStyle.BackColor = state == true ? Color.FromArgb(40, 80, 40) : state == false ? Color.FromArgb(60, 40, 40) : Theme.TabBack;
}
}
// ── Helpers ──
private static Button MakeButton(string text, Color borderColor)
{
var b = new Button
{
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(90, 28), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Cursor = Cursors.Hand, Margin = new Padding(8, 0, 0, 0)
};
b.FlatAppearance.BorderColor = borderColor;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
return b;
}
}
+9 -4
View File
@@ -52,7 +52,8 @@ public sealed class WorkspaceView : UserControl
BackColor = Theme.WorkspaceBack;
// 頂部工具列容器:左側為 Layout/Run 群組(Fill),右側為 Log AllDock Right
var topBar = new Panel { Dock = DockStyle.Top, Height = 38, BackColor = Theme.RailBack };
// 高度 40 與左側 ConnectionSidebar 的 tab bar 對齊,且容得下 AutoSize 按鈕(不被裁切)
var topBar = new Panel { Dock = DockStyle.Top, Height = 40, BackColor = Theme.RailBack };
_toolbar = new FlowLayoutPanel
{
@@ -374,7 +375,9 @@ public sealed class WorkspaceView : UserControl
{
var b = new Button
{
Text = label, AutoSize = false, Size = new Size(44, 26), FlatStyle = FlatStyle.Flat,
Text = label, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(40, 26), Padding = new Padding(8, 2, 8, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(3, 0, 3, 0), Cursor = Cursors.Hand
};
@@ -384,11 +387,13 @@ public sealed class WorkspaceView : UserControl
return b;
}
private static Button MakeActionButton(string text, int width, int leftMargin, EventHandler onClick)
private static Button MakeActionButton(string text, int minWidth, int leftMargin, EventHandler onClick)
{
var b = new Button
{
Text = text, AutoSize = false, Size = new Size(width, 26), FlatStyle = FlatStyle.Flat,
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(minWidth, 26), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(leftMargin, 0, 3, 0), Cursor = Cursors.Hand
};
+24 -1
View File
@@ -10,10 +10,13 @@
<AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 -->
<Version>0.2.0</Version>
<Version>0.3.3</Version>
<Product>ETTerms</Product>
<Company>ETTerms Project</Company>
<!-- 高 DPIPerMonitorV2,讓不同縮放比例的螢幕都能正確縮放、避免文字/按鈕被裁切 -->
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<!-- 視窗 / 工作列 / exe 圖示 -->
<ApplicationIcon>Assets\Choco_256x256.ico</ApplicationIcon>
</PropertyGroup>
@@ -32,4 +35,24 @@
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
<!--
發佈 GUI 時,自動把 MCP serversSerial + PDU)一併發佈到 <publish>\<server>\ 子資料夾。
這樣單一 `dotnet publish src\ETTerms` 就會產生完整自洽的 bundle,
且 McpRegistrar.ResolveServerExe() 解析的 <ETTerms.exe>\<server>\<server>.exe 必定存在。
刻意放子資料夾:與 GUI 的相依 dll 隔離,避免互相覆蓋。
MCP 跟隨 GUI 的 self-contained 設定:框架相依版 → MCP 也框架相依;portable(self-contained)版 → MCP 也免 runtime。
-->
<Target Name="PublishMcpServers" AfterTargets="Publish">
<PropertyGroup>
<_McpRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</_McpRid>
<_McpRid Condition="'$(RuntimeIdentifier)' == ''">win-x64</_McpRid>
<_McpSelfContained Condition="'$(SelfContained)' != ''">$(SelfContained)</_McpSelfContained>
<_McpSelfContained Condition="'$(SelfContained)' == ''">false</_McpSelfContained>
</PropertyGroup>
<Message Importance="high" Text="[ETTerms] Publishing ETTerms.SerialMcp -> $(PublishDir)ETTerms.SerialMcp (self-contained=$(_McpSelfContained))" />
<Exec Command="dotnet publish &quot;$(MSBuildThisFileDirectory)..\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj&quot; -c $(Configuration) -r $(_McpRid) --self-contained $(_McpSelfContained) -o &quot;$(PublishDir)ETTerms.SerialMcp&quot;" />
<Message Importance="high" Text="[ETTerms] Publishing ETTerms.PduMcp -> $(PublishDir)ETTerms.PduMcp (self-contained=$(_McpSelfContained))" />
<Exec Command="dotnet publish &quot;$(MSBuildThisFileDirectory)..\ETTerms.PduMcp\ETTerms.PduMcp.csproj&quot; -c $(Configuration) -r $(_McpRid) --self-contained $(_McpSelfContained) -o &quot;$(PublishDir)ETTerms.PduMcp&quot;" />
</Target>
</Project>
+199
View File
@@ -0,0 +1,199 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ETTerms.Infrastructure;
/// <summary>支援一鍵設定 MCP 的 AI CLI 目標。</summary>
public enum McpTarget { Claude, Kiro }
/// <summary>
/// 把 ETTerms 的 MCP servers<c>ETTerms.SerialMcp</c> 與 <c>ETTerms.PduMcp</c>)一鍵註冊 /
/// 移除到各 AI CLI 的「使用者層級」MCP 設定檔。採 read-modify-write,保留檔內其他既有伺服器。
///
/// - Claude Code<c>~/.claude.json</c> 頂層 <c>mcpServers</c>entry 需 <c>type:"stdio"</c>。
/// - Kiro<c>%USERPROFILE%\.kiro\settings\mcp.json</c> 頂層 <c>mcpServers</c>。
///
/// 兩個 server 一起註冊 / 移除(一鍵同時設定 serial 與 pdu)。
/// </summary>
public static class McpRegistrar
{
/// <summary>一個可被註冊的 MCP server 描述:CLI 內名稱 + 發佈子資料夾 + 執行檔名。</summary>
public sealed record McpServer(string Name, string PublishFolder, string ExeName);
/// <summary>ETTerms 提供的所有 MCP servers。</summary>
public static readonly IReadOnlyList<McpServer> Servers = new[]
{
new McpServer("etterms-serial", "ETTerms.SerialMcp", "ETTerms.SerialMcp.exe"),
new McpServer("etterms-pdu", "ETTerms.PduMcp", "ETTerms.PduMcp.exe"),
};
public static string DisplayName(McpTarget t) => t switch
{
McpTarget.Claude => "Claude Code",
McpTarget.Kiro => "Kiro",
_ => t.ToString()
};
/// <summary>該 AI CLI 的使用者層級 MCP 設定檔路徑。</summary>
public static string ConfigPath(McpTarget t)
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return t switch
{
McpTarget.Claude => Path.Combine(home, ".claude.json"),
McpTarget.Kiro => Path.Combine(home, ".kiro", "settings", "mcp.json"),
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
}
/// <summary>給使用者在 CLI 確認是否設定成功的指令(多行)。</summary>
public static string VerifyHint(McpTarget t) => t switch
{
McpTarget.Claude =>
"claude mcp list\r\n" +
"# 應看到:etterms-serial / etterms-pdu ✓ Connected\r\n" +
"# 細節: claude mcp get etterms-pdu",
McpTarget.Kiro =>
"kiro-cli mcp list\r\n" +
"kiro-cli mcp status --name etterms-pdu\r\n" +
"# 或在 Kiro IDE:點 ghost 圖示開 MCP Servers 面板查看狀態",
_ => ""
};
/// <summary>找出某個 MCP server 執行檔路徑(找不到回傳最可能的位置作為註冊值)。</summary>
public static string ResolveServerExe(McpServer server)
{
var baseDir = AppContext.BaseDirectory;
var candidates = new List<string>
{
Path.Combine(baseDir, server.PublishFolder, server.ExeName), // 發佈版(子資料夾)
Path.Combine(baseDir, server.ExeName), // 同層
};
// 開發版 fallbacksrc\ETTerms\bin\<cfg>\net8.0-windows → src\<folder>\bin\<cfg>\net8.0
try
{
var binCfg = new DirectoryInfo(baseDir); // ...\net8.0-windows
var config = binCfg.Parent?.Name ?? "Debug"; // Debug / Release
var srcDir = binCfg.Parent?.Parent?.Parent?.Parent; // ...\src
if (srcDir != null)
candidates.Add(Path.Combine(srcDir.FullName, server.PublishFolder, "bin", config, "net8.0", server.ExeName));
}
catch { /* 路徑推導失敗就略過開發版 fallback */ }
foreach (var c in candidates)
if (File.Exists(c)) return c;
return candidates[0]; // 都找不到 → 回發佈版預期位置
}
/// <summary>所有 server 執行檔是否都存在。</summary>
public static bool ServerExeExists() => Servers.All(s => File.Exists(ResolveServerExe(s)));
/// <summary>列出每個 server 的解析路徑與是否存在(給 UI 顯示)。</summary>
public static IEnumerable<(string Name, string Exe, bool Exists)> ServerInfos()
{
foreach (var s in Servers)
{
var exe = ResolveServerExe(s);
yield return (s.Name, exe, File.Exists(exe));
}
}
/// <summary>該目標是否已註冊「全部」ETTerms MCP servers。</summary>
public static bool IsRegistered(McpTarget t)
{
try
{
var path = ConfigPath(t);
if (!File.Exists(path)) return false;
var servers = (JsonNode.Parse(File.ReadAllText(path)) as JsonObject)?["mcpServers"] as JsonObject;
if (servers == null) return false;
return Servers.All(s => servers[s.Name] != null);
}
catch { return false; }
}
/// <summary>註冊(或更新)所有 ETTerms MCP servers 到該目標設定檔。</summary>
public static void Register(McpTarget t)
{
var path = ConfigPath(t);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var root = LoadRoot(path);
if (root["mcpServers"] is not JsonObject servers)
{
servers = new JsonObject();
root["mcpServers"] = servers;
}
foreach (var s in Servers)
servers[s.Name] = BuildEntry(t, s);
WriteRoot(path, root);
AppLogger.Info($"MCP registered to {DisplayName(t)} at {path}");
}
/// <summary>從該目標設定檔移除所有 ETTerms MCP servers。</summary>
public static void Unregister(McpTarget t)
{
var path = ConfigPath(t);
if (!File.Exists(path)) return;
var root = LoadRoot(path);
if (root["mcpServers"] is JsonObject servers)
{
bool changed = false;
foreach (var s in Servers)
changed |= servers.Remove(s.Name);
if (changed)
{
WriteRoot(path, root);
AppLogger.Info($"MCP unregistered from {DisplayName(t)} at {path}");
}
}
}
private static JsonObject BuildEntry(McpTarget t, McpServer server)
{
var exe = ResolveServerExe(server);
return t switch
{
// Claude Codestdio server 需 type 欄位
McpTarget.Claude => new JsonObject
{
["type"] = "stdio",
["command"] = exe,
["args"] = new JsonArray()
},
// Kirolocal server,附 env / disabled / autoApprove 預設
McpTarget.Kiro => new JsonObject
{
["command"] = exe,
["args"] = new JsonArray(),
["env"] = new JsonObject(),
["disabled"] = false,
["autoApprove"] = new JsonArray()
},
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
}
/// <summary>讀入設定檔為可變 JSON 物件;不存在回空物件。檔案存在但格式錯誤則丟例外(不覆蓋使用者資料)。</summary>
private static JsonObject LoadRoot(string path)
{
if (!File.Exists(path)) return new JsonObject();
var text = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(text)) return new JsonObject();
if (JsonNode.Parse(text) is JsonObject obj) return obj;
throw new InvalidDataException($"{path} 不是有效的 JSON 物件,為避免覆蓋資料已中止。請手動檢查該檔。");
}
/// <summary>原子寫回(先寫 .tmp 再 replace),避免半寫壞檔。</summary>
private static void WriteRoot(string path, JsonObject root)
{
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var tmp = path + ".tmp";
File.WriteAllText(tmp, json);
if (File.Exists(path)) File.Replace(tmp, path, null);
else File.Move(tmp, path);
}
}
+6 -3
View File
@@ -66,9 +66,12 @@ public sealed class ShellChannel : ISessionChannel
UpdateProcThreadAttribute(si.lpAttributeList, 0, (IntPtr)0x00020016 /* PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE */,
_ptyHandle, IntPtr.Size, IntPtr.Zero, IntPtr.Zero);
string workDir = string.IsNullOrWhiteSpace(_settings.StartupDirectory)
? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
: _settings.StartupDirectory;
// 啟動目錄:若未設定或該目錄已不存在(例如外接碟拔除、資料夾被刪),
// 一律退回目前使用者的家目錄(C:\Users\<user>),避免 CreateProcess 失敗 267 (ERROR_DIRECTORY)。
string configured = _settings.StartupDirectory;
string workDir = (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
? configured
: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
bool ok = CreateProcess(null, $"{exe} {args}".TrimEnd(), IntPtr.Zero, IntPtr.Zero, false,
0x00080000 /* EXTENDED_STARTUPINFO_PRESENT */, IntPtr.Zero, workDir, ref si, out var pi);
+5
View File
@@ -19,6 +19,10 @@ public sealed class AnsiParser
/// <summary>DECCKMapplication cursor keys(影響方向鍵送出序列)。</summary>
public bool AppCursorKeys { get; private set; }
/// <summary>DEC mode 2004bracketed paste。啟用時貼上內容須以 ESC[200~ / ESC[201~ 包夾,
/// 讓 PSReadLine / Kiro CLI 等把多行貼上視為單一輸入而非逐行立即送出。</summary>
public bool BracketedPaste { get; private set; }
public AnsiParser(ScreenBuffer buffer) => _b = buffer;
public void Feed(byte[] data)
@@ -138,6 +142,7 @@ public sealed class AnsiParser
case 25: _b.CursorVisible = set; break;
case 7: _b.AutoWrap = set; break;
case 1: AppCursorKeys = set; break;
case 2004: BracketedPaste = set; break;
case 47: case 1047: case 1049:
if (set) _b.EnterAlt(); else _b.ExitAlt(); break;
}
+166
View File
@@ -0,0 +1,166 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
namespace ETTerms.Terminal;
/// <summary>
/// 自繪深色垂直捲軸:細長、無箭頭按鈕、圓角滑塊,配合終端機深色主題。
/// APIMinimum / Maximum / Value / LargeChange / SmallChange / Scroll)對齊 <see cref="VScrollBar"/>
/// 方便直接替換。<see cref="Scroll"/> 只在「使用者操作」造成值變動時觸發(程式設定 Value 不觸發)。
/// </summary>
public sealed class DarkScrollBar : Control
{
public int Minimum { get; set; } = 0;
public int Maximum { get; set; } = 0;
public int LargeChange { get; set; } = 1;
public int SmallChange { get; set; } = 1;
private int _value;
public int Value
{
get => _value;
set { int v = Clamp(value); if (v != _value) { _value = v; Invalidate(); } }
}
/// <summary>使用者拖曳 / 點軌道 / 滾輪造成值變動時觸發。</summary>
public event EventHandler? Scroll;
// 配色(KKTerm 深色)
private static readonly Color TrackColor = Color.FromArgb(24, 24, 28);
private static readonly Color ThumbColor = Color.FromArgb(70, 70, 82);
private static readonly Color ThumbHover = Color.FromArgb(100, 100, 118);
private static readonly Color ThumbDrag = Color.FromArgb(130, 130, 152);
private bool _hover, _dragging;
private int _dragOffset;
public DarkScrollBar()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
// 純滑鼠操作的捲軸,無任何鍵盤邏輯,絕不可吃鍵盤焦點:否則身為唯一可選取子控制項,
// 在 scrollback 出現(Enabled=true)後,視窗切走再切回時 WinForms 會把焦點還原到它身上,
// 導致 TerminalView 收不到鍵盤輸入(打字 / Enter 全失效),需重開 session 才恢復。
SetStyle(ControlStyles.Selectable, false);
TabStop = false;
Width = 12;
BackColor = TrackColor;
}
// ── 數值範圍工具 ─────────────────────────────────────────
/// <summary>可達的最大 Value(與 VScrollBar 相同:Maximum - LargeChange + 1)。</summary>
private int MaxValue => Math.Max(Minimum, Maximum - LargeChange + 1);
private int Clamp(int v) => Math.Clamp(v, Minimum, MaxValue);
private bool Scrollable => Enabled && MaxValue > Minimum;
// ── 滑塊幾何 ─────────────────────────────────────────────
private const int MinThumb = 24;
private const int Pad = 2;
private int TrackHeight => Math.Max(1, Height - Pad * 2);
private int ThumbHeight()
{
int total = Maximum - Minimum + 1;
if (total <= 0) return TrackHeight;
int h = (int)((long)TrackHeight * LargeChange / total);
return Math.Clamp(h, MinThumb, TrackHeight);
}
private int ThumbTop()
{
int range = MaxValue - Minimum;
if (range <= 0) return Pad;
int travel = TrackHeight - ThumbHeight();
return Pad + (int)((long)travel * (_value - Minimum) / range);
}
private Rectangle ThumbRect() => new(Pad, ThumbTop(), Math.Max(1, Width - Pad * 2), ThumbHeight());
// ── 繪製 ─────────────────────────────────────────────────
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.Clear(TrackColor);
if (!Scrollable) return; // 不可捲動時只畫底色(與停用態一致)
g.SmoothingMode = SmoothingMode.AntiAlias;
var r = ThumbRect();
var color = _dragging ? ThumbDrag : _hover ? ThumbHover : ThumbColor;
int radius = Math.Min(r.Width, 6);
using var path = RoundedRect(r, radius);
using var b = new SolidBrush(color);
g.FillPath(b, path);
}
private static GraphicsPath RoundedRect(Rectangle r, int radius)
{
int d = radius * 2;
var p = new GraphicsPath();
if (d <= 0 || d > r.Width || d > r.Height) { p.AddRectangle(r); return p; }
p.AddArc(r.X, r.Y, d, d, 180, 90);
p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
p.CloseFigure();
return p;
}
// ── 滑鼠互動 ─────────────────────────────────────────────
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left || !Scrollable) return;
var thumb = ThumbRect();
if (thumb.Contains(e.Location))
{
_dragging = true;
_dragOffset = e.Y - thumb.Top;
}
else
{
// 點軌道:往點擊方向翻一頁
SetValueFromUser(e.Y < thumb.Top ? _value - LargeChange : _value + LargeChange);
}
Invalidate();
}
protected override void OnMouseMove(MouseEventArgs e)
{
bool over = ThumbRect().Contains(e.Location);
if (over != _hover) { _hover = over; Invalidate(); }
if (!_dragging) return;
int travel = TrackHeight - ThumbHeight();
if (travel <= 0) return;
int range = MaxValue - Minimum;
int y = Math.Clamp(e.Y - _dragOffset - Pad, 0, travel);
SetValueFromUser(Minimum + (int)((long)y * range / travel));
}
protected override void OnMouseUp(MouseEventArgs e)
{
if (_dragging) { _dragging = false; Invalidate(); }
}
protected override void OnMouseLeave(EventArgs e)
{
if (_hover) { _hover = false; Invalidate(); }
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (!Scrollable) return;
int step = e.Delta / 120 * SmallChange * 3;
SetValueFromUser(_value - step);
}
private void SetValueFromUser(int v)
{
int nv = Clamp(v);
if (nv == _value) return;
_value = nv;
Invalidate();
Scroll?.Invoke(this, EventArgs.Empty);
}
}
+19 -10
View File
@@ -270,20 +270,29 @@ public sealed class ScreenBuffer
{
cols = Math.Max(1, cols); rows = Math.Max(1, rows);
if (cols == Cols && rows == Rows) return;
var ng = new Cell[rows][];
for (int r = 0; r < rows; r++)
{
ng[r] = new Cell[cols];
for (int c = 0; c < cols; c++)
ng[r][c] = (r < Rows && c < Cols) ? _screen[r][c] : BlankDefault();
}
_screen = ng;
_screen = Reflow(_screen, rows, cols);
// alt screen 啟用中也要一併 reflow 保存的主畫面,否則 resize 後 ExitAlt 會還原失敗、
// 畫面殘留 TUI 內容。不可把 _mainScreen 丟掉。
if (_mainScreen != null) _mainScreen = Reflow(_mainScreen, rows, cols);
Rows = rows; Cols = cols;
_top = 0; _bottom = Rows - 1;
CursorRow = Math.Clamp(CursorRow, 0, Rows - 1);
CursorCol = Math.Clamp(CursorCol, 0, Cols - 1);
_wrapPending = false;
_mainScreen = null;
if (AltActive) { /* alt 下 resize:重建 alt 畫面 */ }
}
/// <summary>把字格陣列重排到新尺寸,保留左上重疊區,其餘填預設空白。</summary>
private Cell[][] Reflow(Cell[][] src, int rows, int cols)
{
int oldRows = src.Length;
var ng = new Cell[rows][];
for (int r = 0; r < rows; r++)
{
ng[r] = new Cell[cols];
int oldCols = r < oldRows ? src[r].Length : 0;
for (int c = 0; c < cols; c++)
ng[r][c] = (c < oldCols) ? src[r][c] : BlankDefault();
}
return ng;
}
}
+5 -1
View File
@@ -9,6 +9,11 @@ public static class TerminalInput
{
public static byte[]? Map(KeyEventArgs e, bool appCursor)
{
// Enter:一般送 CR(\r) 視為「送出」;Shift+Enter 送 LF(\n),讓 PSReadLine / Kiro 等
// 視為「插入換行」以便輸入多行指令,而不是直接送出。
if (e.KeyCode == Keys.Enter)
return new[] { (byte)(e.Shift ? '\n' : '\r') };
string? seq = e.KeyCode switch
{
Keys.Up => appCursor ? "\x1bOA" : "\x1b[A",
@@ -21,7 +26,6 @@ public static class TerminalInput
Keys.Delete => "\x1b[3~",
Keys.PageUp => "\x1b[5~",
Keys.PageDown => "\x1b[6~",
Keys.Enter => "\r",
Keys.Tab => "\t",
Keys.Escape => "\x1b",
Keys.Back => "\x7f",
+69 -4
View File
@@ -17,6 +17,8 @@ public sealed class TerminalView : UserControl
private readonly ScreenBuffer _buf;
private readonly AnsiParser _parser;
private readonly Font _font;
private readonly DarkScrollBar _vscroll; // 右側深色垂直捲軸(可拖曳捲動 scrollback)
private bool _suppressScroll; // 程式設定捲軸值時抑制回呼
private int _cellW, _cellH;
private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback
private int _lastCols = -1, _lastRows = -1;
@@ -42,6 +44,11 @@ public sealed class TerminalView : UserControl
_buf = new ScreenBuffer(profile.Cols, profile.Rows,
Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines);
_parser = new AnsiParser(_buf);
// 右側深色垂直捲軸:拖曳即捲動 scrollback。內容寬度會扣掉捲軸寬,故文字不會被蓋住。
_vscroll = new DarkScrollBar { Dock = DockStyle.Right, SmallChange = 1, Minimum = 0, Maximum = 0 };
_vscroll.Scroll += OnVScroll;
Controls.Add(_vscroll);
}
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
@@ -49,21 +56,31 @@ public sealed class TerminalView : UserControl
{
_parser.Feed(data);
_scrollOffset = 0; // 新輸出貼底
UpdateScrollBar();
Invalidate();
}
private int ContentWidth => Math.Max(_cellW, ClientSize.Width - (_vscroll?.Width ?? 0));
private int VisibleRows => Math.Max(1, ClientSize.Height / _cellH);
private int VisibleCols => Math.Max(1, ClientSize.Width / _cellW);
private int VisibleCols => Math.Max(1, ContentWidth / _cellW);
// ── resize → 通知 PTY ────────────────────────────────────
protected override void OnSizeChanged(EventArgs e)
{
base.OnSizeChanged(e);
// 視窗最小化、或分頁/分割切換的瞬間,ClientSize 會變成 0/極小。此時若把退化尺寸
// (會被算成 1×1)送給 PTY,ConPTY 下的全螢幕 TUI(如 Kiro CLI)會誤以為終端機只剩
// 1×1 而停止重繪、輸入也像「卡住」。直接忽略這種尺寸,還原後再以正常尺寸重繪即可。
if (ClientSize.Width < _cellW || ClientSize.Height < _cellH) return;
if (FindForm() is { WindowState: FormWindowState.Minimized }) return;
int cols = VisibleCols, rows = VisibleRows;
if (cols == _lastCols && rows == _lastRows) return;
_lastCols = cols; _lastRows = rows;
_buf.Resize(cols, rows);
Resized?.Invoke(cols, rows);
UpdateScrollBar();
Invalidate();
}
@@ -181,9 +198,47 @@ public sealed class TerminalView : UserControl
{
int delta = e.Delta / 120 * 3;
_scrollOffset = Math.Clamp(_scrollOffset + delta, 0, _buf.ScrollbackCount);
UpdateScrollBar();
Invalidate();
}
// ── 垂直捲軸 ─────────────────────────────────────────────
private void OnVScroll(object? sender, EventArgs e)
{
if (_suppressScroll) return;
// 捲軸值 = 視窗第一列的絕對 index_scrollOffset = 距底部的列數。
_scrollOffset = Math.Clamp(_buf.ScrollbackCount - _vscroll.Value, 0, _buf.ScrollbackCount);
Invalidate();
}
/// <summary>依目前 scrollback / 捲動位置同步捲軸的範圍與滑塊位置。</summary>
private void UpdateScrollBar()
{
if (_vscroll is null) return;
_suppressScroll = true;
try
{
int visible = VisibleRows;
int total = _buf.TotalRows;
if (_buf.ScrollbackCount <= 0)
{
_vscroll.Enabled = false;
_vscroll.LargeChange = 1;
_vscroll.Maximum = 0;
_vscroll.Value = 0;
return;
}
_vscroll.Enabled = true;
// 先設 LargeChange/Maximum 再設 Value,避免 Value 超出可達範圍被夾掉。
_vscroll.LargeChange = Math.Max(1, visible);
_vscroll.Maximum = Math.Max(0, total - 1);
int top = _buf.ScrollbackCount - _scrollOffset; // 視窗第一列絕對 index
int maxValue = Math.Max(0, _vscroll.Maximum - _vscroll.LargeChange + 1);
_vscroll.Value = Math.Clamp(top, 0, maxValue);
}
finally { _suppressScroll = false; }
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
@@ -195,7 +250,8 @@ public sealed class TerminalView : UserControl
}
else if (e.Button == MouseButtons.Right)
{
if (_hasSel) CopySelection(); else Paste();
if (_hasSel) { CopySelection(); _hasSel = false; Invalidate(); } // 複製後清掉反白,讓 user 知道已動作
else Paste();
}
}
@@ -257,8 +313,17 @@ public sealed class TerminalView : UserControl
{
try
{
if (Clipboard.ContainsText())
SendData?.Invoke(Encoding.UTF8.GetBytes(Clipboard.GetText().Replace("\r\n", "\r")));
if (!Clipboard.ContainsText()) return;
// 換行統一成 CR:剪貼簿多為 \r\n,也處理單獨的 \n。
var text = Clipboard.GetText().Replace("\r\n", "\r").Replace('\n', '\r');
// 應用程式啟用 bracketed pastePSReadLine / Kiro CLI 等)時,以 ESC[200~ … ESC[201~
// 包夾整段貼上內容,讓對方視為「單次貼上」而非逐行 Enter 立即送出;
// 未啟用時才退回原本逐字送出(一般 shell 的預期行為)。
var data = _parser.BracketedPaste
? Encoding.UTF8.GetBytes("\x1b[200~" + text + "\x1b[201~")
: Encoding.UTF8.GetBytes(text);
SendData?.Invoke(data);
}
catch { }
}