8 Commits
Author SHA1 Message Date
etwen 4e9b13f9be merge: v0.5.0 terminal search, keyword alerts, TeraTerm-compatible TTL 2026-07-02 16:08:12 +08:00
etwen 40db808450 chore(scripts): remove redundant inline comment in test-power_cycle_SVOS.ttl 2026-07-02 16:08:04 +08:00
etwenandClaude Fable 5 5b7370470f feat: add Show script trace in terminal setting
Settings -> Terminal gains a 'Show script trace in terminal' checkbox
(AppSettings.ShowScriptTrace, default on). When off, TTL trace lines
([wait] progress, sent-command echo, errors) are no longer drawn in
the terminal. dispstr moves to a dedicated Display event and always
shows, since it is the script explicitly printing something.

Trace was already display-only (never written to session logs, the AI
serial bridge, or the device) - docs now state this explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:39:45 +08:00
etwenandClaude Fable 5 98fbd310d2 feat: v0.5.0 terminal search, keyword alerts, TeraTerm-compatible TTL
Search (Ctrl+F):
- in-terminal search bar over full scrollback + screen; all hits
  highlighted, current hit emphasized; Enter searches upward,
  Shift+Enter downward, F3/Esc shortcuts
- hits anchored via ScreenBuffer.DroppedLines so positions stay
  correct as the ring buffer drops old lines

Keyword highlighting & tab alerts:
- new Settings -> Highlight page: user-defined keyword list with
  per-rule enable and a global toggle (AppSettings.KeywordRules)
- keywords highlighted red in every terminal (visible rows only,
  case-insensitive); background tabs flash a red dot when a keyword
  appears, cleared when the tab is opened

TTL engine (TeraTerm macro compatibility):
- new TtlExpression parser: parens, and/or/xor/not, comparisons,
  * / % + -, hex literals (0x/$), string/int values; legacy fallback
  keeps old scripts working
- control flow: goto, call/return (inline, usable inside loops),
  for/next, do/loop [while|until], until/enduntil, break, continue,
  end, exit, include, mpause; one-line "if <expr> <statement>"
- waits: waitln, waitregex (matchstr/groupmatchstr1-9), recvln,
  multi-string wait (TeraTerm semantics), mtimeout
- strings: strlen strcompare strconcat strcopy strinsert strremove
  strmatch strscan strreplace strtrim strsplit strjoin tolower
  toupper str2int int2str code2str str2code sprintf expandenv
- files: fileopen filereadln filewrite(ln) fileclose filecreate
  filedelete filesearch basename dirname makepath foldercreate
  folderdelete foldersearch getdir setdir
- misc: beep getdate gettime getenv setenv random exec getver
  getttdir uptime ifdefined clipb2var var2clipb inputbox yesnobox
  crc32 checksum8/16/32 dispstr
- serial: sendbreak setbaud setdtr setrts sendfile (SerialChannel
  gains SendBreak/SetBaudRate/SetDtr/SetRts)
- script Output (incl. dispstr) now echoed gray into the terminal
- quote-aware comment stripping; case-insensitive variables

Docs:
- docs/ttl-script-reference.md rewritten: ETTerms-only commands
  first, then the TeraTerm-shared set, with examples

Misc:
- version 0.5.0; About changelog; CLAUDE.md v0.5.0 notes
- restore ETTerms.PduCore ProjectReference in ETTerms/PduMcp csproj
  (was dropped in the working tree; required to compile)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:47:06 +08:00
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
37 changed files with 2874 additions and 549 deletions
+8 -1
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.5.0** 搜尋 / 關鍵字警示 / TTL 大擴充。**(1) Ctrl+F scrollback 搜尋** — `TerminalView` 內建搜尋列(Enter 往上找、Shift+Enter 往下、F3/Esc),全部命中黃底、目前命中橘底;命中以「絕對行號 = `ScreenBuffer.DroppedLines` + abs」錨定,環形緩衝丟舊行不漂移。**(2) 關鍵字高亮 + 分頁警示** — `AppSettings.KeywordRules``KeywordRule{Text,Enabled}` + 全域開關),Settings 新 **Highlight** 分頁可增刪/勾選;`TerminalView` 每次重繪只掃可見行標紅底(不分大小寫),`Feed` 路徑用獨立 Decoder+去 ANSI 偵測觸發 `KeywordAlert`(每關鍵字 2s 冷卻)→ `SessionPage``WorkspaceView` 把**非 active 分頁**的圓點標紅、切過去自動清除。**(3) TTL 對齊 TeraTerm** — 新 `TtlExpression` 運算式解析器(括號/邏輯/比較/十六進位 `0x`/`$`,失敗退回 legacy 規則相容舊腳本);新增控制流 `goto/call(行內執行,迴圈內可用)/return/for-next/do-loop/until-enduntil/break/continue/end/exit/include/mpause`、等待 `waitln/waitregex/recvln/wait 多字串(TeraTerm 語意:逾時 result=0 繼續)/mtimeout`、字串 `strlen/strcompare/strconcat/strcopy/strinsert/strremove/strmatch/strscan/strreplace/strtrim/strsplit/strjoin/tolower/toupper/str2int/int2str/code2str/str2code/sprintf(→inputstr)/expandenv`、檔案 `fileopen/filereadln(result=1 是 EOF)/filewrite(ln)/fileclose/filecreate/filedelete/filesearch/basename/dirname/makepath/foldercreate/folderdelete/foldersearch/getdir/setdir`、雜項 `beep/getdate/gettime(strftime 子集)/getenv/setenv/random/exec/getver/getttdir/uptime/ifdefined/clipb2var/var2clipb/inputbox/yesnobox/crc32/checksum8/16/32/dispstr`、serial 專用 `sendbreak/setbaud/setdtr/setrts/sendfile``SerialChannel` 新增對應方法);單行 `if <expr> <statement>` 支援;`Preprocess` 引號內 `;` 不再被當註解;系統變數 `inputstr/matchstr/groupmatchstr1-9``_vars` 改大小寫不敏感;**單字串 `wait` 保留 ETTerms settle+逾時中止語意(勿改)**,多字串才是 TeraTerm 語意。`SessionPage``runner.Output`trace`[wait]`/`>>`/錯誤)以灰色 echo 進終端機,**可由 Settings → Terminal 的 `ShowScriptTrace` 開關關閉**`dispstr` 走獨立的 `Display` 事件一律顯示。灰色訊息只進畫面 buffer——不進 ⏺ Log 側錄、不進 AI bridge、不送裝置(機台原始 log 乾淨)。指令表+範例:[docs/ttl-script-reference.md](docs/ttl-script-reference.md)(前段 ETTerms 獨有、後段與 TeraTerm 共有)。
**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 +90,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)
+244 -116
View File
@@ -1,178 +1,306 @@
# TTL Script Reference — ETTerms # TTL Script Reference — ETTerms
> ETTerms 的 TTLTera Term Language)腳本引擎移植自 MyTeraTerm,改為驅動原生 > ETTerms 的 TTLTera Term Language)腳本引擎移植自 MyTeraTerm,改為驅動原生
> `ISessionChannel`SSH / Serial 皆可)。在 **Scripts 檢視**載入 `.ttl` 腳本,對 > `ISessionChannel`SSH / Serial / Shell 皆可)。在分頁上按 **Script** 載入 `.ttl`
> **目前 active session** 執行(先在 Terminal 檢視開好連線) > 對該分頁執行;或用 toolbar 的 **▶ Run All** / **▶ Group1-3** 批次執行
>
> v0.5.0 起大幅對齊 [TeraTerm macro 指令集](https://teratermproject.github.io/manual/5/en/macro/command/index.html)。
> 本文**前段是 ETTerms 獨有指令****後段是與 TeraTerm 共有的指令**。
執行於背景執行緒,可隨時按 **Stop** 中止;`wait` / `pause` 期間皆可取消。 執行於背景執行緒,可隨時按 **Stop** 中止;`wait` / `pause` 期間皆可取消。
腳本的 trace 訊息(`[wait]` 進度、`>>` 送出回顯、錯誤)會以灰色顯示在該分頁的終端機裡,
可由 **Settings → Terminal → Show script trace in terminal** 關閉;`dispstr` 是腳本明確要
顯示的內容,一律顯示。**這些灰色訊息只出現在畫面上**——不會送到裝置、不會寫進 ⏺ Log
側錄檔、AI serial bridge 也讀不到,機台原始 log 保持乾淨。
--- ---
## 語法規則 ## 語法規則
- 一行一個指令;前後空白會被去除。 - 一行一個指令;前後空白會被去除。
- 註解:`;` 之後到行尾視為註解 - 註解:`;` 之後到行尾**引號內的 `;` 不算**
- 字串引號 `'...'``"..."` 皆可,送出時引號會被去除 - 字串引號 `'...'``"..."` 皆可。
- 變數以 `名稱 = 值` 指派;名稱須符合 `^[a-zA-Z_][a-zA-Z0-9_]*$` - Label`:名稱` 一行,供 `goto` / `call` 跳轉
- 內建變數 `result``wait` 命中為 `1`、逾時為 `0` - 變數 `名稱 = 運算式` 指派;名稱須符合 `[a-zA-Z_][a-zA-Z0-9_]*`,大小寫不分
- 換行:`sendln` 自動附加 `\r\n` - 運算式支援:整數(十進位 / `0x1F` / `$1F`)、字串、括號、`+ - * / %`
比較 `= == <> != > < >= <=`、邏輯 `and or xor not`(同義 `&& || !`)。
比較時兩邊都是數字用數值比較,否則字串比較;未定義變數視為 `0`
- 單行 if`if <條件> <指令>`(如 `if result = 0 goto retry`);區塊 if 用 `then ... endif`
### 系統變數
| 變數 | 說明 |
|------|------|
| `result` | 多數指令的結果碼(各指令說明)。 |
| `inputstr` | `waitln` / `recvln` / `inputbox` / `sprintf` 的結果字串。 |
| `matchstr` | `waitregex` / `strmatch` 命中的整段文字。 |
| `groupmatchstr1``9` | regex 群組(`waitregex` / `strmatch`)或 `strsplit` 的分段。 |
| `timeout` | wait 家族的逾時(秒),`timeout = 10`0 = 無限等待。 |
| `mtimeout` | 逾時的毫秒部分,與 `timeout` 相加(`mtimeout = 500`)。 |
--- ---
## 指令對照表 # 一、ETTerms 獨有指令
## Group 同步(多分頁協同)
以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1-3`)。
`▶ Script``▶ Run All` 會拒絕含這些指令的腳本。
Group 內的成員依序編為 **A, B, C...**(顯示在 cell footer,如 `[Group1-A]`)。
> ⚠ 刻意設計:group 同步時**一個成員停止(Stop / 逾時 / 錯誤),其他成員會停在
> barrier 等**——整組一起停下來,避免半組繼續跑造成狀態不一致。
| 指令 | 語法 | 說明 | | 指令 | 語法 | 說明 |
|------|------|------| |------|------|------|
| `send` | `send '文字'` | 送出文字(不加換行)到 active session。 | | `waitall` | `waitall '字串'` | 各成員各自 `wait` 到字串出現,再等其他成員到齊才繼續。 |
| `sendln` | `sendln '文字'` | 送出文字並附加 `\r\n`。 | | `sendlnall` | `sendlnall '文字'` | 等所有成員到達此行後,每人各自 `sendln` 同一段文字。 |
| `wait` | `wait '字串'` | **一直等到**接收緩衝出現指定字串才往下;預設無限等待(可按 Stop 取消)。命中後會再確認裝置「安靜」約 300ms(無新資料)才接受,以排除夾在輸出中途的回顯(如 `SVOS> help`),並消費到該字串**最後一次**出現(含)之前的內容,`result=1`。 | | `sendlngroup` | `sendlngroup A '文字'` | 只有指定 memberA/B/C...`sendln`,其他成員跳過。 |
| `pause` | `pause 秒數` | 暫停指定秒數(可被 Stop 中止)。 |
| `timeout` | `timeout = 秒數` | 設定 `wait` 的逾時秒數。預設 `0`=無限等待;若設為 `N>0``wait` 超過 N 秒仍未命中會**中止整個腳本並報錯**(不會略過 `wait` 往下做)。 |
| `flushrecv` | `flushrecv` | 清空接收緩衝區。 |
| `logopen` | `logopen '檔名'` | 開啟 log 檔(覆寫);`send``logwrite` 會寫入。 |
| `logwrite` | `logwrite '文字'` | 寫一行到 log 檔。 |
| `logclose` | `logclose` | 關閉 log 檔(腳本結束時自動關閉)。 |
| `messagebox` | `messagebox '訊息'` | 跳出訊息對話框。 |
| `sprintf2` | `sprintf2 變數 格式 [引數 ...]` | C `printf` 風格格式化,結果存入字串變數。詳見下方說明。 |
| `if` / `elseif` / `else` / `endif` | 見下 | 條件分支(可巢狀)。 |
| `while` / `endwhile` | 見下 | 迴圈(可巢狀,可被 Stop 中止)。 |
| `名稱 = 值` | `idx = 0` | 變數指派,支援 `+ - * /` 整數運算與字串。 |
> **注意:** PDU 控制指令(`pductrl` / `pduconnect`)屬 Phase 7,本階段尚未提供。
---
## Group 同步指令
以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1` / `▶ Group2` / `▶ Group3`)。
若在 `▶ Script`(分頁個別執行)或 `▶ Run All` 使用,會跳 Warning 並拒絕執行。
Group 內的成員依加入順序編為 **A, B, C, D...**,顯示在 cell footer(如 `[Group1-A]`)。
| 指令 | 語法 | 說明 |
|------|------|------|
| `waitall` | `waitall '字串'` | 各成員各自 `wait` 到指定字串出現後,再等其他成員也完成,全員到齊才繼續下一步。 |
| `sendlnall` | `sendlnall '文字'` | 等所有成員到達此行後,每人各自對自己的 channel `sendln` 同一段文字。 |
| `sendlngroup` | `sendlngroup A '文字'` | 只有指定 memberA/B/C...)會 `sendln`,其他成員跳過。用於 Group 內各設備需送不同指令的情境。 |
### Group 範例:同步升級多台交換機
```ttl ```ttl
; Group1 A=SW1, B=SW2, C=SW3 ; Group1 A=SW1, B=SW2, C=SW3
; prompt
waitall '#' waitall '#'
;
sendlngroup A 'copy tftp://10.0.0.1/sw1.bin flash:' sendlngroup A 'copy tftp://10.0.0.1/sw1.bin flash:'
sendlngroup B 'copy tftp://10.0.0.1/sw2.bin flash:' sendlngroup B 'copy tftp://10.0.0.1/sw2.bin flash:'
sendlngroup C 'copy tftp://10.0.0.1/sw3.bin flash:' sendlngroup C 'copy tftp://10.0.0.1/sw3.bin flash:'
;
waitall '#' waitall '#'
; reload
sendlnall 'reload' sendlnall 'reload'
waitall 'confirm'
sendlnall 'y'
``` ```
### Group 設定方式 ## PDU 電源控制(iPoMan II/IIISNMP
1. 在分頁 Tab 上**右鍵** → 選擇 `Group 1` / `Group 2` / `Group 3`(或 `No Group` 取消) | 指令 | 語法 | 說明 |
2. 設定後 cell footer 會顯示 `[Group1-A]``[Group1-B]` 等標籤 |------|------|------|
3. 點 toolbar 的 `▶ Group1` 載入 `.ttl` 檔,Group 內所有成員平行執行同一份腳本 | `pduconnect` | `pduconnect <device> <ip>` | 連 PDU 並驗證,`result` 1/0。device 為自訂編號。 |
| `pductrl` | `pductrl <device> <port> <0\|1>` | 指定插座 OFF(0)/ON(1)`result` 1/0。 |
```ttl
pduconnect 1 192.168.1.21
if result = 0 goto fail
pductrl 1 3 0 ; DUT
pause 5
pductrl 1 3 1 ; DUT
wait 'login:'
:fail
```
## 行為與 TeraTerm 不同之處(重要)
| 項目 | ETTerms 行為 |
|------|--------------|
| `wait`(**單字串**) | 命中後需裝置**安靜 300ms**(無新資料)才接受,並取「最後一次」出現——排除輸出中途的指令回顯(如 `SVOS> help`)造成腳本搶跑。**逾時會中止腳本**(TeraTerm 是 `result=0` 繼續)。 |
| `wait`**多字串** | TeraTerm 相容:任一命中即繼續,`result` = 第幾個字串(1 起算);逾時 `result=0` **繼續執行**、無 settle。 |
| `goto` / 跨區塊跳轉 | `goto` 跳出 `if`/`while` 區塊後,該區塊的迴圈控制即結束(同層繼續直行)。避免 goto 跳「進」區塊中間。 |
| `call` | 可以在迴圈 / if 內使用(行內執行,返回後迴圈續跑)。 |
| `include` | 路徑須為絕對路徑,或相對於 ETTerms 的工作目錄。 |
| 浮點數 | TTL 無浮點型別;`sprintf/sprintf2` 的浮點引數以字串傳入(`'3.14'`)。 |
--- ---
## 條件運算子 # 二、與 TeraTerm 共有指令
`if` / `elseif` / `while` 條件支援: ## 通訊
| 運算子 | 類型 | 範例 | | 指令 | 語法 | 說明 |
|--------|------|------| |------|------|------|
| `>=` `<=` `>` `<` | 數值 | `if idx >= 3 then` | | `send` | `send '文字'` | 送出文字(不加換行)。 |
| `==` `!=` | 字串 / 數值 | `if result == 1 then` | | `sendln` | `sendln '文字'` | 送出文字 + `\r\n` |
| `=` | 數值或字串相等 | `if result = 0 then` | | `sendfile` | `sendfile '路徑'` | 把整個檔案內容原樣送出(`result` 1/0)。 |
| (無運算子) | 非零為真 | `if result then` | | `wait` | `wait '字串' ['字串2' ...]` | 等待字串出現(行為差異見上表)。 |
| `waitln` | `waitln '字串' ['字串2' ...]` | 等**包含**任一字串的完整一行;該行存入 `inputstr``result`=第幾個;逾時 0。 |
| `waitregex` | `waitregex '樣式' ['樣式2' ...]` | 等 regex 命中;`matchstr` / `groupmatchstr1-9` 設定,`result`=第幾個;逾時 0。 |
| `recvln` | `recvln` | 收下一行到 `inputstr``result` 1 成功 / 0 逾時。 |
| `flushrecv` | `flushrecv` | 清空接收緩衝。 |
| `dispstr` | `dispstr '文字' [更多...]` | 在終端機顯示訊息(灰色;不送裝置、不進側錄 log、不受 Show script trace 開關影響)。 |
| `sendbreak` | `sendbreak` | 送 serial break**Serial 限定**,約 300ms)。 |
| `setbaud` | `setbaud 115200` | 執行中改 baud rate**Serial 限定**)。 |
| `setdtr` / `setrts` | `setdtr 1` / `setrts 0` | 控制 DTR / RTS 腳位(**Serial 限定**)。 |
`then` 關鍵字可省略。 ## 控制流
| 指令 | 語法 | 說明 |
|------|------|------|
| `if` | `if <條件> <指令>``if <條件> then ... [elseif] [else] endif` | 單行或區塊,可巢狀。 |
| `while` / `endwhile` | `while <條件>` | 條件為真時重複。 |
| `until` / `enduntil` | `until <條件>` | 條件為**假**時重複。 |
| `for` / `next` | `for i 1 10` | i 從 1 到 10(含),自動 ±1。 |
| `do` / `loop` | `do [while\|until <條件>]` ... `loop [while\|until <條件>]` | 前測或後測迴圈;都不帶條件時為無限迴圈(用 `break` 離開)。 |
| `break` / `continue` | | 跳出 / 提前進入下一輪(while / until / for / do 皆可)。 |
| `goto` | `goto 標籤` | 跳到 `:標籤`。 |
| `call` / `return` | `call 標籤` | 呼叫副程式(`:標籤` 起,`return` 返回;可在迴圈內用)。 |
| `include` | `include '檔案.ttl'` | 執行另一個腳本檔(可巢狀 8 層;檔內 `exit` 只離開該檔)。 |
| `pause` | `pause 秒` | 暫停(可 Stop 取消)。 |
| `mpause` | `mpause 毫秒` | 毫秒級暫停。 |
| `end` | `end` | 結束腳本(成功)。 |
| `exit` | `exit` | include 檔內=離開該檔;主檔=同 `end`。 |
| `timeout` / `mtimeout` | `timeout = 10` | wait 家族逾時(秒 / 毫秒,兩者相加)。 |
## 字串
| 指令 | 語法 | 說明 |
|------|------|------|
| `strlen` | `strlen <str>` | `result` = 長度。 |
| `strcompare` | `strcompare <s1> <s2>` | `result` = -1 / 0 / 1。 |
| `strconcat` | `strconcat <strvar> <str>` | strvar += str。 |
| `strcopy` | `strcopy <str> <pos> <len> <strvar>` | 取子字串(pos 1 起算)。 |
| `strinsert` / `strremove` | `strinsert <strvar> <pos> <str>` / `strremove <strvar> <pos> <len>` | 插入 / 刪除。 |
| `strscan` | `strscan <str> <substr>` | `result` = 位置(1 起算,0 = 沒找到)。 |
| `strmatch` | `strmatch <str> <regex>` | regex 比對:`result` = 位置,`matchstr` / `groupmatchstr1-9`。 |
| `strreplace` | `strreplace <strvar> <pos> <regex> <newstr>` | 自 pos 起 regex 全部取代,`result` 1/0。 |
| `strtrim` | `strtrim <strvar> ['字元集']` | 去頭尾字元(預設空白)。 |
| `strsplit` | `strsplit <str> <sep> [count]` | 切成 `groupmatchstr1-9``result` = 個數。 |
| `strjoin` | `strjoin <strvar> <sep> [count]` | 把 `groupmatchstr1..count` 接回一串。 |
| `tolower` / `toupper` | `tolower <strvar> <str>` | 轉小寫 / 大寫。 |
| `str2int` / `int2str` | `str2int <intvar> <str>` / `int2str <strvar> <int>` | 字串 ↔ 整數(str2int 的 `result` 1/0)。 |
| `code2str` / `str2code` | `code2str <strvar> 65` / `str2code <intvar> 'A'` | 字元碼 ↔ 字元。 |
| `sprintf` | `sprintf <格式> [引數...]` | C printf 格式化 → **`inputstr`**。 |
| `sprintf2` | `sprintf2 <strvar> <格式> [引數...]` | 同上但存到指定變數(詳見下節)。 |
| `expandenv` | `expandenv <strvar> '%USERPROFILE%\x'` | 展開環境變數。 |
## 檔案 / 資料夾
| 指令 | 語法 | 說明 |
|------|------|------|
| `fileopen` | `fileopen <fhvar> <路徑> <append 0\|1> [readonly 0\|1]` | 開檔;fh 存入變數(-1 失敗)。readonly=1 開來讀。 |
| `filecreate` | `filecreate <fhvar> <路徑>` | 建新檔(覆寫)供寫入。 |
| `filereadln` | `filereadln <fh> <strvar>` | 讀一行;`result` **1 = EOF**、0 = 成功(TeraTerm 相容)。 |
| `filewrite` / `filewriteln` | `filewrite <fh> <str>` | 寫入(ln 版加換行)。 |
| `fileclose` | `fileclose <fh>` | 關檔(腳本結束會自動關)。 |
| `filedelete` | `filedelete <路徑>` | 刪檔,`result` 1/0。 |
| `filesearch` | `filesearch <路徑>` | `result` 1 = 檔案存在。 |
| `basename` / `dirname` | `basename <strvar> <路徑>` | 取檔名 / 取目錄。 |
| `makepath` | `makepath <strvar> <dir> <file>` | 合成路徑。 |
| `foldercreate` / `folderdelete` / `foldersearch` | `foldercreate <路徑>` | 建 / 刪(空)/ 查資料夾。 |
| `getdir` / `setdir` | `getdir <strvar>` / `setdir <路徑>` | 取得 / 變更工作目錄。 |
| `logopen` / `logwrite` / `logclose` | `logopen '檔名'` | 腳本專屬 log 檔(`send``logwrite` 會寫入)。 |
## 對話框 / 雜項
| 指令 | 語法 | 說明 |
|------|------|------|
| `messagebox` | `messagebox '訊息' ['標題']` | 訊息框。 |
| `inputbox` | `inputbox '提示' ['標題'] [預設值]` | 輸入框 → `inputstr`(取消 `result=0`)。 |
| `yesnobox` | `yesnobox '訊息' ['標題']` | Yes/No → `result` 1/0。 |
| `beep` | `beep` | 系統提示音。 |
| `getdate` / `gettime` | `getdate <strvar> ['%Y%m%d']` | 日期 / 時間字串(strftime 子集:`%Y %y %m %d %H %M %S %j %a %A %b`)。 |
| `getenv` / `setenv` | `getenv 'PATH' <strvar>` | 讀 / 寫環境變數(行程內)。 |
| `random` | `random <intvar> <max>` | 0〜max(含)亂數。 |
| `exec` | `exec '記事本.exe 檔案' ['show'\|'hide'] [wait 0\|1]` | 啟動外部程式;wait=1 時 `result` = exit code。 |
| `getver` | `getver <strvar>` | ETTerms 版本字串。 |
| `getttdir` | `getttdir <strvar>` | ETTerms 執行檔目錄。 |
| `uptime` | `uptime <intvar>` | 系統開機至今毫秒數。 |
| `ifdefined` | `ifdefined <var>` | `result`:0 未定義 / 1 整數 / 2 字串。 |
| `clipb2var` / `var2clipb` | `clipb2var <strvar>` / `var2clipb <str>` | 剪貼簿 ↔ 變數。 |
| `crc32` | `crc32 <intvar> <str>` | CRC-32。 |
| `checksum8/16/32` | `checksum8 <intvar> <str>` | byte 加總(8/16/32 bit)。 |
### 未支援(節錄)
檔案傳輸協定(`xmodem*` / `zmodem*` / `kmt*` / `scp*`)、連線管理(`connect` / `disconnect` /
`closett`)、密碼系列(`getpassword` 等——ETTerms 密碼一律走 Windows Credential Manager)、
陣列(`intdim` / `strdim`)、`waitevent` / `waitn` / `setecho` / `settitle` / `listbox` / `statusbox`
--- ---
## sprintf2 格式化 ## sprintf2 格式化
`sprintf2 變數 格式字串 [引數 ...]` 以 C 語言 `printf` 規則格式化,結果存入指定字串變數,與 `sprintf2 變數 格式字串 [引數 ...]` 以 C `printf` 規則格式化,與 Tera Term 一致。
Tera Term 的 `sprintf2` 行為一致。
```ttl ```ttl
sprintf2 ver 'Tera Term 4.%d' 51 ; ver = "Tera Term 4.51" sprintf2 ver 'Tera Term 4.%d' 51 ; ver = "Tera Term 4.51"
sprintf2 win 'Windows %d (+%s)' 2000 'SP4' ; win = "Windows 2000 (+SP4)"
sprintf2 test '%s=%d %s=0x%x' 'dec' 10 'hex' 33 ; test = "dec=10 hex=0x21" sprintf2 test '%s=%d %s=0x%x' 'dec' 10 'hex' 33 ; test = "dec=10 hex=0x21"
messagebox test
``` ```
- **轉換型別**`c d i o u x X e E f g G a A s` - **轉換型別**`c d i o u x X e E f g G a A s`**旗標**`- + 0 #` 與空白;寬度/精度支援 `*`
- **旗標**`-`(靠左)、`+`(顯示正負號)、`0`(補零)、`#`(替代格式,如 `0x`)、空白(正數前空格) - 浮點數以**字串**傳入:`sprintf2 s '%.2f' '3.14159'`
- **寬度/精度**:十進位整數,或用 `*` 從引數動態取得(如 `%*d``%.*f` - 格式字串不展開變數(保持字面值),引數會展開,故 `sprintf2 s '%s,' s` 可累加自身
- 浮點數須以**字串**傳入(TTL 無浮點型別),例 `sprintf2 s '%.2f' '3.14159'` - `result`:0 成功、1 缺格式、2 格式無效、3 引數無效、4 目的變數無效
- **格式字串不展開變數**(保持字面值),僅各引數會展開變數,故可累加自身:
`sprintf2 s '%s,' s` 會把 `s` 接上 `,`
執行後設定系統變數 `result`
| 值 | 狀態 |
|----|------|
| 0 | 格式化成功 |
| 1 | 缺少格式字串 |
| 2 | 格式無效 |
| 3 | 引數無效(缺少或無法解析) |
| 4 | 目的變數名稱無效 |
--- ---
## 範例SSH 自動登入 + 收 log ## 範例
### 自動登入 + 失敗重試(goto / 單行 if
```ttl ```ttl
;
timeout = 15 timeout = 15
retry = 0
:login
sendln ''
wait 'login:' wait 'login:'
sendln 'admin' sendln 'admin'
wait 'Password:' wait 'Password:'
sendln 'secret' sendln 'secret'
wait '$' wait 'Login incorrect' '$'
if result = 2 goto ok ; 2 $
retry = retry + 1
if retry < 3 goto login
messagebox '登入失敗 3 次' 'Login'
end
logopen 'session.log' :ok
sendln 'uname -a' dispstr 'login ok'
wait '$'
sendln 'uptime'
wait '$'
logclose
messagebox 'Done'
``` ```
## 範例:while 迴圈下命令 ### waitln + strmatch 解析輸出
```ttl ```ttl
idx = 0 sendln 'show environment'
while idx < 5 timeout = 10
sendln 'echo loop' waitln 'Temperature' ; "Temperature: 47 C"
wait '$' if result = 0 goto notfound
idx = idx + 1 strmatch inputstr 'Temperature:\s*(\d+)'
endwhile if result > 0 then
``` str2int temp groupmatchstr1
if temp > 60 then
## 範例:if / elseif / else messagebox '過溫!' 'ALERT'
endif
```ttl
sendln 'whoami'
wait '$'
if result == 1 then
logwrite 'prompt matched'
elseif result == 0 then
logwrite 'timed out'
else
logwrite 'unknown'
endif endif
:notfound
```
### for 迴圈 + 檔案輸出
```ttl
getdate today '%Y%m%d'
sprintf 'report_%s.txt' today
filecreate fh inputstr
for i 1 5
sendln 'cat /proc/loadavg'
recvln ;
recvln ;
filewriteln fh inputstr
pause 2
next
fileclose fh
```
### call 副程式(迴圈內可用)
```ttl
for i 1 3
call powercycle
wait 'login:'
next
end
:powercycle
pductrl 1 3 0
pause 5
pductrl 1 3 1
return
```
### do-loop 等裝置就緒
```ttl
; wait waitln result=0
timeout = 2
do
sendln ''
waitln 'SVOS>'
loop until result = 1 ; 2
``` ```
@@ -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 });
} }
+20
View File
@@ -183,6 +183,26 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog = private static readonly ChangelogEntry[] Changelog =
[ [
new("0.5.0", new DateOnly(2026, 7, 2), "Search, keyword alerts & a much bigger scripting language",
[
"Press Ctrl+F in any terminal to search everything you've scrolled past — all hits are highlighted, Enter jumps between them.",
"New Settings → Highlight page: add your own keywords (like ERROR or FAIL) and they light up in red wherever they appear.",
"When a keyword shows up in a tab you're not looking at, that tab's dot turns red so you don't miss it.",
"TTL scripting grew from ~15 commands to 90+, matching Tera Term macros: loops (for / do / until), goto and subroutines, waitln / waitregex with regex capture, string and file operations, input dialogs, and serial line control (sendbreak, setbaud).",
"Scripts now show their progress in gray right inside the terminal — display only, never written to session logs; turn it off in Settings → Terminal (Show script trace) if you want a clean screen.",
"See the full command table with examples in docs/ttl-script-reference.md.",
]),
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", 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.", "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.",
+119
View File
@@ -52,6 +52,7 @@ 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("Highlight", BuildHighlightTab()));
tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab())); tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab()));
Controls.Add(body); Controls.Add(body);
@@ -93,6 +94,14 @@ public sealed class SettingsView : UserControl
flow.Controls.Add(MakeRow("Scrollback Lines", scrollback)); flow.Controls.Add(MakeRow("Scrollback Lines", scrollback));
flow.Controls.Add(MakeRow("Color Scheme", scheme)); flow.Controls.Add(MakeRow("Color Scheme", scheme));
flow.Controls.Add(MakeRow("Default Newline", newline)); flow.Controls.Add(MakeRow("Default Newline", newline));
var scriptTrace = new CheckBox
{
Text = "Show script trace in terminal ([wait] / sent commands / errors in gray; dispstr always shows)",
Checked = s.ShowScriptTrace, AutoSize = true,
ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 6, 0, 0)
};
flow.Controls.Add(scriptTrace);
flow.Controls.Add(MakeSpacer(16)); flow.Controls.Add(MakeSpacer(16));
// Shell settings // Shell settings
@@ -160,6 +169,7 @@ public sealed class SettingsView : UserControl
s.ScrollbackLines = (int)scrollback.Value; s.ScrollbackLines = (int)scrollback.Value;
s.ColorScheme = scheme.Text; s.ColorScheme = scheme.Text;
s.DefaultNewLine = newline.Text; s.DefaultNewLine = newline.Text;
s.ShowScriptTrace = scriptTrace.Checked;
s.ShellType = shellType.Text; s.ShellType = shellType.Text;
s.ShellStartupDir = shellDir.Text; s.ShellStartupDir = shellDir.Text;
s.Save(); s.Save();
@@ -171,6 +181,115 @@ public sealed class SettingsView : UserControl
return page; return page;
} }
// ═══ Highlight Tab ═══
private Panel BuildHighlightTab()
{
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
var s = AppSettings.Instance;
var flow = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
};
flow.Controls.Add(new Label
{
Text = "Keyword Highlighting", AutoSize = true,
ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4)
});
flow.Controls.Add(new Label
{
Text = "Keywords below are highlighted in red in every terminal (case-insensitive).\n" +
"When a keyword appears in a background tab, that tab's dot turns red until you open it.",
AutoSize = false, Width = 520, Height = 34,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
});
var enable = new CheckBox
{
Text = "Enable keyword highlighting", Checked = s.KeywordHighlightEnabled,
AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
};
flow.Controls.Add(enable);
// 規則清單:每列 = 啟用勾選 + 關鍵字(可直接編輯)
var grid = new DataGridView
{
Width = 420, Height = 240,
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,
AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
Font = Theme.UiFont, RowTemplate = { Height = 24 }, Margin = new Padding(0, 0, 0, 8)
};
grid.Columns.Add(new DataGridViewCheckBoxColumn { Name = "On", HeaderText = "On", Width = 44 });
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Keyword", HeaderText = "Keyword",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
});
foreach (var r in s.KeywordRules) grid.Rows.Add(r.Enabled, r.Text);
flow.Controls.Add(grid);
// 新增 / 移除
var newKw = new TextBox { Width = 220, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle };
var addBtn = MakeButton("Add", Theme.Accent);
var removeBtn = MakeButton("Remove Selected", Color.FromArgb(210, 120, 120));
void AddKeyword()
{
var t = newKw.Text.Trim();
if (t.Length == 0) return;
grid.Rows.Add(true, t);
newKw.Clear();
newKw.Focus();
}
addBtn.Click += (_, _) => AddKeyword();
newKw.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) { AddKeyword(); e.Handled = e.SuppressKeyPress = true; } };
removeBtn.Click += (_, _) =>
{
foreach (DataGridViewRow row in grid.SelectedRows) grid.Rows.Remove(row);
};
var editRow = new FlowLayoutPanel
{
FlowDirection = FlowDirection.LeftToRight, AutoSize = true,
WrapContents = false, BackColor = Theme.WorkspaceBack, Margin = new Padding(0, 0, 0, 12)
};
editRow.Controls.Add(newKw);
editRow.Controls.Add(addBtn);
editRow.Controls.Add(removeBtn);
flow.Controls.Add(editRow);
var save = MakeButton("Save", Theme.Accent);
save.Margin = new Padding(0);
save.Click += (_, _) =>
{
grid.EndEdit();
s.KeywordHighlightEnabled = enable.Checked;
s.KeywordRules = grid.Rows.Cast<DataGridViewRow>()
.Select(r => new KeywordRule
{
Enabled = r.Cells["On"].Value is true,
Text = r.Cells["Keyword"].Value?.ToString()?.Trim() ?? ""
})
.Where(r => r.Text.Length > 0)
.ToList();
s.Save();
MessageBox.Show(this, "Highlight settings saved.\nThey apply immediately to all open sessions.",
"Highlight", MessageBoxButtons.OK, MessageBoxIcon.Information);
};
flow.Controls.Add(save);
page.Controls.Add(flow);
return page;
}
// ═══ AI MCP Tab ═══ // ═══ AI MCP Tab ═══
private Panel BuildAiMcpTab() private Panel BuildAiMcpTab()
{ {
+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++)
{ {
+13 -34
View File
@@ -20,6 +20,7 @@ public sealed class WorkspaceView : UserControl
public required SessionPage Page; public required SessionPage Page;
public Rectangle TabBounds; public Rectangle TabBounds;
public Rectangle CloseRect; public Rectangle CloseRect;
public bool Alert; // 背景分頁出現高亮關鍵字 → 標紅點,切到該分頁時清除
} }
private readonly FlowLayoutPanel _toolbar; private readonly FlowLayoutPanel _toolbar;
@@ -114,6 +115,12 @@ public sealed class WorkspaceView : UserControl
var page = BuildPage(conn); var page = BuildPage(conn);
var s = new Session { Title = conn.Name, IsSsh = conn.IsSsh, Page = page }; var s = new Session { Title = conn.Name, IsSsh = conn.IsSsh, Page = page };
page.ConnectFailed += msg => OnConnectFailed(s, msg); page.ConnectFailed += msg => OnConnectFailed(s, msg);
page.KeywordAlert += _ =>
{
if (s == _active || s.Alert) return;
s.Alert = true;
_tabStrip.Invalidate();
};
_sessions.Add(s); _sessions.Add(s);
_active = s; _active = s;
Relayout(); Relayout();
@@ -191,6 +198,7 @@ public sealed class WorkspaceView : UserControl
} }
_empty.Visible = false; _empty.Visible = false;
_active ??= _sessions[0]; _active ??= _sessions[0];
_active.Alert = false; // 使用者正在看這個分頁,警示清除
int cells = _rows * _cols; int cells = _rows * _cols;
int activeIdx = _sessions.IndexOf(_active); int activeIdx = _sessions.IndexOf(_active);
@@ -360,7 +368,8 @@ public sealed class WorkspaceView : UserControl
if (drag) if (drag)
using (var pen = new Pen(Theme.Accent, 1)) using (var pen = new Pen(Theme.Accent, 1))
g.DrawRectangle(pen, new Rectangle(s.TabBounds.Left, s.TabBounds.Top, s.TabBounds.Width - 1, s.TabBounds.Height - 1)); g.DrawRectangle(pen, new Rectangle(s.TabBounds.Left, s.TabBounds.Top, s.TabBounds.Width - 1, s.TabBounds.Height - 1));
using (var dot = new SolidBrush(s.IsSsh ? Theme.SshColor : Theme.SerialColor)) // 警示中的背景分頁:型別圓點改紅色,切過去看時清除
using (var dot = new SolidBrush(s.Alert ? Color.FromArgb(235, 85, 85) : s.IsSsh ? Theme.SshColor : Theme.SerialColor))
g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8); g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8);
var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH); var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH);
TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim, TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim,
@@ -452,22 +461,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 +482,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
}); });
+8 -6
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName> <AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 --> <!-- 版本資訊 -->
<Version>0.3.3</Version> <Version>0.5.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,
@@ -49,9 +51,9 @@
<_McpSelfContained Condition="'$(SelfContained)' != ''">$(SelfContained)</_McpSelfContained> <_McpSelfContained Condition="'$(SelfContained)' != ''">$(SelfContained)</_McpSelfContained>
<_McpSelfContained Condition="'$(SelfContained)' == ''">false</_McpSelfContained> <_McpSelfContained Condition="'$(SelfContained)' == ''">false</_McpSelfContained>
</PropertyGroup> </PropertyGroup>
<Message Importance="high" Text="[ETTerms] Publishing ETTerms.SerialMcp -> $(PublishDir)ETTerms.SerialMcp (self-contained=$(_McpSelfContained))" /> <Message Importance="high" Text="[ETTerms] Publishing ETTerms.SerialMcp -&gt; $(PublishDir)ETTerms.SerialMcp (self-contained=$(_McpSelfContained))" />
<Exec Command="dotnet publish &quot;$(MSBuildThisFileDirectory)..\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj&quot; -c $(Configuration) -r $(_McpRid) --self-contained $(_McpSelfContained) -o &quot;$(PublishDir)ETTerms.SerialMcp&quot;" /> <Exec Command="dotnet publish &quot;$(MSBuildThisFileDirectory)..\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj&quot; -c $(Configuration) -r $(_McpRid) --self-contained $(_McpSelfContained) -o &quot;$(PublishDir)ETTerms.SerialMcp&quot;" />
<Message Importance="high" Text="[ETTerms] Publishing ETTerms.PduMcp -> $(PublishDir)ETTerms.PduMcp (self-contained=$(_McpSelfContained))" /> <Message Importance="high" Text="[ETTerms] Publishing ETTerms.PduMcp -&gt; $(PublishDir)ETTerms.PduMcp (self-contained=$(_McpSelfContained))" />
<Exec Command="dotnet publish &quot;$(MSBuildThisFileDirectory)..\ETTerms.PduMcp\ETTerms.PduMcp.csproj&quot; -c $(Configuration) -r $(_McpRid) --self-contained $(_McpSelfContained) -o &quot;$(PublishDir)ETTerms.PduMcp&quot;" /> <Exec Command="dotnet publish &quot;$(MSBuildThisFileDirectory)..\ETTerms.PduMcp\ETTerms.PduMcp.csproj&quot; -c $(Configuration) -r $(_McpRid) --self-contained $(_McpSelfContained) -o &quot;$(PublishDir)ETTerms.PduMcp&quot;" />
</Target> </Target>
+14
View File
@@ -22,11 +22,18 @@ public sealed class AppSettings
public int ScrollbackLines { get; set; } = 5000; public int ScrollbackLines { get; set; } = 5000;
public string DefaultNewLine { get; set; } = "\\r\\n"; public string DefaultNewLine { get; set; } = "\\r\\n";
public string ColorScheme { get; set; } = "Dark"; public string ColorScheme { get; set; } = "Dark";
/// <summary>TTL 腳本執行時,[wait] / >> 送出回顯 / 錯誤等 trace 是否以灰色顯示在終端機
/// (只影響畫面;本來就不會寫進側錄 log。dispstr 一律顯示,不受此開關影響)。</summary>
public bool ShowScriptTrace { get; set; } = true;
// ── Shell ── // ── Shell ──
public string ShellType { get; set; } = "PowerShell"; // PowerShell, Bash, Cmd public string ShellType { get; set; } = "PowerShell"; // PowerShell, Bash, Cmd
public string ShellStartupDir { get; set; } = ""; public string ShellStartupDir { get; set; } = "";
// ── Keyword highlight(終端機關鍵字標色 + 分頁警示;Settings → Highlight 分頁設定)──
public bool KeywordHighlightEnabled { get; set; } = true;
public List<KeywordRule> KeywordRules { get; set; } = new();
// ── Window ── // ── Window ──
public int WindowX { get; set; } = -1; public int WindowX { get; set; } = -1;
public int WindowY { get; set; } = -1; public int WindowY { get; set; } = -1;
@@ -83,3 +90,10 @@ public sealed class AppSettings
return new(); return new();
} }
} }
/// <summary>一條關鍵字高亮規則:關鍵字文字 + 是否啟用(比對不分大小寫)。</summary>
public sealed class KeywordRule
{
public string Text { get; set; } = "";
public bool Enabled { get; set; } = true;
}
+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() { }
}
+11 -32
View File
@@ -14,47 +14,25 @@ public sealed class ScriptRunner
/// <summary>(檔名, 行號, 指令) 進度。</summary> /// <summary>(檔名, 行號, 指令) 進度。</summary>
public event Action<string, int, string>? StatusChanged; public event Action<string, int, string>? StatusChanged;
/// <summary>輸出訊息(log / 提示 / 錯誤)。</summary> /// <summary>trace 訊息([wait] 進度 / >> 送出回顯 / 錯誤);顯示與否由 UI 依設定決定。</summary>
public event Action<string>? Output; public event Action<string>? Output;
/// <summary>dispstr 的顯示輸出(一律顯示)。</summary>
public event Action<string>? Display;
/// <summary>結束:(成功, 訊息)。</summary> /// <summary>結束:(成功, 訊息)。</summary>
public event Action<bool, string>? Finished; public event Action<bool, string>? Finished;
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;
@@ -62,6 +40,7 @@ public sealed class ScriptRunner
var interp = new TTLInterpreter(channel, sync, memberLabel); var interp = new TTLInterpreter(channel, sync, memberLabel);
interp.StatusChanged += (f, l, c) => StatusChanged?.Invoke(f, l, c); interp.StatusChanged += (f, l, c) => StatusChanged?.Invoke(f, l, c);
interp.Output += m => Output?.Invoke(m); interp.Output += m => Output?.Invoke(m);
interp.Display += m => Display?.Invoke(m);
_interp = interp; _interp = interp;
try try
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
using System.Globalization;
namespace ETTerms.Scripting;
/// <summary>
/// TTL 運算式解析 / 求值器(v0.5.0,供 if / elseif / while / until / for / 變數指派使用)。
///
/// 支援:
/// - 整數:十進位、<c>0x1F</c>、<c>$1F</c>TeraTerm 十六進位)
/// - 字串常值:<c>'...'</c> 或 <c>"..."</c>
/// - 變數(由呼叫端 resolver 解析;未定義視為 0)
/// - 括號、一元 <c>-</c> / <c>not</c> / <c>!</c> / <c>~</c>
/// - 算術 <c>* / % + -</c>
/// - 比較 <c>= == &lt;&gt; != &gt; &lt; &gt;= &lt;=</c>(兩邊皆可為整數時用數值比較,否則字串比較)
/// - 邏輯 <c>and or xor</c>(同義:<c>&amp;&amp; ||</c>
///
/// 值為 <see cref="object"/>int 或 string;比較 / 邏輯運算結果為 int 1/0。
/// </summary>
public static class TtlExpression
{
/// <summary>求值整段文字;結尾若有多餘內容視為錯誤(FormatException)。</summary>
public static object Evaluate(string text, Func<string, object?> resolve)
{
int pos = 0;
var v = ParseOr(text, ref pos, resolve);
SkipWs(text, ref pos);
if (pos < text.Length) throw new FormatException($"unexpected '{text[pos..]}'");
return v;
}
/// <summary>從 <paramref name="pos"/> 起解析一個運算式,pos 停在運算式之後(供單行 if 判斷剩餘 statement)。</summary>
public static object Parse(string text, ref int pos, Func<string, object?> resolve)
=> ParseOr(text, ref pos, resolve);
public static bool Truthy(object? v) => ToInt(v) != 0;
public static int ToInt(object? v) => v switch
{
int i => i,
string s when TryParseInt(s.Trim(), out int r) => r,
_ => 0
};
public static string ToStr(object? v) => v switch
{
null => "",
string s => s,
_ => v.ToString() ?? ""
};
// ── 文法(優先序低 → 高)─────────────────────────────────
private static object ParseOr(string s, ref int p, Func<string, object?> r)
{
var v = ParseAnd(s, ref p, r);
while (true)
{
SkipWs(s, ref p);
if (MatchWord(s, ref p, "or") || MatchOp(s, ref p, "||"))
v = (Truthy(v) | Truthy(ParseAnd(s, ref p, r))) ? 1 : 0;
else if (MatchWord(s, ref p, "xor"))
v = (Truthy(v) ^ Truthy(ParseAnd(s, ref p, r))) ? 1 : 0;
else return v;
}
}
private static object ParseAnd(string s, ref int p, Func<string, object?> r)
{
var v = ParseCompare(s, ref p, r);
while (true)
{
SkipWs(s, ref p);
if (MatchWord(s, ref p, "and") || MatchOp(s, ref p, "&&"))
v = (Truthy(v) & Truthy(ParseCompare(s, ref p, r))) ? 1 : 0;
else return v;
}
}
private static object ParseCompare(string s, ref int p, Func<string, object?> r)
{
var l = ParseAdd(s, ref p, r);
SkipWs(s, ref p);
string? op =
MatchOp(s, ref p, ">=") ? ">=" :
MatchOp(s, ref p, "<=") ? "<=" :
MatchOp(s, ref p, "<>") ? "!=" :
MatchOp(s, ref p, "==") ? "==" :
MatchOp(s, ref p, "!=") ? "!=" :
MatchOp(s, ref p, "=") ? "==" :
MatchOp(s, ref p, ">") ? ">" :
MatchOp(s, ref p, "<") ? "<" : null;
if (op == null) return l;
var rt = ParseAdd(s, ref p, r);
// 兩邊皆可為整數 → 數值比較;否則字串比較(僅 == / !=;大小比較退回數值 0)
bool numeric = l is int || rt is int
|| (TryParseInt(ToStr(l).Trim(), out _) && TryParseInt(ToStr(rt).Trim(), out _));
if (numeric)
{
int a = ToInt(l), b = ToInt(rt);
return op switch
{
"==" => a == b ? 1 : 0,
"!=" => a != b ? 1 : 0,
">=" => a >= b ? 1 : 0,
"<=" => a <= b ? 1 : 0,
">" => a > b ? 1 : 0,
_ => a < b ? 1 : 0
};
}
int cmp = string.CompareOrdinal(ToStr(l), ToStr(rt));
return op switch
{
"==" => cmp == 0 ? 1 : 0,
"!=" => cmp != 0 ? 1 : 0,
">=" => cmp >= 0 ? 1 : 0,
"<=" => cmp <= 0 ? 1 : 0,
">" => cmp > 0 ? 1 : 0,
_ => cmp < 0 ? 1 : 0
};
}
private static object ParseAdd(string s, ref int p, Func<string, object?> r)
{
var v = ParseMul(s, ref p, r);
while (true)
{
SkipWs(s, ref p);
if (MatchOp(s, ref p, "+")) v = ToInt(v) + ToInt(ParseMul(s, ref p, r));
else if (MatchOp(s, ref p, "-")) v = ToInt(v) - ToInt(ParseMul(s, ref p, r));
else return v;
}
}
private static object ParseMul(string s, ref int p, Func<string, object?> r)
{
var v = ParseUnary(s, ref p, r);
while (true)
{
SkipWs(s, ref p);
if (MatchOp(s, ref p, "*")) v = ToInt(v) * ToInt(ParseUnary(s, ref p, r));
else if (MatchOp(s, ref p, "/"))
{
int d = ToInt(ParseUnary(s, ref p, r));
v = d != 0 ? ToInt(v) / d : 0;
}
else if (MatchOp(s, ref p, "%"))
{
int d = ToInt(ParseUnary(s, ref p, r));
v = d != 0 ? ToInt(v) % d : 0;
}
else return v;
}
}
private static object ParseUnary(string s, ref int p, Func<string, object?> r)
{
SkipWs(s, ref p);
if (MatchOp(s, ref p, "-")) return -ToInt(ParseUnary(s, ref p, r));
if (MatchOp(s, ref p, "~")) return ~ToInt(ParseUnary(s, ref p, r));
if (MatchOp(s, ref p, "!")) return Truthy(ParseUnary(s, ref p, r)) ? 0 : 1;
if (MatchWord(s, ref p, "not")) return Truthy(ParseUnary(s, ref p, r)) ? 0 : 1;
return ParsePrimary(s, ref p, r);
}
private static object ParsePrimary(string s, ref int p, Func<string, object?> r)
{
SkipWs(s, ref p);
if (p >= s.Length) throw new FormatException("unexpected end of expression");
char c = s[p];
if (c == '(')
{
p++;
var v = ParseOr(s, ref p, r);
SkipWs(s, ref p);
if (p >= s.Length || s[p] != ')') throw new FormatException("missing ')'");
p++;
return v;
}
if (c is '\'' or '"')
{
char q = c;
int start = ++p;
while (p < s.Length && s[p] != q) p++;
if (p >= s.Length) throw new FormatException("unterminated string");
var str = s[start..p];
p++;
return str;
}
if (c == '$') // TeraTerm 十六進位 $1F
{
int start = ++p;
while (p < s.Length && Uri.IsHexDigit(s[p])) p++;
if (p == start) throw new FormatException("invalid hex literal");
return int.Parse(s[start..p], NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
if (char.IsDigit(c))
{
if (c == '0' && p + 1 < s.Length && (s[p + 1] is 'x' or 'X'))
{
int hs = p + 2, hp = hs;
while (hp < s.Length && Uri.IsHexDigit(s[hp])) hp++;
if (hp == hs) throw new FormatException("invalid hex literal");
p = hp;
return int.Parse(s[hs..hp], NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
int ds = p;
while (p < s.Length && char.IsDigit(s[p])) p++;
return int.Parse(s[ds..p], CultureInfo.InvariantCulture);
}
if (char.IsLetter(c) || c == '_')
{
int ws = p;
while (p < s.Length && (char.IsLetterOrDigit(s[p]) || s[p] == '_')) p++;
return r(s[ws..p]) ?? 0; // 未定義變數 → 0
}
throw new FormatException($"unexpected character '{c}'");
}
// ── 小工具 ───────────────────────────────────────────────
private static void SkipWs(string s, ref int p) { while (p < s.Length && char.IsWhiteSpace(s[p])) p++; }
private static bool MatchOp(string s, ref int p, string op)
{
if (p + op.Length > s.Length || !s.AsSpan(p, op.Length).SequenceEqual(op)) return false;
// "=" 不可吃掉 "=="、"<" 不可吃掉 "<=" / "<>"(呼叫端已按長度優先排序,這裡防呆單字元誤判)
if (op == "=" && p + 1 < s.Length && s[p + 1] == '=') return false;
if (op == ">" && p + 1 < s.Length && s[p + 1] == '=') return false;
if (op == "<" && p + 1 < s.Length && (s[p + 1] == '=' || s[p + 1] == '>')) return false;
p += op.Length;
return true;
}
private static bool MatchWord(string s, ref int p, string word)
{
if (p + word.Length > s.Length) return false;
if (!s.AsSpan(p, word.Length).Equals(word, StringComparison.OrdinalIgnoreCase)) return false;
int after = p + word.Length;
if (after < s.Length && (char.IsLetterOrDigit(s[after]) || s[after] == '_')) return false; // 是識別字的一部分
p = after;
return true;
}
private static bool TryParseInt(string s, out int v)
{
if (s.StartsWith("0x") || s.StartsWith("0X"))
return int.TryParse(s[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out v);
if (s.StartsWith("$"))
return int.TryParse(s[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out v);
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v);
}
}
+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);
+16
View File
@@ -72,6 +72,22 @@ public sealed class SerialChannel : ISessionChannel
if (_port.IsOpen) _port.Write(data, 0, data.Length); if (_port.IsOpen) _port.Write(data, 0, data.Length);
} }
// ── TTL serial 控制指令用(sendbreak / setbaud / setdtr / setrts)──
/// <summary>送出 serial break(拉 break 狀態約 300ms)。</summary>
public void SendBreak()
{
if (!_port.IsOpen) return;
try { _port.BreakState = true; Thread.Sleep(300); }
finally { try { _port.BreakState = false; } catch { } }
}
/// <summary>執行中變更 baud rateTTL setbaud)。</summary>
public void SetBaudRate(int baud) => _port.BaudRate = baud;
public void SetDtr(bool on) => _port.DtrEnable = on;
public void SetRts(bool on) => _port.RtsEnable = on;
public void Resize(int cols, int rows) { /* serial 無 PTY size */ } public void Resize(int cols, int rows) { /* serial 無 PTY size */ }
public void Close() public void Close()
+16 -25
View File
@@ -63,6 +63,9 @@ public sealed class SessionPage : UserControl
/// <summary>同步開啟失敗(主要是 Serial 連不上 / 被占用)時觸發,附帶訊息。</summary> /// <summary>同步開啟失敗(主要是 Serial 連不上 / 被占用)時觸發,附帶訊息。</summary>
public event Action<string>? ConnectFailed; public event Action<string>? ConnectFailed;
/// <summary>輸出中出現啟用的高亮關鍵字(UI thread),供 WorkspaceView 標分頁警示。</summary>
public event Action<string>? KeywordAlert;
public SessionPage(ISessionChannel channel, string title) public SessionPage(ISessionChannel channel, string title)
{ {
_channel = channel; _channel = channel;
@@ -90,30 +93,29 @@ public sealed class SessionPage : UserControl
_term = new TerminalView(new TerminalProfile()) { Dock = DockStyle.Fill }; _term = new TerminalView(new TerminalProfile()) { Dock = DockStyle.Fill };
_term.SendData += data => _channel.Write(data); _term.SendData += data => _channel.Write(data);
_term.Resized += (cols, rows) => _channel.Resize(cols, rows); _term.Resized += (cols, rows) => _channel.Resize(cols, rows);
_term.KeywordAlert += k => KeywordAlert?.Invoke(k);
Controls.Add(_term); // Fill 先加 Controls.Add(_term); // Fill 先加
Controls.Add(bar); // Top Controls.Add(bar); // Top
_runner.StatusChanged += (_, line, cmd) => Ui(() => _status.Text = $"line {line}: {Trunc(cmd)}"); _runner.StatusChanged += (_, line, cmd) => Ui(() => _status.Text = $"line {line}: {Trunc(cmd)}");
_runner.Finished += (_, msg) => Ui(() => { _status.Text = msg; SetRunning(false); }); _runner.Finished += (_, msg) => Ui(() => { _status.Text = msg; SetRunning(false); });
// 腳本 trace[wait] 進度 / >> 送出回顯 / 錯誤)以灰色 echo 到終端機;
// 可由 Settings → Terminal 的 Show script trace 關閉。只影響畫面,不會進側錄 log。
_runner.Output += m =>
{
if (!AppSettings.Instance.ShowScriptTrace) return;
Ui(() => _term.Feed(System.Text.Encoding.UTF8.GetBytes($"\x1b[90m{m}\x1b[0m\r\n")));
};
// dispstr 是腳本明確要顯示的內容,一律顯示(不受開關影響)
_runner.Display += m => Ui(() =>
_term.Feed(System.Text.Encoding.UTF8.GetBytes($"\x1b[90m{m}\x1b[0m\r\n")));
} }
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 +170,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);
+22 -2
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,8 +115,15 @@ public sealed class SshChannel : ISessionChannel
public void Write(byte[] data) public void Write(byte[] data)
{ {
if (_shell == null) return; if (_shell == null) return;
_shell.Write(data, 0, data.Length); try
_shell.Flush(); {
_shell.Write(data, 0, data.Length);
_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;
+17 -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,19 @@ 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];
/// <summary>scrollback 滿後被丟棄的總行數。搜尋以「絕對行號 = DroppedLines + abs」錨定命中,
/// 舊行被丟棄時命中位置不會漂移。</summary>
public long DroppedLines { get; private set; }
// ── 內部建構工具 ───────────────────────────────────────── // ── 內部建構工具 ─────────────────────────────────────────
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 +80,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; DroppedLines++; } // 滿了:覆蓋最舊一行
} }
// ── 輸出字元 ───────────────────────────────────────────── // ── 輸出字元 ─────────────────────────────────────────────
@@ -229,7 +237,7 @@ public sealed class ScreenBuffer
public void EraseInDisplay(int mode) public void EraseInDisplay(int mode)
{ {
if (mode == 3) { _scrollback.Clear(); return; } if (mode == 3) { DroppedLines += _sbCount; 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());
}
} }
+354 -18
View File
@@ -1,7 +1,9 @@
using System.Drawing; using System.Drawing;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Windows.Forms; using System.Windows.Forms;
using ETTerms.Infrastructure;
namespace ETTerms.Terminal; namespace ETTerms.Terminal;
@@ -17,6 +19,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;
@@ -28,6 +35,31 @@ public sealed class TerminalView : UserControl
private (int row, int col) _selStart, _selEnd; private (int row, int col) _selStart, _selEnd;
private bool _hasSel; private bool _hasSel;
// ── 搜尋(Ctrl+F)──
private Panel? _searchPanel;
private TextBox _searchBox = null!;
private Label _searchCount = null!;
private readonly List<(long line, int col, int len)> _matches = new(); // line = DroppedLines + abs
private int _matchIdx = -1;
// ── 高亮標記(搜尋命中 + 關鍵字),每次 OnPaint 對可見行重建 ──
private enum MarkKind { Keyword, Search, SearchCurrent }
private readonly Dictionary<int, List<(int col, int len, MarkKind kind)>> _rowMarks = new();
private readonly StringBuilder _lineSb = new();
private readonly List<int> _lineColMap = new();
// ── 關鍵字警示(Feed 偵測,分頁標紅點用)──
private static readonly Regex AnsiStrip = new(
@"\x1B\][^\x07\x1B]*(\x07|\x1B\\)|\x1B[@-Z\\-_]|\x1B\[[0-?]*[ -/]*[@-~]",
RegexOptions.Compiled);
private readonly Decoder _alertDec = Encoding.UTF8.GetDecoder();
private string _alertCarry = "";
private readonly Dictionary<string, long> _alertLastFired = new(StringComparer.OrdinalIgnoreCase);
private const int AlertCooldownMs = 2000;
/// <summary>啟用的關鍵字在輸出中出現(UI thread 觸發),供分頁標警示。</summary>
public event Action<string>? KeywordAlert;
public TerminalView(TerminalProfile profile) public TerminalView(TerminalProfile profile)
{ {
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
@@ -35,6 +67,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 +79,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 +90,13 @@ public sealed class TerminalView : UserControl
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary> /// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
public void Feed(byte[] data) public void Feed(byte[] data)
{ {
DetectKeywords(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();
} }
@@ -75,6 +116,8 @@ public sealed class TerminalView : UserControl
if (ClientSize.Width < _cellW || ClientSize.Height < _cellH) return; if (ClientSize.Width < _cellW || ClientSize.Height < _cellH) return;
if (FindForm() is { WindowState: FormWindowState.Minimized }) return; if (FindForm() is { WindowState: FormWindowState.Minimized }) return;
PositionSearchPanel();
int cols = VisibleCols, rows = VisibleRows; int cols = VisibleCols, rows = VisibleRows;
if (cols == _lastCols && rows == _lastRows) return; if (cols == _lastCols && rows == _lastRows) return;
_lastCols = cols; _lastRows = rows; _lastCols = cols; _lastRows = rows;
@@ -93,6 +136,8 @@ public sealed class TerminalView : UserControl
int top = _buf.ScrollbackCount - _scrollOffset; // 視窗第一列的絕對 index int top = _buf.ScrollbackCount - _scrollOffset; // 視窗第一列的絕對 index
if (top < 0) top = 0; if (top < 0) top = 0;
CollectMarks(top, rows);
for (int vr = 0; vr < rows; vr++) for (int vr = 0; vr < rows; vr++)
{ {
int abs = top + vr; int abs = top + vr;
@@ -116,36 +161,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);
} }
@@ -154,6 +211,27 @@ public sealed class TerminalView : UserControl
fg = cell.Fg.A == 0 ? _buf.DefaultFg : cell.Fg; fg = cell.Fg.A == 0 ? _buf.DefaultFg : cell.Fg;
bg = cell.Bg.A == 0 ? _buf.DefaultBg : cell.Bg; bg = cell.Bg.A == 0 ? _buf.DefaultBg : cell.Bg;
if ((cell.Attr & CellAttr.Inverse) != 0) (fg, bg) = (bg, fg); if ((cell.Attr & CellAttr.Inverse) != 0) (fg, bg) = (bg, fg);
// 標記優先序:目前搜尋命中 > 其他搜尋命中 > 關鍵字(選取反白最後蓋上)
if (_rowMarks.TryGetValue(abs, out var marks))
{
var best = MarkKind.Keyword; bool hit = false;
foreach (var (c0, len, kind) in marks)
{
if (col < c0 || col >= c0 + len) continue;
if (!hit || kind > best) { best = kind; hit = true; }
}
if (hit)
{
switch (best)
{
case MarkKind.SearchCurrent: bg = Color.FromArgb(215, 160, 40); fg = Color.Black; break;
case MarkKind.Search: bg = Color.FromArgb(120, 100, 25); break;
case MarkKind.Keyword: bg = Color.FromArgb(150, 45, 45); break;
}
}
}
if (_hasSel && InSelection(abs, col)) (fg, bg) = (bg, Color.FromArgb(70, 90, 140)); if (_hasSel && InSelection(abs, col)) (fg, bg) = (bg, Color.FromArgb(70, 90, 140));
} }
@@ -174,6 +252,9 @@ public sealed class TerminalView : UserControl
{ {
if (e.Control && e.KeyCode == Keys.C && _hasSel) { CopySelection(); e.Handled = e.SuppressKeyPress = true; return; } if (e.Control && e.KeyCode == Keys.C && _hasSel) { CopySelection(); e.Handled = e.SuppressKeyPress = true; return; }
if ((e.Control && e.KeyCode == Keys.V) || (e.Shift && e.KeyCode == Keys.Insert)) { Paste(); e.Handled = e.SuppressKeyPress = true; return; } if ((e.Control && e.KeyCode == Keys.V) || (e.Shift && e.KeyCode == Keys.Insert)) { Paste(); e.Handled = e.SuppressKeyPress = true; return; }
if (e.Control && e.KeyCode == Keys.F) { OpenSearch(); e.Handled = e.SuppressKeyPress = true; return; }
if (e.KeyCode == Keys.F3 && _searchPanel is { Visible: true }) { StepMatch(e.Shift ? +1 : -1); e.Handled = e.SuppressKeyPress = true; return; }
if (e.KeyCode == Keys.Escape && _searchPanel is { Visible: true }) { CloseSearch(); e.Handled = e.SuppressKeyPress = true; return; }
var bytes = TerminalInput.Map(e, _parser.AppCursorKeys); var bytes = TerminalInput.Map(e, _parser.AppCursorKeys);
if (bytes != null) { SendData?.Invoke(bytes); e.Handled = e.SuppressKeyPress = true; } if (bytes != null) { SendData?.Invoke(bytes); e.Handled = e.SuppressKeyPress = true; }
@@ -182,10 +263,7 @@ 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 +274,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();
} }
@@ -328,6 +414,243 @@ public sealed class TerminalView : UserControl
catch { } catch { }
} }
// ── 搜尋(Ctrl+F)─────────────────────────────────────────
private void OpenSearch()
{
EnsureSearchUi();
_searchPanel!.Visible = true;
PositionSearchPanel();
_searchBox.SelectAll();
_searchBox.Focus();
if (_searchBox.Text.Length > 0) RunSearch();
}
private void CloseSearch()
{
if (_searchPanel == null) return;
_searchPanel.Visible = false;
_matches.Clear();
_matchIdx = -1;
Focus();
Invalidate();
}
private void EnsureSearchUi()
{
if (_searchPanel != null) return;
var back = Color.FromArgb(32, 32, 38);
_searchPanel = new Panel { Size = new Size(268, 30), BackColor = back, Visible = false };
_searchPanel.Paint += (_, pe) =>
{
using var pen = new Pen(Color.FromArgb(58, 58, 66));
pe.Graphics.DrawRectangle(pen, 0, 0, _searchPanel.Width - 1, _searchPanel.Height - 1);
};
_searchBox = new TextBox
{
Bounds = new Rectangle(6, 5, 130, 20), BorderStyle = BorderStyle.None,
BackColor = back, ForeColor = Color.FromArgb(222, 222, 226), Font = new Font("Segoe UI", 9.5f)
};
_searchBox.TextChanged += (_, _) => RunSearch();
_searchBox.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Enter || e.KeyCode == Keys.F3)
{ StepMatch(e.Shift ? +1 : -1); e.Handled = e.SuppressKeyPress = true; } // Enter 往上找(較舊),Shift 往下
else if (e.KeyCode == Keys.Escape)
{ CloseSearch(); e.Handled = e.SuppressKeyPress = true; }
};
_searchCount = new Label
{
Bounds = new Rectangle(138, 7, 56, 16), Text = "",
ForeColor = Color.FromArgb(150, 150, 158), BackColor = back,
Font = new Font("Segoe UI", 8.5f), TextAlign = ContentAlignment.MiddleRight
};
Button MakeBtn(string text, int x, Action onClick)
{
var b = new Button
{
Bounds = new Rectangle(x, 4, 22, 22), Text = text, FlatStyle = FlatStyle.Flat,
ForeColor = Color.FromArgb(180, 180, 188), BackColor = back,
Font = new Font("Segoe UI", 8.5f), TabStop = false, Cursor = Cursors.Hand
};
b.FlatAppearance.BorderSize = 0;
b.FlatAppearance.MouseOverBackColor = Color.FromArgb(60, 60, 70);
b.Click += (_, _) => { onClick(); _searchBox.Focus(); };
return b;
}
var up = MakeBtn("▲", 196, () => StepMatch(-1)); // 往上(較舊)
var down = MakeBtn("▼", 218, () => StepMatch(+1)); // 往下(較新)
var close = MakeBtn("✕", 240, CloseSearch);
_searchPanel.Controls.AddRange(new Control[] { _searchBox, _searchCount, up, down, close });
Controls.Add(_searchPanel);
_searchPanel.BringToFront();
}
private void PositionSearchPanel()
{
if (_searchPanel == null) return;
_searchPanel.Location = new Point(Math.Max(0, ContentWidth - _searchPanel.Width - 8), 6);
}
/// <summary>重掃整個 bufferscrollback + 畫面)建立命中清單,並跳到最靠近底部的命中。</summary>
private void RunSearch()
{
_matches.Clear();
_matchIdx = -1;
string q = _searchBox.Text;
if (q.Length > 0)
{
long dropped = _buf.DroppedLines;
for (int abs = 0; abs < _buf.TotalRows; abs++)
{
BuildLineText(_buf.LineAt(abs), _lineSb, _lineColMap);
string s = _lineSb.ToString();
int at = 0;
while (s.Length >= q.Length && (at = s.IndexOf(q, at, StringComparison.OrdinalIgnoreCase)) >= 0)
{
int colStart = _lineColMap[at];
int colEnd = at + q.Length < _lineColMap.Count
? _lineColMap[at + q.Length]
: _buf.LineAt(abs).Length;
_matches.Add((dropped + abs, colStart, Math.Max(1, colEnd - colStart)));
at++;
}
}
if (_matches.Count > 0) { _matchIdx = _matches.Count - 1; ScrollToMatch(); }
}
UpdateSearchCount();
Invalidate();
}
/// <summary>dir = -1 往上(較舊)、+1 往下(較新),循環。</summary>
private void StepMatch(int dir)
{
if (_matches.Count == 0) { RunSearch(); if (_matches.Count == 0) return; }
else
{
_matchIdx = (_matchIdx + dir + _matches.Count) % _matches.Count;
ScrollToMatch();
}
UpdateSearchCount();
Invalidate();
}
private void ScrollToMatch()
{
if (_matchIdx < 0 || _matchIdx >= _matches.Count) return;
int abs = (int)(_matches[_matchIdx].line - _buf.DroppedLines);
if (abs < 0) return; // 該行已被 scrollback 丟棄
int top = Math.Max(0, abs - VisibleRows / 2);
_scrollOffset = Math.Clamp(_buf.ScrollbackCount - top, 0, _buf.ScrollbackCount);
UpdateScrollBar();
}
private void UpdateSearchCount() =>
_searchCount.Text = _matches.Count == 0
? (_searchBox.Text.Length > 0 ? "0" : "")
: $"{_matchIdx + 1}/{_matches.Count}";
// ── 高亮標記收集(每次重繪對可見行執行)──────────────────
private void CollectMarks(int top, int rows)
{
_rowMarks.Clear();
// 關鍵字(Settings → Highlight):只掃可見行,成本固定
var settings = AppSettings.Instance;
bool kwOn = settings.KeywordHighlightEnabled && settings.KeywordRules.Count > 0;
if (kwOn)
{
for (int vr = 0; vr < rows; vr++)
{
int abs = top + vr;
if (abs >= _buf.TotalRows) break;
BuildLineText(_buf.LineAt(abs), _lineSb, _lineColMap);
string s = _lineSb.ToString();
foreach (var rule in settings.KeywordRules)
{
if (!rule.Enabled || rule.Text.Length == 0) continue;
int at = 0;
while (s.Length >= rule.Text.Length &&
(at = s.IndexOf(rule.Text, at, StringComparison.OrdinalIgnoreCase)) >= 0)
{
int colStart = _lineColMap[at];
int colEnd = at + rule.Text.Length < _lineColMap.Count
? _lineColMap[at + rule.Text.Length]
: _buf.LineAt(abs).Length;
AddMark(abs, colStart, Math.Max(1, colEnd - colStart), MarkKind.Keyword);
at++;
}
}
}
}
// 搜尋命中(RunSearch 已算好,換算回目前 abs)
if (_matches.Count > 0)
{
long dropped = _buf.DroppedLines;
for (int i = 0; i < _matches.Count; i++)
{
int abs = (int)(_matches[i].line - dropped);
if (abs < top || abs >= top + rows) continue;
AddMark(abs, _matches[i].col, _matches[i].len,
i == _matchIdx ? MarkKind.SearchCurrent : MarkKind.Search);
}
}
}
private void AddMark(int abs, int col, int len, MarkKind kind)
{
if (!_rowMarks.TryGetValue(abs, out var list)) _rowMarks[abs] = list = new();
list.Add((col, len, kind));
}
/// <summary>把一行 cell 轉成緊湊字串(略過 WideTrail),並記錄字元 index → 欄位 col 的對應。</summary>
private static void BuildLineText(Cell[] line, StringBuilder sb, List<int> colMap)
{
sb.Clear();
colMap.Clear();
for (int c = 0; c < line.Length; c++)
{
var cell = line[c];
if ((cell.Attr & CellAttr.WideTrail) != 0) continue;
sb.Append(cell.Ch == '\0' ? ' ' : cell.Ch);
colMap.Add(c);
}
}
// ── 關鍵字警示(Feed 路徑偵測,供分頁標紅點)──────────────
private void DetectKeywords(byte[] data)
{
var settings = AppSettings.Instance;
if (!settings.KeywordHighlightEnabled || settings.KeywordRules.Count == 0 || KeywordAlert == null)
{ _alertCarry = ""; return; }
var chars = new char[data.Length];
int n = _alertDec.GetChars(data, 0, data.Length, chars, 0);
if (n == 0) return;
string text = _alertCarry + AnsiStrip.Replace(new string(chars, 0, n), "");
int maxKw = 1;
foreach (var rule in settings.KeywordRules)
{
if (!rule.Enabled || rule.Text.Length == 0) continue;
maxKw = Math.Max(maxKw, rule.Text.Length);
if (text.IndexOf(rule.Text, StringComparison.OrdinalIgnoreCase) < 0) continue;
long now = Environment.TickCount64;
if (_alertLastFired.TryGetValue(rule.Text, out var last) && now - last < AlertCooldownMs) continue;
_alertLastFired[rule.Text] = now;
KeywordAlert.Invoke(rule.Text);
}
// 保留尾巴(最長關鍵字 - 1),跨 chunk 的關鍵字下一輪才接得上
int keep = maxKw - 1;
_alertCarry = text.Length <= keep ? text : text[^keep..];
}
// ── IME(中文 / 日文 / 韓文輸入)───────────────────────── // ── IME(中文 / 日文 / 韓文輸入)─────────────────────────
// 自繪控制項預設不處理 IME 組字,故攔截 WM_IME_COMPOSITION 取「結果字串」直接送出 UTF-8。 // 自繪控制項預設不處理 IME 組字,故攔截 WM_IME_COMPOSITION 取「結果字串」直接送出 UTF-8。
private const int WM_IME_STARTCOMPOSITION = 0x010D; private const int WM_IME_STARTCOMPOSITION = 0x010D;
@@ -397,6 +720,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; }
+1 -1
View File
@@ -28,7 +28,7 @@ while 1
wait "ServiceOS login:" wait "ServiceOS login:"
sendln "admin" sendln "admin"
wait prompt_SVOS ; wait prompt_SVOS
sendln "version" sendln "version"
wait prompt_SVOS wait prompt_SVOS
sendln "help" sendln "help"