6 Commits
Author SHA1 Message Date
etwen c9671c819f merge: v0.4.0 performance & stability overhaul 2026-07-02 11:23:05 +08:00
etwen ef533d9783 docs: add release notes for v0.4.0 2026-07-02 11:22:52 +08:00
etwenandClaude Fable 5 572e6589ec feat: v0.4.0 performance & stability overhaul
Terminal:
- cache font variants + single reusable brush in render hot path;
  resolve cell colors once per cell; dispose GDI resources
- scrollback: List+RemoveRange -> O(1) ring buffer
- follow new output only when already at bottom (no more yank-to-bottom
  while reading history)
- alt-screen mouse wheel -> arrow keys (vim/htop scroll)
- answer DSR (ESC[5n/6n) and DA (ESC[c) queries so TUIs no longer hang
- remove dead code in OnKeyPress

Encoding correctness (garbled CJK across chunk boundaries):
- stateful UTF-8 Decoder in TTLInterpreter.OnData, SerialBridgeServer
  rx forwarding, and SessionLogger.Write

Sessions:
- ShellChannel: free proc-thread attribute list, close hProcess,
  notify '[ETTerms] shell process exited' in the tab
- SshChannel: surface ErrorOccurred / ShellStream.Closed in the tab;
  Write no longer throws into the UI thread on a dead connection

PDU:
- new shared ETTerms.PduCore project replaces the two drifted copies of
  PduController (GUI + PduMcp); logging via injected delegates
- batched SNMP GET (GetAllPortsStatus): 12-port poll is 3 UDP
  round-trips instead of 36 (StatusView polling + pdu_status tool)

Scripting:
- cap TTL receive buffer at 1MB; skip re-scan in wait when buffer
  length unchanged
- merge ScriptRunner.RunAsync/RunGroupAsync; new TtlScript helper
  dedups script picking + group-command checks (3 copies -> 1)

Misc:
- ConnectionStore: parse LastUsedUtc with InvariantCulture/RoundtripKind
- version 0.4.0; About changelog; CLAUDE.md notes (intentional group
  barrier behavior, SSH.NET reflection resize caveat)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:17:19 +08:00
etwenandClaude Opus 4.8 38b6af1f35 docs: update README & add release notes for v0.3.3
Bump the version badge to 0.3.3 and add the v0.3.3 entry to Version History
in both README.md and README.zh-TW.md (terminal input fix). Add the v0.3.3
release note, and include the previously uncommitted v0.3.2 release note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:29:36 +08:00
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
32 changed files with 599 additions and 321 deletions
+6 -1
View File
@@ -10,6 +10,8 @@ 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)。打包待指示。 **進度:** 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.4.0** 效能 / 穩定性總整理(來自全 codebase 審視)。**(1) 終端機繪製熱路徑去配置** — `TerminalView` 快取 4 種 Font 變體(Bold/Underline 組合)與單一可變色 `SolidBrush`,run 合併時每 cell 顏色只解析一次,`Dispose` 收掉 GDI 資源;**(2) scrollback 改環形緩衝** — `ScreenBuffer``List+RemoveRange(0,…)`(滿了每行 O(n) 搬移)改 O(1) ring buffer**(3) UTF-8 跨 chunk 亂碼修正(3 處)** — `TTLInterpreter.OnData``SerialBridgeServer` rx 轉發、`SessionLogger.Write` 改用 stateful `Decoder`(比照 `AnsiParser` 原本的正確做法);**(4) PDU SNMP 批次查詢** — 新共用專案 **`src/ETTerms.PduCore/`** 取代 GUI 與 PduMcp 兩份複製的 `PduController`log 走建構子委派),新增 `GetAllPortsStatus()` 一個 SNMP GET 帶 12 個 varbind12 port 輪詢從 36 個 UDP 來回縮成 3 個(StatusView 與 `pdu_status` 都改用);**(5) ShellChannel 資源修正** — `DeleteProcThreadAttributeList`+`FreeHGlobal` 釋放 attribute list、`CloseHandle(pi.hProcess)`shell 自行 exit 時顯示灰色 `[ETTerms] shell process exited` 提示;**(6) SSH 斷線可見** — 訂 `ErrorOccurred`/`ShellStream.Closed` 顯示提示,`Write` 例外不再炸 UI thread;**(7) 終端機行為** — 新輸出只在已貼底時跟隨(看歷史不被拉回底部)、alt screen 滾輪轉方向鍵(vim/htop 可滾)、回應 DSR `ESC[6n`/`ESC[5n` 與 DA `ESC[c`(TUI 查游標位置不再卡住);**(8) TTL** — `_recv` 上限 1MB、wait 輪詢長度沒變不重掃;**(9) 去重複** — `ScriptRunner` 兩個 Run 合併、`TtlScript` 共用選檔/group 指令檢查(SessionPage/WorkspaceView 三份流程收斂)、`ConnectionStore` 讀取改 InvariantCulture+RoundtripKind。註:group `waitall` 一個成員失敗其他成員停在 barrier 是**刻意行為**(使用者要求整組停下),勿「修」。
**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.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.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` 並重繪,讓使用者知道已複製。
@@ -86,11 +88,14 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm
- ⚠️ VT 相容性以常見情境(VT100 / 常見 ANSI)為主,冷門 escape 後補,不阻塞 GUI 進度。 - ⚠️ VT 相容性以常見情境(VT100 / 常見 ANSI)為主,冷門 escape 後補,不阻塞 GUI 進度。
- ⚠️ 本專案**無伺服端祕密 / 無 DB 密碼 / 無 EC2 / 無 VM**,因此不套用 AWS / VirtualBox 部署流程。 - ⚠️ 本專案**無伺服端祕密 / 無 DB 密碼 / 無 EC2 / 無 VM**,因此不套用 AWS / VirtualBox 部署流程。
- ⚠️ Group 同步指令(`waitall` / `sendlnall` / `sendlngroup`**只能在 Run Group 模式**使用;`▶ Script``▶ Run All` 須拒絕含這些指令的腳本。 - ⚠️ Group 同步指令(`waitall` / `sendlnall` / `sendlngroup`**只能在 Run Group 模式**使用;`▶ Script``▶ Run All` 須拒絕含這些指令的腳本。
- ⚠️ Group `waitall` 的 barrier 行為:**一個成員失敗/停止,其他成員會停在 barrier 等** —— 這是刻意設計(整組一起停下來),不是 bug,勿改成 RemoveParticipant。
- ⚠️ `SshChannel.Resize` 用反射挖 SSH.NET 私有 `_channel` 呼叫 `SendWindowChangeRequest`(該 API 未公開)。**升級 SSH.NET 版本時必須驗證 resize 仍有效**(連上後拉視窗大小看遠端 TUI 是否跟著變)。
## 資料夾用途 ## 資料夾用途
- **`src/ETTerms/`** — 主應用程式(WinForms 視窗外殼 + 連線 / 終端機 / 腳本引擎)。 - **`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.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` - **`src/ETTerms.PduMcp/`** — ✅ stdio MCP server(給 AI agent 控制 SNMP PDUv0.3.0)。獨立行程,**直接打 SNMP**,不經 GUI、GUI 不開著也能用;net8.0 console + `ModelContextProtocol`PDU 邏輯用 `ETTerms.PduCore`
- **`src/ETTerms.PduCore/`** — ✅ PDU SNMP 控制共用庫(v0.4.0)。GUI 與 PduMcp 共用的唯一 `PduController`(先前兩份複製已移除);診斷 log 走建構子注入委派(GUI→AppLogger、MCP→stderr);含批次查詢 `GetAllPortsStatus()`
- **`For_AI/`** — AI 協作素材與**參考專案**(`KKTerm-main` UI 參考、`MyTeraTerm` Script 參考)。整個資料夾 gitignored,僅供開發對照。 - **`For_AI/`** — AI 協作素材與**參考專案**(`KKTerm-main` UI 參考、`MyTeraTerm` Script 參考)。整個資料夾 gitignored,僅供開發對照。
- 本專案**無 `secret/` 資料夾**:沒有伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager。 - 本專案**無 `secret/` 資料夾**:沒有伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager。
+1
View File
@@ -3,5 +3,6 @@
<Project Path="src/ETTerms/ETTerms.csproj" /> <Project Path="src/ETTerms/ETTerms.csproj" />
<Project Path="src/ETTerms.SerialMcp/ETTerms.SerialMcp.csproj" /> <Project Path="src/ETTerms.SerialMcp/ETTerms.SerialMcp.csproj" />
<Project Path="src/ETTerms.PduMcp/ETTerms.PduMcp.csproj" /> <Project Path="src/ETTerms.PduMcp/ETTerms.PduMcp.csproj" />
<Project Path="src/ETTerms.PduCore/ETTerms.PduCore.csproj" />
</Folder> </Folder>
</Solution> </Solution>
+6 -1
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. > 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.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) ![version](https://img.shields.io/badge/version-0.3.3-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)
--- ---
@@ -404,6 +404,11 @@ ETTerms/
## 📜 Version History ## 📜 Version History
### v0.3.3
- **Terminal input fix** — after the terminal had printed output (Serial or PowerShell), switching away and back (minimize, or click another app) could leave it unable to accept typing or **Enter**, forcing a session reopen; idle sessions were never affected
- Cause: once output built up scrollback, the terminal's scrollbar could steal keyboard focus on window re-activation. The scrollbar is now mouse-only and never takes focus, so input keeps working no matter how much has printed
### v0.3.2 ### 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 - **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
+6 -1
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。單機、無雲、無登入系統。 > 原生 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.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) ![version](https://img.shields.io/badge/version-0.3.3-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)
--- ---
@@ -404,6 +404,11 @@ ETTerms/
## 📜 版本紀錄 ## 📜 版本紀錄
### v0.3.3
- **終端機輸入修正** —— 終端機印過輸出後(Serial 或 PowerShell),切走再切回(縮小視窗、或點其他程式)可能導致無法打字或按 **Enter**,只能重開 session;閒置時則不受影響
- 原因:輸出累積出 scrollback 後,視窗重新取得焦點時終端機捲軸可能搶走鍵盤焦點。捲軸已改為純滑鼠操作、永不吃焦點,無論印出多少都能正常輸入
### v0.3.2 ### v0.3.2
- **Status → PDU 分頁** —— 新增 `Status` rail 檢視,PDU 面板移到此處:輸入 IP 連線後每 3 秒自動輪詢全部 12 個插座(背景執行緒,免手動 Refresh),即時顯示電流 / 功率 - **Status → PDU 分頁** —— 新增 `Status` rail 檢視,PDU 面板移到此處:輸入 IP 連線後每 3 秒自動輪詢全部 12 個插座(背景執行緒,免手動 Refresh),即時顯示電流 / 功率
+40
View File
@@ -0,0 +1,40 @@
# v0.3.2 — Status page with PDU outlet control
## ✨ New features
**Status page — control PDU outlets with buttons**
* New **Status** view (the **⚡** icon on the left, between Terminal and Settings) with a **PDU** tab —
connect to your PDU by IP and see every outlet at a glance: state, current (mA) and power (W).
* Each outlet row now has its own **Control** button: the label tracks the live state
(**Turn ON** when off, **Turn OFF** when on), so a single click flips that port. The SNMP command
runs off the UI thread, the grid refreshes after the change, and it resets cleanly on disconnect.
* Outlet status **auto-refreshes every 3 seconds** in the background — no more manual Refresh button.
* The PDU panel moved out of **Settings** into its own Status tab (Settings now keeps just
**Terminal / AI MCP**), and the layout is ready for more Status tabs later.
## 🐛 Bug fixes
**Local Shell startup**
* Fixed: the local Shell no longer fails to start when its saved working folder is gone
(e.g. an unplugged USB drive or a deleted folder), which previously caused
`CreateProcess failed: 267 (ERROR_DIRECTORY)`. It now falls back to your home folder,
matching Windows PowerShell behavior.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`ETTerms_v0.3.2`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`ETTerms_v0.3.2_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `ETTerms v0.3.2.exe`.
## 🔗 Links
* PDU MCP & Serial MCP setup guide:
[docs/serial-mcp-guide.md](https://github.com/ETWen/ETTerms/blob/main/docs/serial-mcp-guide.md)
* TTL reference:
[docs/ttl-script-reference.md](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
**Full changelog:** [v0.3.1...v0.3.2](https://github.com/ETWen/ETTerms/compare/v0.3.1...v0.3.2)
+32
View File
@@ -0,0 +1,32 @@
# v0.3.3 — Terminal input no longer dies after activity
## 🐛 Bug fixes
**Terminal stops accepting input after you switch away and back**
* Fixed: once the terminal had printed some output (on a **Serial** or **PowerShell** tab),
switching away and back — minimizing the window, or clicking another app — could leave the
terminal unable to accept any typing or **Enter**. Previously the only way out was to close
and reopen the session (re-open the COM port / start a fresh PowerShell).
* While the session sat idle, this never happened — it only showed up after enough output had
scrolled by, which is exactly when it bit you mid-task.
* Cause: after enough output built up scrollback, the terminal's scrollbar could grab the
keyboard focus when the window regained focus, and the scrollbar has no keyboard handling —
so your keystrokes went nowhere. The scrollbar is now mouse-only and never takes focus, so
the terminal keeps working no matter how much it has printed.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`ETTerms_v0.3.3`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`ETTerms_v0.3.3_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `ETTerms v0.3.3.exe`.
## 🔗 Links
* PDU MCP & Serial MCP setup guide:
[docs/serial-mcp-guide.md](https://github.com/ETWen/ETTerms/blob/main/docs/serial-mcp-guide.md)
**Full changelog:** [v0.3.2...v0.3.3](https://github.com/ETWen/ETTerms/compare/v0.3.2...v0.3.3)
+61
View File
@@ -0,0 +1,61 @@
# v0.4.0 — Performance & stability overhaul
## ✨ New features
**Terminal performance overhaul**
* Heavy output (long boot logs, big file dumps) now renders much more smoothly — drawing was
reworked to stop re-creating fonts and brushes on every frame.
* Scroll-back history storage was rebuilt so the terminal stays fast no matter how much output
has accumulated; previously it got slower and slower once the history buffer filled up.
**Full-screen apps (vim / htop) behave like a real terminal**
* The mouse wheel now scrolls inside full-screen tools like `vim` and `htop` instead of doing nothing.
* Tools that ask the terminal "where is the cursor?" now get an answer — fixes full-screen apps
that could hang on startup or draw at the wrong position.
**Connection status you can actually see**
* When an SSH connection drops or hits an error, a message now appears right in the tab —
no more sessions that silently stop responding.
* When the local shell exits (e.g. you type `exit`), the tab shows
`[ETTerms] shell process exited` instead of just going dead.
**Faster PDU status (GUI and AI)**
* Outlet polling now bundles all 12 ports into a few SNMP requests instead of 36 separate ones —
the Status page refreshes faster, and an unreachable PDU no longer stalls the poll for minutes.
* The AI `pdu_status` tool gets the same speed-up.
## 🐛 Bug fixes
**Garbled Chinese / non-English text**
* Fixed: multi-byte characters could occasionally turn into `` garbage when text was split
across data chunks — this affected TTL script `wait` matching, what the AI reads over the
serial bridge, and saved session log files. All three now decode correctly.
**Scrolling**
* Fixed: while you were scrolled back reading history, any new output yanked the view down to
the bottom. The view now stays where you are and only follows when you're already at the bottom.
**Resource & stability**
* Fixed small memory and handle leaks when opening and closing many local shell tabs over a
long session.
* Fixed: typing into an SSH session after the connection died could crash the window — it now
just prints a short "write failed" note in the tab.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`ETTerms_v0.4.0`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`ETTerms_v0.4.0_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `ETTerms v0.4.0.exe`.
## 🔗 Links
* PDU MCP & Serial MCP setup guide:
[docs/serial-mcp-guide.md](https://github.com/ETWen/ETTerms/blob/main/docs/serial-mcp-guide.md)
* TTL reference:
[docs/ttl-script-reference.md](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
**Full changelog:** [v0.3.3...v0.4.0](https://github.com/ETWen/ETTerms/compare/v0.3.3...v0.4.0)
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ETTerms.PduCore</RootNamespace>
<AssemblyName>ETTerms.PduCore</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SnmpSharpNet" Version="0.9.7">
<NoWarn>NU1701</NoWarn>
</PackageReference>
</ItemGroup>
</Project>
+124
View File
@@ -0,0 +1,124 @@
using System.Net;
using SnmpSharpNet;
namespace ETTerms.PduCore;
/// <summary>
/// PDU controller for iPoMan II/III models via SNMP。
/// GUI 與 ETTerms.PduMcp 共用的唯一實作(v0.4.0 起取代兩份複製的版本)。
/// 診斷輸出走建構子注入的 log 委派:GUI 給 AppLogger、MCP server 給 stderr
/// stdio MCP 的 stdout 專供 JSON-RPC,不可污染)。
/// SNMP 走 UDP、非獨佔,多個行程可同時對同一台 PDU 操作。
/// </summary>
public sealed class PduController : IDisposable
{
private readonly string _ip;
private readonly Action<string>? _logInfo;
private readonly Action<string>? _logWarn;
private const string Community = "private";
private const int SnmpPort = 161;
private const int Timeout = 3000;
public string Ip => _ip;
public PduController(string ip, Action<string>? logInfo = null, Action<string>? logWarn = null)
{
_ip = ip;
_logInfo = logInfo;
_logWarn = logWarn;
}
/// <summary>讀 PDU 型號/名稱 OID;非空且含 "PDU" 代表連得上。</summary>
public string? GetModelName() => SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4");
public bool CheckConnection()
{
var name = GetModelName();
_logInfo?.Invoke($"[PDU] 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) => ParseState(SnmpGet(PortStateOid(port)));
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;
/// <summary>
/// 一次讀回所有插座的狀態 / 電流(mA) / 功率(W)。
/// 以 3 個批次 SNMP GET(每個 PDU 含 <paramref name="portCount"/> 個 varbind)取代
/// 逐 port 逐 OID 的 3×N 個請求 —— 12 port 從 36 個 UDP 來回縮成 3 個,
/// 逾時時的最壞情況也從 36×Timeout 縮到 3×Timeout。
/// </summary>
public (bool? State, int? CurrentMilliAmps, double? PowerWatts)[] GetAllPortsStatus(int portCount)
{
var ports = Enumerable.Range(1, portCount).ToArray();
var states = SnmpGetMany(ports.Select(PortStateOid).ToArray());
var currents = SnmpGetMany(ports.Select(PortCurrentOid).ToArray());
var powers = SnmpGetMany(ports.Select(PortPowerOid).ToArray());
var result = new (bool?, int?, double?)[portCount];
for (int i = 0; i < portCount; i++)
{
result[i] = (
ParseState(states[i]),
int.TryParse(currents[i], out int c) ? c : null,
int.TryParse(powers[i], out int p) ? p / 10.0 : null);
}
return result;
}
private static bool? ParseState(string? r) => r == "3" ? true : (r == "2" || r == "4") ? false : 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) { _logWarn?.Invoke($"[PDU] SNMP SET {oid} exception: {ex.Message}"); return false; }
}
private string? SnmpGet(string oid)
{
var r = SnmpGetMany(new[] { oid });
return r[0];
}
/// <summary>一個 SNMP GET 帶多個 OID(varbind),回傳同序的值;失敗整批回 null。</summary>
private string?[] SnmpGetMany(string[] oids)
{
var result = new string?[oids.Length];
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);
foreach (var oid in oids) pdu.VbList.Add(new Oid(oid));
var resp = (SnmpV1Packet)target.Request(pdu, param);
if (resp == null) { _logWarn?.Invoke($"[PDU] SNMP GET ({oids.Length} oids): no response (timeout)"); return result; }
if (resp.Pdu.ErrorStatus != 0) { _logWarn?.Invoke($"[PDU] SNMP GET ({oids.Length} oids): ErrorStatus={resp.Pdu.ErrorStatus}"); return result; }
// SNMP GET 回應的 varbind 順序與請求一致,直接依 index 對回去。
int n = Math.Min(oids.Length, resp.Pdu.VbList.Count);
for (int i = 0; i < n; i++) result[i] = resp.Pdu.VbList[i].Value.ToString();
}
catch (Exception ex) { _logWarn?.Invoke($"[PDU] SNMP GET ({oids.Length} oids) exception: {ex.Message}"); }
return result;
}
public void Dispose() { }
}
+4 -3
View File
@@ -12,9 +12,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.2.0" /> <PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="SnmpSharpNet" Version="0.9.7"> </ItemGroup>
<NoWarn>NU1701</NoWarn>
</PackageReference> <ItemGroup>
<ProjectReference Include="..\ETTerms.PduCore\ETTerms.PduCore.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
-92
View File
@@ -1,92 +0,0 @@
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() { }
}
+5 -1
View File
@@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using ETTerms.PduCore;
namespace ETTerms.PduMcp; namespace ETTerms.PduMcp;
@@ -16,7 +17,10 @@ public sealed class PduRegistry
private readonly ConcurrentDictionary<string, PduController> _pdus = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, PduController> _pdus = new(StringComparer.OrdinalIgnoreCase);
public PduController GetOrAdd(string ip) => public PduController GetOrAdd(string ip) =>
_pdus.GetOrAdd(ip.Trim(), static k => new PduController(k)); _pdus.GetOrAdd(ip.Trim(), static k => new PduController(k, Log, Log));
// stdio MCP serverstdout 專供 JSON-RPC,診斷一律走 stderr。
private static void Log(string msg) => Console.Error.WriteLine(msg);
public bool TryGet(string ip, out PduController pdu) => public bool TryGet(string ip, out PduController pdu) =>
_pdus.TryGetValue(ip.Trim(), out pdu!); _pdus.TryGetValue(ip.Trim(), out pdu!);
+14 -2
View File
@@ -1,6 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using ETTerms.PduCore;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
namespace ETTerms.PduMcp; namespace ETTerms.PduMcp;
@@ -65,9 +66,20 @@ public static class PduTools
{ {
if (!PduRegistry.Instance.TryGet(ip, out var pdu)) if (!PduRegistry.Instance.TryGet(ip, out var pdu))
return Err($"PDU {ip} not connected. Call pdu_connect first."); return Err($"PDU {ip} not connected. Call pdu_connect first.");
// 批次 SNMP GET12 port 只需 3 個 UDP 來回,而非逐 port 逐 OID 36 個。
var all = pdu.GetAllPortsStatus(PduRegistry.PortCount);
var ports = new List<object>(); var ports = new List<object>();
for (int p = 1; p <= PduRegistry.PortCount; p++) for (int p = 0; p < all.Length; p++)
ports.Add(PortSnapshot(pdu, p)); {
var (state, current, power) = all[p];
ports.Add(new
{
port = p + 1,
state = state == true ? "on" : state == false ? "off" : "unknown",
currentMilliAmps = current,
powerWatts = power
});
}
return Ok(new { ip, ports }); return Ok(new { ip, ports });
} }
+16
View File
@@ -183,6 +183,22 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog = private static readonly ChangelogEntry[] Changelog =
[ [
new("0.4.0", new DateOnly(2026, 7, 2), "Performance & stability overhaul",
[
"The terminal is much smoother during heavy output (long boot logs, big file dumps) — drawing and scroll history were reworked to stay fast no matter how much text has scrolled by.",
"Fixed Chinese and other non-English text occasionally turning into garbled characters — in script 'wait' matching, in what the AI reads from the serial port, and in saved session logs.",
"While you are scrolled back reading history, new output no longer yanks the view down to the bottom.",
"The mouse wheel now scrolls inside full-screen tools like vim and htop instead of doing nothing.",
"Full-screen tools that ask the terminal where the cursor is now get an answer, fixing apps that could hang or draw at the wrong position.",
"You now see a clear message in the tab when an SSH connection drops or the local shell exits, instead of the session silently going dead.",
"PDU status refresh is much faster and lighter on the network (one bundled query instead of 36 separate ones).",
"Fixed small memory and handle leaks when opening many local shell tabs over a long session.",
]),
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("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.", "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.",
+7 -9
View File
@@ -1,6 +1,7 @@
using System.Drawing; using System.Drawing;
using System.Windows.Forms; using System.Windows.Forms;
using ETTerms.Scripting.Pdu; using ETTerms.Infrastructure;
using ETTerms.PduCore;
namespace ETTerms.App; namespace ETTerms.App;
@@ -149,12 +150,9 @@ public sealed class StatusView : UserControl
try try
{ {
var rows = new (bool? state, int? current, double? power)[12]; // 批次 SNMP GET12 port 只需 3 個 UDP 來回(原本逐 port 逐 OID 36 個,
for (int i = 0; i < 12; i++) // 逾時時最壞一輪要 36×3 秒)。
{ var rows = current.GetAllPortsStatus(12);
int port = i + 1;
rows[i] = (current.GetPortState(port), current.GetPortCurrent(port), current.GetPortPowerWatts(port));
}
if (!IsDisposed && IsHandleCreated) if (!IsDisposed && IsHandleCreated)
{ {
@@ -192,7 +190,7 @@ public sealed class StatusView : UserControl
} }
var ip = ipBox.Text.Trim(); var ip = ipBox.Text.Trim();
var p = new PduController(ip); var p = new PduController(ip, AppLogger.Info, AppLogger.LogWarning);
if (p.CheckConnection()) if (p.CheckConnection())
{ {
pdu = p; pdu = p;
@@ -255,7 +253,7 @@ public sealed class StatusView : UserControl
return page; return page;
} }
private static void ApplyPduRows(DataGridView grid, (bool? state, int? current, double? power)[] rows) private static void ApplyPduRows(DataGridView grid, (bool? State, int? CurrentMilliAmps, double? PowerWatts)[] rows)
{ {
for (int i = 0; i < rows.Length && i < grid.Rows.Count; i++) for (int i = 0; i < rows.Length && i < grid.Rows.Count; i++)
{ {
+3 -33
View File
@@ -452,22 +452,9 @@ public sealed class WorkspaceView : UserControl
return; return;
} }
string content, name; if (!TtlScript.TryPick(this, out var content, out var name)) return;
using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" })
{
if (dlg.ShowDialog(this) != DialogResult.OK) return;
content = File.ReadAllText(dlg.FileName);
name = Path.GetFileName(dlg.FileName);
}
// Run All 拒絕含 waitall / sendlnall 的腳本 // Run All 拒絕含 waitall / sendlnall 的腳本
if (ScriptContainsGroupCommands(content)) if (TtlScript.WarnIfGroupCommands(this, content, "Run All")) return;
{
MessageBox.Show(this,
"Script contains 'waitall' or 'sendlnall' which require Group execution.\nPlease use Run Group buttons instead.",
"Run All", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
await Task.WhenAll(serials.Select(sp => sp.RunScriptAsync(content, name))); await Task.WhenAll(serials.Select(sp => sp.RunScriptAsync(content, name)));
} }
@@ -486,29 +473,12 @@ public sealed class WorkspaceView : UserControl
return; return;
} }
string content, name; if (!TtlScript.TryPick(this, out var content, out var name)) return;
using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" })
{
if (dlg.ShowDialog(this) != DialogResult.OK) return;
content = File.ReadAllText(dlg.FileName);
name = Path.GetFileName(dlg.FileName);
}
var sync = new GroupSyncContext(members.Count); var sync = new GroupSyncContext(members.Count);
await Task.WhenAll(members.Select((sp, i) => sp.RunGroupScriptAsync(content, name, sync, ((char)('A' + i)).ToString()))); await Task.WhenAll(members.Select((sp, i) => sp.RunGroupScriptAsync(content, name, sync, ((char)('A' + i)).ToString())));
} }
private static bool ScriptContainsGroupCommands(string content)
{
foreach (var line in content.Split('\n'))
{
var t = line.Trim().ToLower();
if (t.StartsWith("waitall") || t.StartsWith("sendlnall") || t.StartsWith("sendlngroup"))
return true;
}
return false;
}
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing) if (disposing)
+3 -1
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using ETTerms.Infrastructure; using ETTerms.Infrastructure;
@@ -59,7 +60,8 @@ public sealed class ConnectionStore
Type = (ConnectionType)r.GetInt32(2), Type = (ConnectionType)r.GetInt32(2),
SortOrder = r.GetInt32(3), SortOrder = r.GetInt32(3),
GroupName = r.IsDBNull(4) ? null : r.GetString(4), GroupName = r.IsDBNull(4) ? null : r.GetString(4),
LastUsedUtc = DateTime.Parse(r.GetString(5)), // 與寫入端的 "o" (round-trip) 格式對稱,不受系統地區設定影響
LastUsedUtc = DateTime.Parse(r.GetString(5), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
Ssh = blob?.Ssh, Ssh = blob?.Ssh,
Serial = blob?.Serial Serial = blob?.Serial
}); });
+6 -4
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName> <AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 --> <!-- 版本資訊 -->
<Version>0.3.2</Version> <Version>0.4.0</Version>
<Product>ETTerms</Product> <Product>ETTerms</Product>
<Company>ETTerms Project</Company> <Company>ETTerms Project</Company>
@@ -28,13 +28,15 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
<PackageReference Include="SnmpSharpNet" Version="0.9.7">
<NoWarn>NU1701</NoWarn>
</PackageReference>
<PackageReference Include="SSH.NET" Version="2024.2.0" /> <PackageReference Include="SSH.NET" Version="2024.2.0" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" /> <PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- PDU SNMP 控制共用庫(GUI 與 ETTerms.PduMcp 共用,v0.4.0 取代兩份複製的 PduController -->
<ProjectReference Include="..\ETTerms.PduCore\ETTerms.PduCore.csproj" />
</ItemGroup>
<!-- <!--
發佈 GUI 時,自動把 MCP serversSerial + PDU)一併發佈到 <publish>\<server>\ 子資料夾。 發佈 GUI 時,自動把 MCP serversSerial + PDU)一併發佈到 <publish>\<server>\ 子資料夾。
這樣單一 `dotnet publish src\ETTerms` 就會產生完整自洽的 bundle, 這樣單一 `dotnet publish src\ETTerms` 就會產生完整自洽的 bundle,
+6 -3
View File
@@ -23,6 +23,7 @@ public sealed class SessionLogger : IDisposable
private readonly object _lock = new(); private readonly object _lock = new();
private readonly StreamWriter _writer; private readonly StreamWriter _writer;
private readonly StringBuilder _pending = new(); private readonly StringBuilder _pending = new();
private readonly Decoder _decoder = Encoding.UTF8.GetDecoder(); // stateful:多位元組字元跨 chunk 不會解成亂碼
private bool _disposed; private bool _disposed;
/// <summary>實際寫入的完整檔案路徑。</summary> /// <summary>實際寫入的完整檔案路徑。</summary>
@@ -52,13 +53,15 @@ public sealed class SessionLogger : IDisposable
public void Write(byte[] data) public void Write(byte[] data)
{ {
if (_disposed || data.Length == 0) return; if (_disposed || data.Length == 0) return;
string text = Encoding.UTF8.GetString(data);
string clean = AnsiRegex.Replace(text, "").Replace("\r", "");
if (clean.Length == 0) return;
lock (_lock) lock (_lock)
{ {
if (_disposed) return; if (_disposed) return;
var chars = new char[data.Length];
int n = _decoder.GetChars(data, 0, data.Length, chars, 0);
if (n == 0) return;
string clean = AnsiRegex.Replace(new string(chars, 0, n), "").Replace("\r", "");
if (clean.Length == 0) return;
string stamp = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] "; string stamp = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] ";
int start = 0; int start = 0;
for (int i = 0; i < clean.Length; i++) for (int i = 0; i < clean.Length; i++)
@@ -1,76 +0,0 @@
using System.Net;
using ETTerms.Infrastructure;
using SnmpSharpNet;
namespace ETTerms.Scripting.Pdu;
/// <summary>
/// PDU Controller for iPoMan II/III models via SNMP.
/// Ported from MyTeraTerm's PDUControlLib.
/// </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 PduController(string ip) => _ip = ip;
public bool CheckConnection()
{
var name = SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4");
AppLogger.Info($"[PDU] 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));
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 { 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) { AppLogger.LogWarning($"[PDU] SNMP GET {oid}: no response (timeout)"); return null; }
if (result.Pdu.ErrorStatus != 0) { AppLogger.LogWarning($"[PDU] SNMP GET {oid}: ErrorStatus={result.Pdu.ErrorStatus}"); return null; }
foreach (var v in result.Pdu.VbList) return v.Value.ToString();
}
catch (Exception ex) { AppLogger.LogError($"[PDU] SNMP GET {oid} exception", ex); }
return null;
}
public void Dispose() { }
}
+6 -31
View File
@@ -22,39 +22,14 @@ public sealed class ScriptRunner
public bool IsRunning { get; private set; } public bool IsRunning { get; private set; }
public async Task RunAsync(string content, string fileName, ISessionChannel channel) public Task RunAsync(string content, string fileName, ISessionChannel channel)
{ => RunCoreAsync(content, fileName, channel, null, "");
if (IsRunning) return;
IsRunning = true;
var interp = new TTLInterpreter(channel); public Task RunGroupAsync(string content, string fileName, ISessionChannel channel, GroupSyncContext sync, string memberLabel = "")
interp.StatusChanged += (f, l, c) => StatusChanged?.Invoke(f, l, c); => RunCoreAsync(content, fileName, channel, sync, memberLabel);
interp.Output += m => Output?.Invoke(m);
_interp = interp;
try private async Task RunCoreAsync(string content, string fileName, ISessionChannel channel,
{ GroupSyncContext? sync, string memberLabel)
await Task.Run(() => interp.ExecuteScriptContent(content, fileName));
Finished?.Invoke(true, "Completed");
}
catch (OperationCanceledException)
{
Finished?.Invoke(false, "Cancelled");
}
catch (Exception ex)
{
Output?.Invoke($"[error] {ex.Message}");
Finished?.Invoke(false, ex.Message);
}
finally
{
interp.Dispose();
_interp = null;
IsRunning = false;
}
}
public async Task RunGroupAsync(string content, string fileName, ISessionChannel channel, GroupSyncContext sync, string memberLabel = "")
{ {
if (IsRunning) return; if (IsRunning) return;
IsRunning = true; IsRunning = true;
+22 -5
View File
@@ -1,7 +1,8 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ETTerms.Scripting.Pdu; using ETTerms.Infrastructure;
using ETTerms.PduCore;
using ETTerms.Sessions; using ETTerms.Sessions;
namespace ETTerms.Scripting; namespace ETTerms.Scripting;
@@ -17,11 +18,13 @@ public sealed class TTLInterpreter : IDisposable
{ {
private readonly ISessionChannel _channel; private readonly ISessionChannel _channel;
private readonly Encoding _enc = new UTF8Encoding(false); private readonly Encoding _enc = new UTF8Encoding(false);
private readonly Decoder _rxDecoder = Encoding.UTF8.GetDecoder(); // stateful:多位元組字元跨 chunk 不會解成亂碼
private readonly Dictionary<string, object> _vars = new(); private readonly Dictionary<string, object> _vars = new();
private readonly StringBuilder _recv = new(); private readonly StringBuilder _recv = new();
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private const int SettleMs = 300; // wait 命中關鍵字後,需連續安靜這麼久(無新資料)才接受,避免比對到輸出中途的回顯 private const int SettleMs = 300; // wait 命中關鍵字後,需連續安靜這麼久(無新資料)才接受,避免比對到輸出中途的回顯
private const int MaxRecvChars = 1_000_000; // _recv 上限:長時間無 wait 消費時避免無限成長
private GroupSyncContext? _groupSync; private GroupSyncContext? _groupSync;
private string _groupMemberLabel = ""; private string _groupMemberLabel = "";
@@ -51,7 +54,13 @@ public sealed class TTLInterpreter : IDisposable
private void OnData(byte[] data) private void OnData(byte[] data)
{ {
lock (_recv) _recv.Append(_enc.GetString(data)); lock (_recv)
{
var chars = new char[data.Length];
int n = _rxDecoder.GetChars(data, 0, data.Length, chars, 0);
_recv.Append(chars, 0, n);
if (_recv.Length > MaxRecvChars) _recv.Remove(0, _recv.Length - MaxRecvChars);
}
} }
#region Script Execution #region Script Execution
@@ -350,11 +359,19 @@ public sealed class TTLInterpreter : IDisposable
_logWriter?.WriteLine($"[Wait] {text}"); _logWriter?.WriteLine($"[Wait] {text}");
int elapsed = 0; int elapsed = 0;
int lastLen = -1; // buffer 長度沒變就不重掃(避免每 100ms 全量 ToString+IndexOf
while (true) while (true)
{ {
ThrowIfCancelled(); ThrowIfCancelled();
bool found; bool found = false;
lock (_recv) found = _recv.ToString().IndexOf(text, StringComparison.Ordinal) >= 0; lock (_recv)
{
if (_recv.Length != lastLen)
{
lastLen = _recv.Length;
found = _recv.ToString().IndexOf(text, StringComparison.Ordinal) >= 0;
}
}
if (found && SettleAndConsume(text)) return; if (found && SettleAndConsume(text)) return;
// 只有在明確設定 timeout(>0) 且超時才中止腳本;否則一直等到關鍵字出現或被取消。 // 只有在明確設定 timeout(>0) 且超時才中止腳本;否則一直等到關鍵字出現或被取消。
@@ -503,7 +520,7 @@ public sealed class TTLInterpreter : IDisposable
if (parts.Length != 2) { Output?.Invoke("[pduconnect] syntax: pduconnect <device> <ip>"); _result = 0; return; } if (parts.Length != 2) { Output?.Invoke("[pduconnect] syntax: pduconnect <device> <ip>"); _result = 0; return; }
int device = ParseIntDirect(parts[0]); int device = ParseIntDirect(parts[0]);
string ip = parts[1].Trim('\'', '"'); string ip = parts[1].Trim('\'', '"');
var pdu = new PduController(ip); var pdu = new PduController(ip, AppLogger.Info, AppLogger.LogWarning);
if (pdu.CheckConnection()) if (pdu.CheckConnection())
{ {
_pdus[device] = pdu; _pdus[device] = pdu;
+44
View File
@@ -0,0 +1,44 @@
using System.Windows.Forms;
namespace ETTerms.Scripting;
/// <summary>
/// TTL 腳本檔的共用小工具:選檔載入與 group 指令檢查。
/// SessionPage(▶ Script)與 WorkspaceViewRun All / Run Group)共用,避免三份重複流程。
/// </summary>
public static class TtlScript
{
/// <summary>跳出選檔對話框並讀入腳本內容。使用者取消回傳 false。</summary>
public static bool TryPick(IWin32Window owner, out string content, out string fileName)
{
content = "";
fileName = "";
using var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" };
if (dlg.ShowDialog(owner) != DialogResult.OK) return false;
content = File.ReadAllText(dlg.FileName);
fileName = Path.GetFileName(dlg.FileName);
return true;
}
/// <summary>腳本是否含只能在 Run Group 模式使用的同步指令(waitall / sendlnall / sendlngroup)。</summary>
public static bool ContainsGroupCommands(string content)
{
foreach (var line in content.Split('\n'))
{
var t = line.Trim().ToLower();
if (t.StartsWith("waitall") || t.StartsWith("sendlnall") || t.StartsWith("sendlngroup"))
return true;
}
return false;
}
/// <summary>含 group 指令時警告並回傳 true(呼叫端應中止,改用 Run Group)。</summary>
public static bool WarnIfGroupCommands(IWin32Window owner, string content, string caption)
{
if (!ContainsGroupCommands(content)) return false;
MessageBox.Show(owner,
"Script contains 'waitall' or 'sendlnall' which require Group execution.\nPlease use Run Group buttons instead.",
caption, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return true;
}
}
+16 -1
View File
@@ -69,13 +69,28 @@ public sealed class SerialBridgeServer : IDisposable
Send(new { sessions = SerialBridge.All.Select(e => new { name = e.Name, baud = e.BaudRate }) }); Send(new { sessions = SerialBridge.All.Select(e => new { name = e.Name, baud = e.BaudRate }) });
break; break;
case "attach": case "attach":
{
Detach(); Detach();
attached = SerialBridge.Find(req.session ?? ""); attached = SerialBridge.Find(req.session ?? "");
if (attached == null) { Send(new { ok = false, error = $"no open serial session '{req.session}' in GUI" }); break; } if (attached == null) { Send(new { ok = false, error = $"no open serial session '{req.session}' in GUI" }); break; }
rx = data => Send(new { op = "rx", data = Encoding.UTF8.GetString(data) }); // stateful DecoderUTF-8 多位元組字元跨 chunk 邊界時不會解成亂碼
var dec = Encoding.UTF8.GetDecoder();
var decLock = new object();
rx = data =>
{
string s;
lock (decLock)
{
var chars = new char[data.Length];
int n = dec.GetChars(data, 0, data.Length, chars, 0);
s = new string(chars, 0, n);
}
if (s.Length > 0) Send(new { op = "rx", data = s });
};
attached.Rx += rx; attached.Rx += rx;
Send(new { ok = true, name = attached.Name }); Send(new { ok = true, name = attached.Name });
break; break;
}
case "write": case "write":
if (attached == null) { Send(new { ok = false, error = "not attached" }); break; } if (attached == null) { Send(new { ok = false, error = "not attached" }); break; }
attached.Write(req.data ?? "", req.newline); attached.Write(req.data ?? "", req.newline);
+2 -25
View File
@@ -100,20 +100,8 @@ public sealed class SessionPage : UserControl
private async void OnRunScript(object? sender, EventArgs e) private async void OnRunScript(object? sender, EventArgs e)
{ {
string content, name; if (!TtlScript.TryPick(this, out var content, out var name)) return;
using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" }) if (TtlScript.WarnIfGroupCommands(this, content, "Script")) return;
{
if (dlg.ShowDialog(this) != DialogResult.OK) return;
content = File.ReadAllText(dlg.FileName);
name = Path.GetFileName(dlg.FileName);
}
if (ContainsGroupCommands(content))
{
MessageBox.Show(this,
"Script contains 'waitall' or 'sendlnall' which require Group execution.\nPlease use Run Group buttons instead.",
"Script", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
SetRunning(true); SetRunning(true);
await _runner.RunAsync(content, name, _channel); await _runner.RunAsync(content, name, _channel);
} }
@@ -168,17 +156,6 @@ public sealed class SessionPage : UserControl
_log.FlatAppearance.BorderColor = on ? Theme.SerialColor : Theme.Border; _log.FlatAppearance.BorderColor = on ? Theme.SerialColor : Theme.Border;
} }
private static bool ContainsGroupCommands(string content)
{
foreach (var line in content.Split('\n'))
{
var t = line.Trim().ToLower();
if (t.StartsWith("waitall") || t.StartsWith("sendlnall") || t.StartsWith("sendlngroup"))
return true;
}
return false;
}
private void SetRunning(bool running) private void SetRunning(bool running)
{ {
_run.Enabled = !running; _run.Enabled = !running;
+23 -3
View File
@@ -73,13 +73,25 @@ public sealed class ShellChannel : ISessionChannel
? configured ? configured
: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
bool ok = CreateProcess(null, $"{exe} {args}".TrimEnd(), IntPtr.Zero, IntPtr.Zero, false, bool ok;
0x00080000 /* EXTENDED_STARTUPINFO_PRESENT */, IntPtr.Zero, workDir, ref si, out var pi); PROCESS_INFORMATION pi;
try
{
ok = CreateProcess(null, $"{exe} {args}".TrimEnd(), IntPtr.Zero, IntPtr.Zero, false,
0x00080000 /* EXTENDED_STARTUPINFO_PRESENT */, IntPtr.Zero, workDir, ref si, out pi);
}
finally
{
// attribute list 在 CreateProcess 返回後即可釋放(文件保證),不釋放會每開一個 shell 漏一次
DeleteProcThreadAttributeList(si.lpAttributeList);
Marshal.FreeHGlobal(si.lpAttributeList);
}
if (!ok) throw new Exception($"CreateProcess failed: {Marshal.GetLastWin32Error()}"); if (!ok) throw new Exception($"CreateProcess failed: {Marshal.GetLastWin32Error()}");
CloseHandle(pi.hThread); CloseHandle(pi.hThread);
_proc = Process.GetProcessById(pi.dwProcessId); _proc = Process.GetProcessById(pi.dwProcessId);
CloseHandle(pi.hProcess); // 之後都經由 _proc 操作,raw handle 不留(避免 handle 洩漏)
// Start reader thread // Start reader thread
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "ConPTY-Reader" }; _reader = new Thread(ReadLoop) { IsBackground = true, Name = "ConPTY-Reader" };
@@ -101,7 +113,12 @@ public sealed class ShellChannel : ISessionChannel
DataReceived?.Invoke(data); DataReceived?.Invoke(data);
} }
} }
catch when (_closed) { } catch { /* 使用者關閉或 ConPTY 收掉都會走到這,不可讓例外殺掉行程 */ }
// shell 自己 exit(非使用者關分頁)時給個明確提示,分頁才不會看起來像死掉
if (!_closed)
DataReceived?.Invoke(Encoding.UTF8.GetBytes(
"\r\n\x1b[90m[ETTerms] shell process exited\x1b[0m\r\n"));
} }
public void Write(byte[] data) public void Write(byte[] data)
@@ -176,6 +193,9 @@ public sealed class ShellChannel : ISessionChannel
[DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
private static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize); private static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize);
[DllImport("kernel32.dll")]
private static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern bool CreateProcess(string? lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFOEX lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); private static extern bool CreateProcess(string? lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFOEX lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
+20
View File
@@ -40,9 +40,22 @@ public sealed class SshChannel : ISessionChannel
var ci = BuildConnectionInfo(); var ci = BuildConnectionInfo();
_client = new SshClient(ci); _client = new SshClient(ci);
_client.HostKeyReceived += OnHostKey; _client.HostKeyReceived += OnHostKey;
// 斷線 / 錯誤要讓使用者看得到,否則只會覺得「打字沒反應」
_client.ErrorOccurred += (_, e) =>
{
if (_closed) return;
Emit($"\r\n\x1b[90m[SSH error] {e.Exception.Message}\x1b[0m\r\n");
AppLogger.LogWarning($"SSH error {_ssh.Host}: {e.Exception.Message}");
};
_client.Connect(); _client.Connect();
_shell = _client.CreateShellStream("xterm-256color", (uint)_cols, (uint)_rows, 0, 0, 4096); _shell = _client.CreateShellStream("xterm-256color", (uint)_cols, (uint)_rows, 0, 0, 4096);
_shell.DataReceived += (_, e) => DataReceived?.Invoke(e.Data); _shell.DataReceived += (_, e) => DataReceived?.Invoke(e.Data);
_shell.Closed += (_, _) =>
{
if (_closed) return;
Emit("\r\n\x1b[90m[SSH] connection closed by remote host\x1b[0m\r\n");
AppLogger.Info($"SSH shell closed: {_ssh.Host}");
};
AppLogger.Info($"SSH connected: {_ssh.Username}@{_ssh.Host}:{_ssh.Port}"); AppLogger.Info($"SSH connected: {_ssh.Username}@{_ssh.Host}:{_ssh.Port}");
} }
catch (Exception ex) catch (Exception ex)
@@ -102,9 +115,16 @@ public sealed class SshChannel : ISessionChannel
public void Write(byte[] data) public void Write(byte[] data)
{ {
if (_shell == null) return; if (_shell == null) return;
try
{
_shell.Write(data, 0, data.Length); _shell.Write(data, 0, data.Length);
_shell.Flush(); _shell.Flush();
} }
catch (Exception ex) // 連線已斷時從 UI thread 寫入不可讓例外炸掉視窗
{
if (!_closed) Emit($"\r\n\x1b[90m[SSH] write failed: {ex.Message}\x1b[0m\r\n");
}
}
public void Resize(int cols, int rows) public void Resize(int cols, int rows)
{ {
+15
View File
@@ -23,6 +23,10 @@ public sealed class AnsiParser
/// 讓 PSReadLine / Kiro CLI 等把多行貼上視為單一輸入而非逐行立即送出。</summary> /// 讓 PSReadLine / Kiro CLI 等把多行貼上視為單一輸入而非逐行立即送出。</summary>
public bool BracketedPaste { get; private set; } public bool BracketedPaste { get; private set; }
/// <summary>對查詢序列(DSR ESC[5n/6n、DA ESC[c)的回覆,須回送給遠端。
/// vim 等 TUI 會查游標位置,收不到回覆可能卡住或排版錯亂。</summary>
public event Action<byte[]>? Response;
public AnsiParser(ScreenBuffer buffer) => _b = buffer; public AnsiParser(ScreenBuffer buffer) => _b = buffer;
public void Feed(byte[] data) public void Feed(byte[] data)
@@ -130,9 +134,20 @@ public sealed class AnsiParser
case 'h': SetMode(p, true); break; case 'h': SetMode(p, true); break;
case 'l': SetMode(p, false); break; case 'l': SetMode(p, false); break;
case 'm': Sgr(p); break; case 'm': Sgr(p); break;
case 'n': if (!_priv) DeviceStatusReport(p); break;
case 'c': if (!_priv) Respond("\x1b[?1;2c"); break; // DAVT100 with AVO
} }
} }
private void DeviceStatusReport(int[] p)
{
int code = p.Length > 0 ? p[0] : 0;
if (code == 5) Respond("\x1b[0n"); // 裝置 OK
else if (code == 6) Respond($"\x1b[{_b.CursorRow + 1};{_b.CursorCol + 1}R"); // 游標位置(1-based
}
private void Respond(string seq) => Response?.Invoke(Encoding.ASCII.GetBytes(seq));
private void SetMode(int[] p, bool set) private void SetMode(int[] p, bool set)
{ {
if (!_priv) return; if (!_priv) return;
+5
View File
@@ -39,6 +39,11 @@ public sealed class DarkScrollBar : Control
{ {
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
// 純滑鼠操作的捲軸,無任何鍵盤邏輯,絕不可吃鍵盤焦點:否則身為唯一可選取子控制項,
// 在 scrollback 出現(Enabled=true)後,視窗切走再切回時 WinForms 會把焦點還原到它身上,
// 導致 TerminalView 收不到鍵盤輸入(打字 / Enter 全失效),需重開 session 才恢復。
SetStyle(ControlStyles.Selectable, false);
TabStop = false;
Width = 12; Width = 12;
BackColor = TrackColor; BackColor = TrackColor;
} }
+13 -9
View File
@@ -33,8 +33,11 @@ public sealed class ScreenBuffer
public bool AutoWrap = true; public bool AutoWrap = true;
private Cell[][] _screen; private Cell[][] _screen;
private readonly List<Cell[]> _scrollback = new(); // scrollback 用環形緩衝:滿了以後 push/丟舊行都是 O(1)。
private readonly int _maxScroll; // (原本 List + RemoveRange(0,…) 在 buffer 滿時每推一行就整串搬移,大量輸出會愈跑愈卡。)
private readonly Cell[]?[] _sb;
private int _sbHead; // 最舊一行的位置
private int _sbCount;
private int _top, _bottom; // 滾動區(含) private int _top, _bottom; // 滾動區(含)
private bool _wrapPending; private bool _wrapPending;
@@ -49,15 +52,15 @@ public sealed class ScreenBuffer
{ {
Cols = Math.Max(1, cols); Rows = Math.Max(1, rows); Cols = Math.Max(1, cols); Rows = Math.Max(1, rows);
DefaultFg = fg; DefaultBg = bg; PenFg = fg; PenBg = bg; DefaultFg = fg; DefaultBg = bg; PenFg = fg; PenBg = bg;
_maxScroll = maxScrollback; _sb = new Cell[Math.Max(0, maxScrollback)][];
_screen = NewGrid(Rows, Cols); _screen = NewGrid(Rows, Cols);
_top = 0; _bottom = Rows - 1; _top = 0; _bottom = Rows - 1;
} }
// ── 給 TerminalView 讀取 ───────────────────────────────── // ── 給 TerminalView 讀取 ─────────────────────────────────
public int ScrollbackCount => _scrollback.Count; public int ScrollbackCount => _sbCount;
public int TotalRows => _scrollback.Count + Rows; public int TotalRows => _sbCount + Rows;
public Cell[] LineAt(int abs) => abs < _scrollback.Count ? _scrollback[abs] : _screen[abs - _scrollback.Count]; public Cell[] LineAt(int abs) => abs < _sbCount ? _sb[(_sbHead + abs) % _sb.Length]! : _screen[abs - _sbCount];
// ── 內部建構工具 ───────────────────────────────────────── // ── 內部建構工具 ─────────────────────────────────────────
private Cell BlankPen() => new() { Ch = ' ', Fg = PenFg, Bg = PenBg, Attr = CellAttr.None }; private Cell BlankPen() => new() { Ch = ' ', Fg = PenFg, Bg = PenBg, Attr = CellAttr.None };
@@ -73,8 +76,9 @@ public sealed class ScreenBuffer
private void PushScroll(Cell[] line) private void PushScroll(Cell[] line)
{ {
_scrollback.Add(line); if (_sb.Length == 0) return;
if (_scrollback.Count > _maxScroll) _scrollback.RemoveRange(0, _scrollback.Count - _maxScroll); if (_sbCount < _sb.Length) _sb[(_sbHead + _sbCount++) % _sb.Length] = line;
else { _sb[_sbHead] = line; _sbHead = (_sbHead + 1) % _sb.Length; } // 滿了:覆蓋最舊一行
} }
// ── 輸出字元 ───────────────────────────────────────────── // ── 輸出字元 ─────────────────────────────────────────────
@@ -229,7 +233,7 @@ public sealed class ScreenBuffer
public void EraseInDisplay(int mode) public void EraseInDisplay(int mode)
{ {
if (mode == 3) { _scrollback.Clear(); return; } if (mode == 3) { Array.Clear(_sb); _sbHead = 0; _sbCount = 0; return; }
if (mode == 2) { for (int r = 0; r < Rows; r++) _screen[r] = BlankLine(); return; } if (mode == 2) { for (int r = 0; r < Rows; r++) _screen[r] = BlankLine(); return; }
if (mode == 0) if (mode == 0)
{ {
+13
View File
@@ -45,4 +45,17 @@ public static class TerminalInput
}; };
return seq is null ? null : Encoding.ASCII.GetBytes(seq); return seq is null ? null : Encoding.ASCII.GetBytes(seq);
} }
/// <summary>滾輪 → 上/下方向鍵序列(alt screen 用,每格 3 行)。notches 正值往上。</summary>
public static byte[]? WheelArrows(int notches, bool appCursor)
{
if (notches == 0) return null;
string one = notches > 0
? (appCursor ? "\x1bOA" : "\x1b[A")
: (appCursor ? "\x1bOB" : "\x1b[B");
int count = Math.Abs(notches) * 3;
var sb = new StringBuilder(one.Length * count);
for (int i = 0; i < count; i++) sb.Append(one);
return Encoding.ASCII.GetBytes(sb.ToString());
}
} }
+60 -17
View File
@@ -17,6 +17,11 @@ public sealed class TerminalView : UserControl
private readonly ScreenBuffer _buf; private readonly ScreenBuffer _buf;
private readonly AnsiParser _parser; private readonly AnsiParser _parser;
private readonly Font _font; private readonly Font _font;
// 繪製熱路徑的快取:Font 建立是貴的 GDI 操作,預先做好 4 種變體;
// SolidBrush 的 Color 可改,用單一 brush 重複填色,避免每段 run 都 new/dispose。
private readonly Font _fontBold, _fontUnderline, _fontBoldUnderline;
private readonly SolidBrush _fill = new(Color.Black);
private readonly StringBuilder _runSb = new();
private readonly DarkScrollBar _vscroll; // 右側深色垂直捲軸(可拖曳捲動 scrollback) private readonly DarkScrollBar _vscroll; // 右側深色垂直捲軸(可拖曳捲動 scrollback)
private bool _suppressScroll; // 程式設定捲軸值時抑制回呼 private bool _suppressScroll; // 程式設定捲軸值時抑制回呼
private int _cellW, _cellH; private int _cellW, _cellH;
@@ -35,6 +40,9 @@ public sealed class TerminalView : UserControl
BackColor = Color.FromArgb(18, 18, 22); BackColor = Color.FromArgb(18, 18, 22);
ImeMode = ImeMode.NoControl; // 容器控制項預設關 IME,這裡明確開啟讓使用者可切中文 ImeMode = ImeMode.NoControl; // 容器控制項預設關 IME,這裡明確開啟讓使用者可切中文
_font = new Font(profile.FontFamily, profile.FontSize); _font = new Font(profile.FontFamily, profile.FontSize);
_fontBold = new Font(_font, FontStyle.Bold);
_fontUnderline = new Font(_font, FontStyle.Underline);
_fontBoldUnderline = new Font(_font, FontStyle.Bold | FontStyle.Underline);
using (var g = CreateGraphics()) using (var g = CreateGraphics())
{ {
var sz = TextRenderer.MeasureText(g, "W", _font, Size.Empty, TextFormatFlags.NoPadding); var sz = TextRenderer.MeasureText(g, "W", _font, Size.Empty, TextFormatFlags.NoPadding);
@@ -44,6 +52,7 @@ public sealed class TerminalView : UserControl
_buf = new ScreenBuffer(profile.Cols, profile.Rows, _buf = new ScreenBuffer(profile.Cols, profile.Rows,
Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines); Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines);
_parser = new AnsiParser(_buf); _parser = new AnsiParser(_buf);
_parser.Response += bytes => SendData?.Invoke(bytes); // DSR/DA 查詢回覆(如 ESC[6n 游標位置)
// 右側深色垂直捲軸:拖曳即捲動 scrollback。內容寬度會扣掉捲軸寬,故文字不會被蓋住。 // 右側深色垂直捲軸:拖曳即捲動 scrollback。內容寬度會扣掉捲軸寬,故文字不會被蓋住。
_vscroll = new DarkScrollBar { Dock = DockStyle.Right, SmallChange = 1, Minimum = 0, Maximum = 0 }; _vscroll = new DarkScrollBar { Dock = DockStyle.Right, SmallChange = 1, Minimum = 0, Maximum = 0 };
@@ -54,8 +63,12 @@ public sealed class TerminalView : UserControl
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary> /// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
public void Feed(byte[] data) public void Feed(byte[] data)
{ {
int before = _buf.ScrollbackCount;
_parser.Feed(data); _parser.Feed(data);
_scrollOffset = 0; // 新輸出貼底 // 已貼底才跟隨新輸出;使用者往回看歷史時,補償 scrollback 增量讓畫面停在原處,
// 不會每來一筆輸出就被拉回底部。
if (_scrollOffset > 0)
_scrollOffset = Math.Min(_scrollOffset + (_buf.ScrollbackCount - before), _buf.ScrollbackCount);
UpdateScrollBar(); UpdateScrollBar();
Invalidate(); Invalidate();
} }
@@ -116,36 +129,48 @@ public sealed class TerminalView : UserControl
if ((cell.Attr & CellAttr.Wide) != 0) if ((cell.Attr & CellAttr.Wide) != 0)
{ {
var wr = new Rectangle(c * _cellW, y, _cellW * 2, _cellH); var wr = new Rectangle(c * _cellW, y, _cellW * 2, _cellH);
using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, wr); FillRect(g, wr, bg);
DrawRun(g, cell.Ch == '\0' ? " " : cell.Ch.ToString(), cell.Attr, wr, fg); DrawRun(g, cell.Ch == '\0' ? " " : cell.Ch.ToString(), cell.Attr, wr, fg);
c++; c++;
continue; continue;
} }
// 合併同屬性連續窄字 // 合併同色同字型的連續窄字(每個 cell 的顏色只解析一次)
const CellAttr FontAttrs = CellAttr.Bold | CellAttr.Underline;
int start = c; int start = c;
var sb = new StringBuilder(); _runSb.Clear();
_runSb.Append(cell.Ch == '\0' ? ' ' : cell.Ch);
c++;
while (c < line.Length) while (c < line.Length)
{ {
var cur = line[c]; var cur = line[c];
if ((cur.Attr & (CellAttr.Wide | CellAttr.WideTrail)) != 0) break; if ((cur.Attr & (CellAttr.Wide | CellAttr.WideTrail)) != 0) break;
ResolveColors(cur, out var f2, out var b2, abs, c); ResolveColors(cur, out var f2, out var b2, abs, c);
if (f2 != fg || b2 != bg || (cur.Attr & CellAttr.Bold) != (cell.Attr & CellAttr.Bold)) break; if (f2 != fg || b2 != bg || (cur.Attr & FontAttrs) != (cell.Attr & FontAttrs)) break;
sb.Append(cur.Ch == '\0' ? ' ' : cur.Ch); _runSb.Append(cur.Ch == '\0' ? ' ' : cur.Ch);
c++; c++;
} }
var rect = new Rectangle(start * _cellW, y, (c - start) * _cellW, _cellH); var rect = new Rectangle(start * _cellW, y, (c - start) * _cellW, _cellH);
using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, rect); FillRect(g, rect, bg);
DrawRun(g, sb.ToString(), cell.Attr, rect, fg); DrawRun(g, _runSb.ToString(), cell.Attr, rect, fg);
} }
} }
private void FillRect(Graphics g, Rectangle rect, Color bg)
{
_fill.Color = bg;
g.FillRectangle(_fill, rect);
}
private Font FontFor(CellAttr attr)
{
bool bold = (attr & CellAttr.Bold) != 0, ul = (attr & CellAttr.Underline) != 0;
return bold ? (ul ? _fontBoldUnderline : _fontBold) : (ul ? _fontUnderline : _font);
}
private void DrawRun(Graphics g, string text, CellAttr attr, Rectangle rect, Color fg) private void DrawRun(Graphics g, string text, CellAttr attr, Rectangle rect, Color fg)
{ {
var style = (attr & CellAttr.Bold) != 0 ? FontStyle.Bold : FontStyle.Regular; TextRenderer.DrawText(g, text, FontFor(attr), rect, fg,
if ((attr & CellAttr.Underline) != 0) style |= FontStyle.Underline;
using var fnt = style == FontStyle.Regular ? _font : new Font(_font, style);
TextRenderer.DrawText(g, text, fnt, rect, fg,
TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left); TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left);
} }
@@ -181,11 +206,8 @@ public sealed class TerminalView : UserControl
} }
protected override void OnKeyPress(KeyPressEventArgs e) protected override void OnKeyPress(KeyPressEventArgs e)
{
if (!char.IsControl(e.KeyChar) || e.KeyChar is '\r' or '\t' or '\b' or '\x1b')
{ {
// \r,\t,\b,\x1b 已由 OnKeyDown 送出,這裡只送一般可列印字元與 Ctrl 組合碼 // \r,\t,\b,\x1b 已由 OnKeyDown 送出,這裡只送一般可列印字元與 Ctrl 組合碼
}
if (e.KeyChar >= ' ' && e.KeyChar != '\x7f') if (e.KeyChar >= ' ' && e.KeyChar != '\x7f')
SendData?.Invoke(Encoding.UTF8.GetBytes(e.KeyChar.ToString())); SendData?.Invoke(Encoding.UTF8.GetBytes(e.KeyChar.ToString()));
else if (char.IsControl(e.KeyChar) && e.KeyChar is not ('\r' or '\t' or '\b' or '\x1b')) else if (char.IsControl(e.KeyChar) && e.KeyChar is not ('\r' or '\t' or '\b' or '\x1b'))
@@ -196,8 +218,16 @@ public sealed class TerminalView : UserControl
// ── 滑鼠:scrollback / 選取 / 貼上 ─────────────────────── // ── 滑鼠:scrollback / 選取 / 貼上 ───────────────────────
protected override void OnMouseWheel(MouseEventArgs e) protected override void OnMouseWheel(MouseEventArgs e)
{ {
int delta = e.Delta / 120 * 3; int notches = e.Delta / 120;
_scrollOffset = Math.Clamp(_scrollOffset + delta, 0, _buf.ScrollbackCount); // alt screenvim / htop 等全螢幕 TUI)沒有 scrollback:慣例是把滾輪轉成
// 上下方向鍵送給對方,讓應用程式自己捲動。
if (_buf.AltActive)
{
var seq = TerminalInput.WheelArrows(notches, _parser.AppCursorKeys);
if (seq != null) SendData?.Invoke(seq);
return;
}
_scrollOffset = Math.Clamp(_scrollOffset + notches * 3, 0, _buf.ScrollbackCount);
UpdateScrollBar(); UpdateScrollBar();
Invalidate(); Invalidate();
} }
@@ -397,6 +427,19 @@ public sealed class TerminalView : UserControl
finally { ImmReleaseContext(Handle, hImc); } finally { ImmReleaseContext(Handle, hImc); }
} }
protected override void Dispose(bool disposing)
{
if (disposing)
{
_fill.Dispose();
_fontBold.Dispose();
_fontUnderline.Dispose();
_fontBoldUnderline.Dispose();
_font.Dispose();
}
base.Dispose(disposing);
}
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int x, y; } [StructLayout(LayoutKind.Sequential)] private struct POINT { public int x, y; }
[StructLayout(LayoutKind.Sequential)] private struct RECT { public int left, top, right, bottom; } [StructLayout(LayoutKind.Sequential)] private struct RECT { public int left, top, right, bottom; }
[StructLayout(LayoutKind.Sequential)] private struct COMPOSITIONFORM { public int dwStyle; public POINT ptCurrentPos; public RECT rcArea; } [StructLayout(LayoutKind.Sequential)] private struct COMPOSITIONFORM { public int dwStyle; public POINT ptCurrentPos; public RECT rcArea; }