7 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
14 changed files with 630 additions and 126 deletions
+10 -3
View File
@@ -118,8 +118,11 @@ ETTerms/
│ ├── MainForm.cs # 主視窗:Rail + Sidebar + Workspace(含深色標題列 DWM │ ├── MainForm.cs # 主視窗:Rail + Sidebar + Workspace(含深色標題列 DWM
│ ├── MainForm.Designer.cs │ ├── MainForm.Designer.cs
│ ├── Theme.cs # 全域深色配色 (KKTerm 風格) │ ├── Theme.cs # 全域深色配色 (KKTerm 風格)
│ ├── ActivityRail.cs # 左側圖示列 (Terminal/Scripts/Settings) │ ├── ActivityRail.cs # 左側圖示列 (Terminal/Status/Settings/About)
│ ├── ConnectionSidebar.cs# 仿 KKTerm 可編輯資料夾樹(搜尋/CRUD/拖曳分類) │ ├── ConnectionSidebar.cs# 仿 KKTerm 可編輯資料夾樹(搜尋/CRUD/拖曳分類)
│ ├── StatusView.cs # Status 檢視:分頁式(PDU…),PDU 連線後每 3 秒背景輪詢插座狀態,並可用表格內 Control 鈕直接開關各 Port
│ ├── SettingsView.cs # Settings 檢視:分頁式(Terminal / AI MCP
│ ├── AboutView.cs # About 檢視(版本 / 連結)
│ ├── Dialogs/ # ── 深色對話框 ── │ ├── Dialogs/ # ── 深色對話框 ──
│ │ ├── DarkDialog.cs # 對話框基底(深色 + DWM 標題列) │ │ ├── DarkDialog.cs # 對話框基底(深色 + DWM 標題列)
│ │ ├── TextPromptDialog.cs # 單行輸入(資料夾命名 / 改名) │ │ ├── TextPromptDialog.cs # 單行輸入(資料夾命名 / 改名)
@@ -131,7 +134,8 @@ ETTerms/
├── Terminal/ # ── 終端機渲染 ── ├── Terminal/ # ── 終端機渲染 ──
│ ├── TerminalView.cs # 自繪 VT100 控制項 (owner-drawn) │ ├── 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, 屬性/顏色) │ ├── ScreenBuffer.cs # 字格緩衝 (rows×cols, 屬性/顏色)
│ └── TerminalInput.cs # 鍵盤 → byte 序列 (含特殊鍵) │ └── TerminalInput.cs # 鍵盤 → byte 序列 (含特殊鍵)
@@ -139,7 +143,7 @@ ETTerms/
│ ├── ISessionChannel.cs # Write(byte[]) + event DataReceived │ ├── ISessionChannel.cs # Write(byte[]) + event DataReceived
│ ├── SshChannel.cs # SSH.NET 實作 (ShellStream) │ ├── SshChannel.cs # SSH.NET 實作 (ShellStream)
│ ├── SerialChannel.cs # System.IO.Ports 實作 │ ├── SerialChannel.cs # System.IO.Ports 實作
│ ├── ShellChannel.cs # Windows ConPTY 本機 Shell │ ├── ShellChannel.cs # Windows ConPTY 本機 ShellStartupDirectory 不存在時 fallback 使用者家目錄)
│ ├── SessionPage.cs # 一個分頁 = TerminalView + Channel + 狀態 │ ├── SessionPage.cs # 一個分頁 = TerminalView + Channel + 狀態
│ ├── SessionManager.cs # 開 / 關 / 列舉所有 active session │ ├── SessionManager.cs # 開 / 關 / 列舉所有 active session
│ ├── SerialBridgeServer.cs# ✅ 本機 named pipe server:把 serial session 的讀寫橋接給 MCPPhase 9 │ ├── SerialBridgeServer.cs# ✅ 本機 named pipe server:把 serial session 的讀寫橋接給 MCPPhase 9
@@ -295,6 +299,9 @@ ETTerms 是單視窗多分頁,沒有「路由」,以下以**功能面板**
- 接收 channel bytes → `AnsiParser``ScreenBuffer` → 繪製 - 接收 channel bytes → `AnsiParser``ScreenBuffer` → 繪製
- 鍵盤輸入 → `TerminalInput` → byte 序列 → channel - 鍵盤輸入 → `TerminalInput` → byte 序列 → channel
- 支援:scrollback、選取 / 複製、貼上、字型 / 配色(從 `TerminalProfile` - 支援:scrollback、選取 / 複製、貼上、字型 / 配色(從 `TerminalProfile`
- **捲動:** 滑鼠滾輪或右側自繪深色捲軸 `DarkScrollBar`(拖曳滑塊 / 點軌道翻頁);`ContentWidth` 扣掉捲軸寬避免文字被蓋,捲軸範圍 / 位置在 `Feed` / 滾輪 / resize 時同步
- **貼上:** 對方啟用 bracketed pasteDEC mode 2004,如 PSReadLine / Kiro CLI)時,整段以 `ESC[200~ … ESC[201~` 包夾送出,避免多行貼上被逐行 Enter 立即送出;未啟用則逐字送出
- **複製:** 右鍵複製選取內容後自動清除反白(提示已複製)
### Script Editor / RunnerScripts 檢視) ### Script Editor / RunnerScripts 檢視)
- 載入 / 編輯 `.ttl` 腳本(語法沿用 MyTeraTerm - 載入 / 編輯 `.ttl` 腳本(語法沿用 MyTeraTerm
+4
View File
@@ -10,6 +10,10 @@ 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.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.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.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()` 解析路徑對齊。
+29 -3
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.2.1-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.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,6 +63,7 @@
-**PDU power control (optional)** -**PDU power control (optional)**
- Control PDU outlets over SNMP (`SnmpSharpNet`) to power-cycle devices during tests - 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` - Script commands: `pduconnect` / `pductrl`
- 🤖 **AI / MCP integration (optional)** - 🤖 **AI / MCP integration (optional)**
@@ -77,9 +78,10 @@
### Terminal Rendering ### Terminal Rendering
- 🎨 Owner-drawn VT100 / ANSI control, double-buffered cell grid - 🎨 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 - 🔤 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
--- ---
@@ -402,6 +404,30 @@ ETTerms/
## 📜 Version History ## 📜 Version History
### 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 ### 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 - **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
+29 -3
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.2.1-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.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,6 +63,7 @@
-**PDU 電源控制(選用)** -**PDU 電源控制(選用)**
- 透過 SNMP`SnmpSharpNet`)控制 PDU 插座,測試中遠端電源循環 - 透過 SNMP`SnmpSharpNet`)控制 PDU 插座,測試中遠端電源循環
- **Status → PDU** 分頁:連線 PDU 後每 3 秒自動輪詢插座狀態,並可用各列的 **Control** 鈕直接開 / 關該 Port
- 腳本指令:`pduconnect` / `pductrl` - 腳本指令:`pduconnect` / `pductrl`
- 🤖 **AI / MCP 整合(選用)** - 🤖 **AI / MCP 整合(選用)**
@@ -77,9 +78,10 @@
### 終端機渲染 ### 終端機渲染
- 🎨 自繪 VT100 / ANSI 控制項(owner-drawn),雙緩衝繪字格 - 🎨 自繪 VT100 / ANSI 控制項(owner-drawn),雙緩衝繪字格
- 🌑 KKTerm 風格深色主題(含 DWM 深色標題列) - 🌑 KKTerm 風格深色主題(含 DWM 深色標題列 + 深色終端機捲軸
- 🔤 可調字型 / 字級 / 配色 / scrollback 行數 - 🔤 可調字型 / 字級 / 配色 / scrollback 行數
- 📋 選取 / 複製 / 貼上 - 🖱️ 用滑鼠滾輪或深色捲軸往回捲長輸出
- 📋 選取 / 複製 / 貼上 —— 右鍵複製會清掉反白;多行貼上採 **bracketed paste**,不會被逐行送出
--- ---
@@ -402,6 +404,30 @@ ETTerms/
## 📜 版本紀錄 ## 📜 版本紀錄
### 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 ### v0.2.1
- **AI MCP 一鍵設定** —— 新增 **設定 → AI MCP** 分頁,一個按鈕就把 Serial MCP server 註冊進 Claude Code`~/.claude.json`)或 Kiro`~/.kiro/settings/mcp.json`);保留檔內其他既有 MCP server,卡片並顯示驗證指令 - **AI MCP 一鍵設定** —— 新增 **設定 → AI MCP** 分頁,一個按鈕就把 Serial MCP server 註冊進 Claude Code`~/.claude.json`)或 Kiro`~/.kiro/settings/mcp.json`);保留檔內其他既有 MCP server,卡片並顯示驗證指令
+23 -5
View File
@@ -183,12 +183,30 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog = private static readonly ChangelogEntry[] Changelog =
[ [
new("0.3.0", new DateOnly(2026, 6, 8), "feat: PDU MCP server", new("0.3.3", new DateOnly(2026, 6, 16), "Bugfix — terminal input no longer dies after activity",
[ [
"New ETTerms.PduMcp server: AI agents (Kiro / Claude CLI) can now control SNMP PDU outlets directly.", "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.",
"Tools: pdu_connect / pdu_list / pdu_set_port / pdu_get_port / pdu_status / pdu_power_cycle / pdu_disconnect.", "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.",
"PDU runs over SNMP, so the AI can power-cycle a DUT without the GUI session being open.", ]),
"Settings → AI MCP now registers both Serial and PDU MCP servers with one click.", 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", new("0.2.2", new DateOnly(2026, 6, 5), "Bugfix — terminal stability",
[ [
+2 -1
View File
@@ -10,7 +10,7 @@ namespace ETTerms.App;
/// </summary> /// </summary>
public sealed class ActivityRail : UserControl public sealed class ActivityRail : UserControl
{ {
public enum RailView { Terminal, Settings, About } public enum RailView { Terminal, Status, Settings, About }
public event EventHandler<RailView>? ViewSelected; public event EventHandler<RailView>? ViewSelected;
@@ -23,6 +23,7 @@ public sealed class ActivityRail : UserControl
private static readonly (RailView view, string glyph, string tip)[] Items = private static readonly (RailView view, string glyph, string tip)[] Items =
{ {
(RailView.Terminal, "▤", "Terminal"), (RailView.Terminal, "▤", "Terminal"),
(RailView.Status, "⚡", "Status"),
(RailView.Settings, "⚙", "Settings"), (RailView.Settings, "⚙", "Settings"),
(RailView.About, "", "About"), (RailView.About, "", "About"),
}; };
+4
View File
@@ -14,6 +14,7 @@ public partial class MainForm : Form
private readonly ActivityRail _rail = new(); private readonly ActivityRail _rail = new();
private readonly ConnectionSidebar _sidebar = new(); private readonly ConnectionSidebar _sidebar = new();
private readonly WorkspaceView _workspace = new(); private readonly WorkspaceView _workspace = new();
private readonly StatusView _statusView = new();
private readonly SettingsView _settings = new(); private readonly SettingsView _settings = new();
private readonly AboutView _about = new(); private readonly AboutView _about = new();
private readonly StatusStrip _status = new(); private readonly StatusStrip _status = new();
@@ -45,11 +46,13 @@ public partial class MainForm : Form
private void BuildLayout() private void BuildLayout()
{ {
Controls.Add(_workspace); // Fill Controls.Add(_workspace); // Fill
Controls.Add(_statusView); // Fill (hidden)
Controls.Add(_settings); // Fill (hidden) Controls.Add(_settings); // Fill (hidden)
Controls.Add(_about); // Fill (hidden) Controls.Add(_about); // Fill (hidden)
Controls.Add(_sidebar); // Left (內側) Controls.Add(_sidebar); // Left (內側)
Controls.Add(_rail); // Left (最外側) Controls.Add(_rail); // Left (最外側)
_statusView.Visible = false;
_settings.Visible = false; _settings.Visible = false;
_about.Visible = false; _about.Visible = false;
@@ -68,6 +71,7 @@ public partial class MainForm : Form
_statusLabel.Text = $"View: {view}"; _statusLabel.Text = $"View: {view}";
_sidebar.Visible = view == ActivityRail.RailView.Terminal; _sidebar.Visible = view == ActivityRail.RailView.Terminal;
_workspace.Visible = view == ActivityRail.RailView.Terminal; _workspace.Visible = view == ActivityRail.RailView.Terminal;
_statusView.Visible = view == ActivityRail.RailView.Status;
_settings.Visible = view == ActivityRail.RailView.Settings; _settings.Visible = view == ActivityRail.RailView.Settings;
_about.Visible = view == ActivityRail.RailView.About; _about.Visible = view == ActivityRail.RailView.About;
AppLogger.LogInfo($"View selected: {view}"); AppLogger.LogInfo($"View selected: {view}");
+1 -103
View File
@@ -1,11 +1,10 @@
using System.Drawing; using System.Drawing;
using System.Windows.Forms; using System.Windows.Forms;
using ETTerms.Infrastructure; using ETTerms.Infrastructure;
using ETTerms.Scripting.Pdu;
namespace ETTerms.App; namespace ETTerms.App;
/// <summary>Settings page with tabs: Terminal / PDU / AI MCP.</summary> /// <summary>Settings page with tabs: Terminal / AI MCP.</summary>
public sealed class SettingsView : UserControl public sealed class SettingsView : UserControl
{ {
public SettingsView() public SettingsView()
@@ -53,7 +52,6 @@ public sealed class SettingsView : UserControl
var termBtn = MakeTab("Terminal", BuildTerminalTab()); var termBtn = MakeTab("Terminal", BuildTerminalTab());
tabBar.Controls.Add(termBtn); tabBar.Controls.Add(termBtn);
tabBar.Controls.Add(MakeTab("PDU", BuildPduTab()));
tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab())); tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab()));
Controls.Add(body); Controls.Add(body);
@@ -173,106 +171,6 @@ public sealed class SettingsView : UserControl
return page; return page;
} }
// ═══ 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)");
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 += (_, _) =>
{
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())
{
pdu = p;
statusLabel.Text = $"Connected to {ip}";
statusLabel.ForeColor = Theme.SerialColor;
connectBtn.Text = "Disconnect";
RefreshPduGrid(pdu, grid);
}
else
{
p.Dispose();
MessageBox.Show(this, $"Failed to connect to PDU at {ip}", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
};
refreshBtn.Click += (_, _) =>
{
if (pdu == null) { MessageBox.Show(this, "Connect to PDU first.", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Information); return; }
RefreshPduGrid(pdu, grid);
};
page.Controls.Add(flow);
return page;
}
private static void RefreshPduGrid(PduController pdu, DataGridView grid)
{
for (int i = 0; i < 12; i++)
{
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;
}
}
// ═══ AI MCP Tab ═══ // ═══ AI MCP Tab ═══
private Panel BuildAiMcpTab() private Panel BuildAiMcpTab()
{ {
+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;
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName> <AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 --> <!-- 版本資訊 -->
<Version>0.3.0</Version> <Version>0.3.3</Version>
<Product>ETTerms</Product> <Product>ETTerms</Product>
<Company>ETTerms Project</Company> <Company>ETTerms Project</Company>
+6 -3
View File
@@ -66,9 +66,12 @@ public sealed class ShellChannel : ISessionChannel
UpdateProcThreadAttribute(si.lpAttributeList, 0, (IntPtr)0x00020016 /* PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE */, UpdateProcThreadAttribute(si.lpAttributeList, 0, (IntPtr)0x00020016 /* PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE */,
_ptyHandle, IntPtr.Size, IntPtr.Zero, IntPtr.Zero); _ptyHandle, IntPtr.Size, IntPtr.Zero, IntPtr.Zero);
string workDir = string.IsNullOrWhiteSpace(_settings.StartupDirectory) // 啟動目錄:若未設定或該目錄已不存在(例如外接碟拔除、資料夾被刪),
? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) // 一律退回目前使用者的家目錄(C:\Users\<user>),避免 CreateProcess 失敗 267 (ERROR_DIRECTORY)。
: _settings.StartupDirectory; 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, bool ok = CreateProcess(null, $"{exe} {args}".TrimEnd(), IntPtr.Zero, IntPtr.Zero, false,
0x00080000 /* EXTENDED_STARTUPINFO_PRESENT */, IntPtr.Zero, workDir, ref si, out var pi); 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> /// <summary>DECCKMapplication cursor keys(影響方向鍵送出序列)。</summary>
public bool AppCursorKeys { get; private set; } 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 AnsiParser(ScreenBuffer buffer) => _b = buffer;
public void Feed(byte[] data) public void Feed(byte[] data)
@@ -138,6 +142,7 @@ public sealed class AnsiParser
case 25: _b.CursorVisible = set; break; case 25: _b.CursorVisible = set; break;
case 7: _b.AutoWrap = set; break; case 7: _b.AutoWrap = set; break;
case 1: AppCursorKeys = set; break; case 1: AppCursorKeys = set; break;
case 2004: BracketedPaste = set; break;
case 47: case 1047: case 1049: case 47: case 1047: case 1049:
if (set) _b.EnterAlt(); else _b.ExitAlt(); break; 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);
}
}
+62 -4
View File
@@ -17,6 +17,8 @@ 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;
private readonly DarkScrollBar _vscroll; // 右側深色垂直捲軸(可拖曳捲動 scrollback)
private bool _suppressScroll; // 程式設定捲軸值時抑制回呼
private int _cellW, _cellH; private int _cellW, _cellH;
private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback
private int _lastCols = -1, _lastRows = -1; private int _lastCols = -1, _lastRows = -1;
@@ -42,6 +44,11 @@ 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);
// 右側深色垂直捲軸:拖曳即捲動 scrollback。內容寬度會扣掉捲軸寬,故文字不會被蓋住。
_vscroll = new DarkScrollBar { Dock = DockStyle.Right, SmallChange = 1, Minimum = 0, Maximum = 0 };
_vscroll.Scroll += OnVScroll;
Controls.Add(_vscroll);
} }
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary> /// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
@@ -49,11 +56,13 @@ public sealed class TerminalView : UserControl
{ {
_parser.Feed(data); _parser.Feed(data);
_scrollOffset = 0; // 新輸出貼底 _scrollOffset = 0; // 新輸出貼底
UpdateScrollBar();
Invalidate(); Invalidate();
} }
private int ContentWidth => Math.Max(_cellW, ClientSize.Width - (_vscroll?.Width ?? 0));
private int VisibleRows => Math.Max(1, ClientSize.Height / _cellH); 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 ──────────────────────────────────── // ── resize → 通知 PTY ────────────────────────────────────
protected override void OnSizeChanged(EventArgs e) protected override void OnSizeChanged(EventArgs e)
@@ -71,6 +80,7 @@ public sealed class TerminalView : UserControl
_lastCols = cols; _lastRows = rows; _lastCols = cols; _lastRows = rows;
_buf.Resize(cols, rows); _buf.Resize(cols, rows);
Resized?.Invoke(cols, rows); Resized?.Invoke(cols, rows);
UpdateScrollBar();
Invalidate(); Invalidate();
} }
@@ -188,9 +198,47 @@ public sealed class TerminalView : UserControl
{ {
int delta = e.Delta / 120 * 3; int delta = e.Delta / 120 * 3;
_scrollOffset = Math.Clamp(_scrollOffset + delta, 0, _buf.ScrollbackCount); _scrollOffset = Math.Clamp(_scrollOffset + delta, 0, _buf.ScrollbackCount);
UpdateScrollBar();
Invalidate(); 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) protected override void OnMouseDown(MouseEventArgs e)
{ {
Focus(); Focus();
@@ -202,7 +250,8 @@ public sealed class TerminalView : UserControl
} }
else if (e.Button == MouseButtons.Right) else if (e.Button == MouseButtons.Right)
{ {
if (_hasSel) CopySelection(); else Paste(); if (_hasSel) { CopySelection(); _hasSel = false; Invalidate(); } // 複製後清掉反白,讓 user 知道已動作
else Paste();
} }
} }
@@ -264,8 +313,17 @@ public sealed class TerminalView : UserControl
{ {
try try
{ {
if (Clipboard.ContainsText()) if (!Clipboard.ContainsText()) return;
SendData?.Invoke(Encoding.UTF8.GetBytes(Clipboard.GetText().Replace("\r\n", "\r"))); // 換行統一成 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 { } catch { }
} }