21 Commits
Author SHA1 Message Date
etwenandClaude Opus 4.8 3c90df0e83 docs: add v0.7.1 release notes (and bump version to 0.7.1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 09:22:49 +08:00
etwenandClaude Opus 4.8 fae5ca7337 fix(ttl): add sendlnretry — resend until the device confirms it ran the command
A device that is still booting can silently discard console input the moment its
shell takes over the tty (tty reopen / termios flush). The line sent by `sendln`
vanishes — the device neither echoes nor runs it — so the following `wait` blocks
forever and the rig sits dead.

This is a race, not a delay: `pause` before `sendln` only lowers the odds of hitting
the window, it can never close it. Evidence from a 45-cycle overnight power-cycle run
(For_AI/[COM121]_20260715): 3 cycles hung this way, and the swallowed sends landed at
the same DUT uptime (31.8-34.8s) as the 42 that worked. The `random: crng init done`
line those hangs share is a symptom, not the cause — it only appears because a hung
script stops power-cycling, letting the DUT reach uptime 89.7s it never otherwise sees.

Single-string `wait` aborts the script on timeout (deliberate, see CLAUDE.md), so the
TeraTerm idiom `wait` -> `if result = 0 then goto retry` cannot express a resend here.
Hence a new command rather than a semantics change:

    sendlnretry '<text>' '<confirm keyword>' [max attempts]

Sends, then waits for proof the device actually ran it, and resends if that proof does
not arrive. Attempts omitted = retry until it gets through. On success result=1; when
attempts run out result=0 and the script continues so it can handle the failure.

Semantics: clears the receive buffer before each send (a match can only come from this
send); on a hit consumes only up to the first occurrence, leaving the rest for the next
`wait`; deliberately no settle (the keyword appearing is itself proof). Per-attempt
timeout follows timeout/mtimeout when set, else 3s — unlike `wait`, 0 cannot mean
"forever" here since that would mean never retrying.

Not yet run against hardware: compiles clean (cross-built win-x64 on Linux), but the
DUT power-cycle rig is the first real execution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 09:22:49 +08:00
etwenandClaude Fable 5 4f1aff07be merge: v0.7.0 — WebView2 AI chat (bubbles, Markdown, thinking) + configurable tool limit
The AI Assistant pane moves to WebView2: real chat bubbles (user right, AI
left), full Markdown, and an animated thinking indicator while the AI works.
The tool-call limit is now a Settings value (0 = unlimited) with a Stop button
in the chat to abort long/automation runs. Verified end-to-end against an
Arduino serial DUT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:05:58 +08:00
etwenandClaude Fable 5 cc134d56c9 docs: add v0.7.0 release notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:05:58 +08:00
etwenandClaude Fable 5 7ce65536f6 feat(ai): configurable tool-call limit (0=unlimited) + Stop button
Replace the fixed 30-round cap with a Settings value (AiMaxToolRounds,
Settings → AI Assistant). 0 = unlimited, for long automation runs left
going for hours. The chat's Send button turns into Stop while the agent
is running so any run — bounded or unlimited — can be aborted (via the
CancellationToken; the loop also checks it each round).

Docs: ARCHITECTURE gains the configurable-limit note and a generic
"pair with a self-hosted gateway = hardware assistant" usage section
(no private endpoints).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:54:40 +08:00
etwenandClaude Fable 5 d6135237e8 fix(ai): raise agent tool-call limit 8 -> 30
Multi-step hardware tasks (operate several ports, repeated read/write,
power-cycle waits) hit the old 8-round cap easily — e.g. "send help 3
times" needs 8 calls and stopped short of the final summary. 30 keeps
the runaway-loop guard while allowing normal workflows to finish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:45:57 +08:00
etwenandClaude Fable 5 4820de22f8 feat: WebView2 chat — real bubbles, Markdown & thinking indicator (v0.7.0)
The AI Chat pane's message area moves from RichTextBox to WebView2:
- Real chat bubbles (user right, AI left) with full Markdown via Markdig
  (code blocks, tables, lists, inline code).
- Sending a prompt shows an animated "…" thinking bubble that clears when
  the reply lands (AgentHost Status "thinking" → showThinking; AssistantText
  → hideThinking + bubble).
- HTML/CSS/JS template inlined in Ai/ChatHtml.cs (NavigateToString, no
  external deps); C# drives it via ExecuteScriptAsync. Calls made before
  WebView2 is ready are queued and flushed on NavigationCompleted. User-data
  folder under %LocalAppData%\ETTerms\WebView2.

Bottom bar (input / model dropdown / Send), [AI] serial tagging and PDU
confirmations are unchanged. Adds Microsoft.Web.WebView2 + Markdig; needs
the WebView2 Runtime (built into Windows 11). Publish verified to include
the native WebView2Loader.dll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:59:37 +08:00
etwenandClaude Fable 5 cf1fd2c345 merge: v0.6.0 — built-in AI Assistant (BYO endpoint, workspace pane)
Drive serial & PDU in plain language from a workspace pane you can tile
next to a live terminal. BYO OpenAI-compatible endpoint (nothing baked
in), model dropdown in the pane, destructive PDU actions gated by a
confirmation, API key in Credential Manager. Separate from the existing
Serial/PDU MCP servers for external AI CLIs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:36:54 +08:00
etwenandClaude Fable 5 62d3aa7807 docs: add v0.6.0 release notes (and backfill v0.5.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:36:45 +08:00
etwenandClaude Fable 5 639d10438d refactor(ai): clean transcript chat + model dropdown; drop Settings model field
Settle the AI chat pane on a clean, terminal-consistent layout instead of
faux bubbles in a RichTextBox:
- Messages: user right-aligned accent, AI left, tool activity quiet gray,
  "thinking" folded into the Send button (no transcript spam).
- Bottom bar: input (fill) + bottom-right column (model dropdown over Send).
- Model dropdown lists the endpoint's /v1/models, switches on the fly, and
  is remembered — Settings → AI Assistant now only holds Base URL / API Key
  / system prompt (configured = Base URL set). OpenAiChatClient.Model is
  mutable + ListModelsAsync.

Real chat bubbles + Markdown via WebView2 are logged as a v0.7.0 option in
ARCHITECTURE Future Extensions rather than hacked onto RichTextBox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:31:09 +08:00
etwenandClaude Fable 5 ad0f6c5114 feat(ai): chat bubbles + on-the-fly model dropdown
- Chat UI now Claude-style: user messages right-aligned with an accent
  bubble (no "你:" prefix), AI replies left-aligned.
- Model dropdown at the bottom, populated from the endpoint's GET
  /v1/models, lets you switch models without opening Settings; the choice
  is remembered. OpenAiChatClient.Model is now mutable + ListModelsAsync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:12:19 +08:00
etwenandClaude Fable 5 6e955ba247 feat: make AI Assistant a tileable workspace pane (not a rail view)
Open it from the toolbar ( AI Chat) and lay it out next to a Serial
session with Layout (1×2, 2×2…) — chat on one side, watch the terminal
on the other, like Claude Code / Kiro. Previously it was a full-page
Activity Rail view that couldn't sit beside a live session.

WorkspaceView.Session is now abstracted to hold either a SessionPage or
an AiChatView via a Content property; group/log/script actions skip AI
panes. Removed the standalone Ai rail view; Settings → AI Assistant
(provider config) is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:57:09 +08:00
etwenandClaude Fable 5 d9e0e8fabf feat: built-in AI Assistant with BYO endpoint (v0.6.0, Phase 10)
Add a GUI AI Assistant view ( rail) that drives serial + PDU in plain
language, without depending on Claude/Kiro. In-process function calling
(no MCP hop): Ai/OpenAiChatClient (minimal OpenAI-compatible client) +
Ai/AgentHost (hand-written agent loop) + Ai/AiTools (serial via the
existing SerialBridge with [AI] echo; PDU via ETTerms.PduCore).

BYO endpoint: Base URL / Model / API Key set in Settings → AI Assistant,
blank by default = disabled. No private endpoint ships in the app; API
key lives in Windows Credential Manager, never in settings.json or code.

Safety: destructive PDU actions (outlet off / power-cycle) require a GUI
confirmation; every tool call is written to AppLogger. Existing Serial/
PDU MCP servers (Settings → AI MCP) are unaffected and keep serving
external AI CLIs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:10:02 +08:00
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
47 changed files with 4222 additions and 574 deletions
+57
View File
@@ -33,6 +33,7 @@ ETTerms 是一個給工程師 / 韌體 / 硬體驗證人員用的**單一視窗
| PDU 控制(選用) | **SnmpSharpNet** | 沿用 MyTeraTerm PDU 控制(`pductrl` / `pduconnect` | | PDU 控制(選用) | **SnmpSharpNet** | 沿用 MyTeraTerm PDU 控制(`pductrl` / `pduconnect` |
| 日誌 | 自製 **AppLogger**(從 MyTeraTerm 移植) | 檔案 + Debug 雙輸出 | | 日誌 | 自製 **AppLogger**(從 MyTeraTerm 移植) | 檔案 + Debug 雙輸出 |
| AI / MCP 整合(選用) | **stdio MCP server**(官方 C# SDK `ModelContextProtocol` | 兩個獨立 server`ETTerms.SerialMcp`(不自己開 port,經本機 named pipe 橋接 GUI 持有的 serial session)與 `ETTerms.PduMcp`(直接打 SNMP 控制 PDU 插座,不需 GUI);把收發 / 電源控制暴露成 AI 可呼叫工具(Kiro CLI / Claude CLI),見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp--pdu-mcp-server) | | AI / MCP 整合(選用) | **stdio MCP server**(官方 C# SDK `ModelContextProtocol` | 兩個獨立 server`ETTerms.SerialMcp`(不自己開 port,經本機 named pipe 橋接 GUI 持有的 serial session)與 `ETTerms.PduMcp`(直接打 SNMP 控制 PDU 插座,不需 GUI);把收發 / 電源控制暴露成 AI 可呼叫工具(Kiro CLI / Claude CLI),見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp--pdu-mcp-server) |
| 內建 AI AssistantPhase 10 規劃中) | **Microsoft.Extensions.AI**OpenAI 相容 client + function calling | GUI 內建 agent 聊天分頁,in-process 直呼 serial / PDU 工具(不經 MCP);**BYO endpoint**——Provider 預設空白,發佈版不含任何私人端點,API key 存 Credential Manager |
| 打包 | `dotnet publish` + (選用)Inno Setup / MSIX | 單機安裝,current-user | | 打包 | `dotnet publish` + (選用)Inno Setup / MSIX | 單機安裝,current-user |
> **與舊版 MyTeraTerm 的關鍵差異:** 舊版是把真正的 `ttermpro.exe`TeraTerm)嵌進 Panel,靠 **com0com 虛擬 COM 對**攔截 serial 來跑腳本。ETTerms 改走**全原生**SSH.NET 做 SSH、`System.IO.Ports` 做 serial、自繪 VT100 控制項做終端機畫面,**不再依賴外部 TeraTerm exe,也不再需要 com0com**。腳本引擎從「驅動 com0com bridge」改成「驅動原生 `ISessionChannel`」。 > **與舊版 MyTeraTerm 的關鍵差異:** 舊版是把真正的 `ttermpro.exe`TeraTerm)嵌進 Panel,靠 **com0com 虛擬 COM 對**攔截 serial 來跑腳本。ETTerms 改走**全原生**SSH.NET 做 SSH、`System.IO.Ports` 做 serial、自繪 VT100 控制項做終端機畫面,**不再依賴外部 TeraTerm exe,也不再需要 com0com**。腳本引擎從「驅動 com0com bridge」改成「驅動原生 `ISessionChannel`」。
@@ -161,6 +162,11 @@ ETTerms/
│ └── Pdu/ │ └── Pdu/
│ └── PduController.cs# SnmpSharpNet PDU 控制 (pductrl / pduconnect) │ └── PduController.cs# SnmpSharpNet PDU 控制 (pductrl / pduconnect)
├── Ai/ # ── 內建 AI AssistantPhase 10, v0.6.0)──
│ ├── OpenAiChatClient.cs # 極簡 OpenAI 相容 /chat/completionsHttpClient, 非串流;BYO endpoint
│ ├── AgentHost.cs # 手寫 agent looptool_calls → 執行 → 餵回 → 迴圈,上限 8 輪)
│ └── AiTools.cs # 工具集:serial(經 SerialBridge, [AI] echo+ PDUPduCore);破壞性動作經 ConfirmAsync 彈框
└── Infrastructure/ └── Infrastructure/
├── AppLogger.cs # 日誌 (port 自 MyTeraTerm) ├── AppLogger.cs # 日誌 (port 自 MyTeraTerm)
├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json) ├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json)
@@ -468,6 +474,41 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
> 所有工具回傳統一的 `{ "ok": bool, "result"/"error": ... }` JSON。SNMP community 目前沿用 GUI 版的 `"private"`。 > 所有工具回傳統一的 `{ "ok": bool, "result"/"error": ... }` JSON。SNMP community 目前沿用 GUI 版的 `"private"`。
### 內建 AI AssistantPhase 10 — ✅ 已完成 v0.6.0
> 讓 ETTerms **自己就是 agent host**——不經 Claude / Kiro,在 GUI 內建 AI 聊天**分頁**,用自然語言驅動 serial 與 PDU(「接上 COM3,送 help 看回應」「連上 PDU,把 outlet 3 重開」一句話完成)。**AI 分頁是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟),可用 Layout1×2 / 2×2…)與 serial 分頁**並排同時使用**——像 Claude Code / Kiro 那樣一邊聊、一邊看終端機。
**設計原則:BYO Endpoint(使用者自帶 LLM 端點)**
- Provider 設定(**Base URL / API Key / Model**)預設**全空白**;未設定時 AI 面板顯示「未設定 AI Provider」且功能完全停用——**發佈出去的 ETTerms 不內含任何端點,對一般使用者就是一個沒有 AI 的終端機**。
- API Key 只存 **Windows Credential Manager**`ETTerms/AiApiKey`,沿用 `CredentialVault`;Base URL / Model 存 settings.json(本機、非 repo)。
- 介面走 **OpenAI 相容 `/v1/chat/completions` + function calling**,任何供應商皆可接:本機 Ollama(`http://localhost:11434/v1`)、自架 LiteLLM gateway、公司內部 gateway、OpenAI 等。模型需支援 function calling。
- 🚫 **鐵則:任何私人端點 URL / API key 不得出現在程式碼、預設值、文件範例、repo、publish 產物。** 文件範例一律用 `localhost` 或占位符。
**架構(in-process function calling,不經 MCP**
```
AiChatViewWorkspace 的一種 pane,與 SessionPage 並排;工具列 ✨ AI Chat 開啟)
└─ AgentHostAi/AgentHost.cs):維護對話歷史 + 手寫 agent loop(最多 8 輪工具)
├─ OpenAiChatClientAi/OpenAiChatClient.cs):極簡 OpenAI 相容 /chat/completions(非串流)→ 使用者設定的 Base URL
└─ AiToolsAi/AiTools.cs):in-process 直呼,不經子行程 / pipe
├─ serial_list / attach / write / read → SerialBridge 同一套路徑([AI] 標色照舊;read 端自行累積 RX)
└─ pdu_connect / status / set_port / power_cycle → ETTerms.PduCore(本 session 內 IP→controller 登錄)
```
**為何內建 agent 不經 MCP** MCP 解決的是「跨行程 / 跨信任邊界」(Claude / Kiro 是別人的 process,所以需要 stdio JSON-RPC + named pipe 橋接);內建 agent 與工具在**同一個 process**,直呼即可,插一層「子行程 + 序列化 + pipe」繞一圈回自己記憶體裡的物件,只增加故障面。既有的 SerialMcp / PduMcp **不受影響**,繼續服務外部 AI CLISettings → AI MCP)。日後若要開放「使用者掛第三方 MCP server」(ETTerms 作為 **MCP host**,把 MCP client 列出的工具 schema concat 進 `AiTools.GetSchemas()` 的清單即可,agent loop 不需改動。
**安全設計:**
- 破壞性 PDU 動作(`pdu_set_port` off / `pdu_power_cycle`)一律 **C# 端彈確認框**`AiTools.ConfirmAsync` → GUI MessageBox,預設按鈕 No;不信 LLM 自律)。
- 所有 AI 工具呼叫寫 **AppLogger** 留跡(`[AI tool] <name> <args>`)。
- Serial TX 沿 Phase 9 慣例以 `[AI]` 標色 echo`SerialBridgeEndpoint.Write`),使用者全程看得到 AI 打了什麼。
- **工具呼叫上限可設定**`AppSettings.AiMaxToolRounds`,Settings → AI Assistant):單次訊息最多鏈幾輪工具的保險,**0 = 無上限**(給放著跑一天的自動化腳本;每輪都燒 token,執行中聊天視窗的 **Stop** 鈕可隨時中止,經 `CancellationToken`)。預設 30。
**典型應用(搭配自架 OpenAI 相容 gateway = 硬體工程助理):**
把 Provider 指向你自己的 LLM gateway(本機 Ollama / LiteLLM / 公司 gateway…),ETTerms 就變成一個能用自然語言操作實體硬體的助理:查/送 serial console 指令、依裝置回應判斷、控制 PDU power-cycle DUT、跑重複性測試序列(工具上限設 0 可長跑)。AI 的 serial TX 以 `[AI]` 顯示在終端機,與手動操作同一條 channel,所見即所得。⚠️ 端點由使用者自帶,發佈版不含任何端點(見下方 Security)。
**實作選型:** 手寫 `OpenAiChatClient`HttpClient + System.Text.Json,非串流)+ 手寫 agent loop,**不引入 `Microsoft.Extensions.AI`**——依賴最小、對任意 OpenAI 相容 gateway 相容性自己掌控、無額外 NuGet 演進風險。工具 schema 為手組 JSONOpenAI function-calling 格式)。
--- ---
## Key Constraints & Business Rules ## Key Constraints & Business Rules
@@ -482,6 +523,7 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
8. **GUI 先行:** Phase 1–2 必須先讓視窗外殼 + 分頁 + 假連線可見可操作,再接真實 channel。 8. **GUI 先行:** Phase 1–2 必須先讓視窗外殼 + 分頁 + 假連線可見可操作,再接真實 channel。
9. **不依賴外部 exe** 不嵌 TeraTerm、不需 com0com;全原生 .NET 元件。 9. **不依賴外部 exe** 不嵌 TeraTerm、不需 com0com;全原生 .NET 元件。
10. **UI 不可被 channel I/O 阻塞:** channel 讀寫在背景,UI 更新一律 `Invoke` 回 UI thread。 10. **UI 不可被 channel I/O 阻塞:** channel 讀寫在背景,UI 更新一律 `Invoke` 回 UI thread。
11. **AI Provider 預設空白(BYO endpoint):** 內建 AI(Phase 10)未設定端點時完全停用;**任何私人端點 / 金鑰不得進程式碼、預設值、文件範例、publish 產物**。API key 只存 Windows Credential Manager。
--- ---
@@ -490,6 +532,7 @@ Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
- **密碼儲存:** 一律使用 **Windows Credential Manager**(透過 `CredentialVault.cs`)。SQLite 內只存索引 `CredentialKey`,無明碼。SSH private key passphrase 同理。 - **密碼儲存:** 一律使用 **Windows Credential Manager**(透過 `CredentialVault.cs`)。SQLite 內只存索引 `CredentialKey`,無明碼。SSH private key passphrase 同理。
- **SSH host key 驗證:** 首次連線顯示 host key 指紋供使用者確認(trust-on-first-use),記錄已信任的指紋,之後比對;指紋不符要警告。 - **SSH host key 驗證:** 首次連線顯示 host key 指紋供使用者確認(trust-on-first-use),記錄已信任的指紋,之後比對;指紋不符要警告。
- **私鑰檔保護:** private key 路徑存設定,但不複製 key 內容進 repo / SQLite。 - **私鑰檔保護:** private key 路徑存設定,但不複製 key 內容進 repo / SQLite。
- **AI ProviderPhase 10):** Base URL / Model 存本機 settings.json、API key 只存 Credential Manager`ETTerms/AiApiKey`);**無任何預設端點**——發佈產物內不含開發者私人伺服器資訊,文件範例一律 `localhost` / 占位符。AI 工具呼叫全程 AppLogger 留跡,破壞性 PDU 動作需 GUI 確認。
- **輸入處理:** 終端機輸入直接透傳給遠端,不做 shell 注入解讀(本來就是終端機);但 UI 載入腳本檔時要防路徑穿越 / 過大檔。 - **輸入處理:** 終端機輸入直接透傳給遠端,不做 shell 注入解讀(本來就是終端機);但 UI 載入腳本檔時要防路徑穿越 / 過大檔。
- **日誌不含密碼:** `AppLogger``logopen` 輸出不可寫入密碼 / passphrase;連線資訊只記主機 / port,不記 credential。 - **日誌不含密碼:** `AppLogger``logopen` 輸出不可寫入密碼 / passphrase;連線資訊只記主機 / port,不記 credential。
- **無 `secret/` 資料夾:** ETTerms 無伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager,因此不設 `secret/` 集中目錄,也不需要 publish 類腳本。若日後做 Release 程式碼簽章,簽章 `.pfx` 請放在 repo 外並以環境變數 / CI secret 傳入。 - **無 `secret/` 資料夾:** ETTerms 無伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager,因此不設 `secret/` 集中目錄,也不需要 publish 類腳本。若日後做 Release 程式碼簽章,簽章 `.pfx` 請放在 repo 外並以環境變數 / CI secret 傳入。
@@ -704,6 +747,19 @@ Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
- [x] 註冊說明(`kiro-cli mcp add` / agent.json `mcpServers`)寫入 [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md),含「需先在 GUI 開好 port」前提 - [x] 註冊說明(`kiro-cli mcp add` / agent.json `mcpServers`)寫入 [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md),含「需先在 GUI 開好 port」前提
**驗收條件:** ✅ GUI 開一條 SerialCOM3)→ 另一分頁 PowerShell 跑 kiro → AI 經 MCP `serial_attach` COM3 → `serial_write` 送指令、`serial_read` 讀回應,**整個過程在 GUI Tab1 即時可見(AI 的 TX 有 `[AI]` 標色)**;全程只有 GUI 開該 port。 **驗收條件:** ✅ GUI 開一條 SerialCOM3)→ 另一分頁 PowerShell 跑 kiro → AI 經 MCP `serial_attach` COM3 → `serial_write` 送指令、`serial_read` 讀回應,**整個過程在 GUI Tab1 即時可見(AI 的 TX 有 `[AI]` 標色)**;全程只有 GUI 開該 port。
### Phase 10 — 內建 AI AssistantBYO endpoint agent)(工作量:M)✅ 已完成(v0.6.0)
**目標:** 不依賴 Claude / KiroGUI 內建 AI 聊天檢視,自然語言驅動 serial + PDU;**發佈版不含任何私人端點**(設計見 [內建 AI Assistant](#內建-ai-assistantphase-10--✅-已完成-v060))。
**包含:**
- [x] `AppSettings``AiBaseUrl` / `AiModel` / `AiSystemPrompt`(預設空白);API key 存 `CredentialVault``ETTerms/AiApiKey`
- [x] `App/SettingsView.cs`**AI Assistant** 分頁:Base URL / Model / API Key(密碼框)/ 系統提示詞,Save 寫 settings + Credential Manager;空白=停用
- [x] `App/AiChatView.cs`:聊天 UI(乾淨逐字稿:user 靠右 accent、AI 靠左、工具灰字、thinking 收進 Send 按鈕)+ 底部控制列(輸入框 Fill + 右下角模型下拉 / Send);`RefreshProvider()` 開分頁時依設定重建 client。**作為 Workspace pane**`WorkspaceView` 工具列 `✨ AI Chat``OpenAiPane()``Session` 抽象化容納 `SessionPage``AiChatView``Content` 屬性),可與 serial 用 Layout 並排;AI 分頁無 Group / Log / Script(自動 skip)。**模型下拉**打端點 `/v1/models` 列出可選模型、即時切換並記住(Settings 不再設 model,只留 Base URL / Key / 系統提示詞)
- [x] `Ai/OpenAiChatClient.cs`:極簡 OpenAI 相容 chat-completionsHttpClient,非串流)
- [x] `Ai/AgentHost.cs`:手寫 agent looptool_calls → 執行 → role=tool 餵回 → 迴圈,上限 8 輪)
- [x] `Ai/AiTools.cs`seriallist/attach/write/read,經 `SerialBridge``[AI]` echo 沿用)+ PDUconnect/status/set_port/power_cycle`ETTerms.PduCore`);破壞性動作經 `ConfirmAsync` 彈框;每筆呼叫寫 AppLogger
- [x] AI 入口在 `WorkspaceView` 工具列(`✨ AI Chat` 按鈕),開成可並排的 pane(非獨立 rail view)——這樣才能與 serial 分頁同時使用
- [ ] (延伸,未做)MCP host:讓使用者掛自己的第三方 MCP serverschema concat 進 `AiTools.GetSchemas()`
**驗收條件:** ✅ 建置 0 錯誤;未設定 Provider 時 AI 面板停用並提示;`src` grep 無任何私人端點 / key(範例一律 localhost / 假 IP)。實機端到端(接 OpenAI 相容端點跑 serial/PDU 一句話流程、PDU 確認框、`[AI]` 標色、AppLogger 紀錄)待使用者驗收。
--- ---
## Future Extensions ## Future Extensions
@@ -712,6 +768,7 @@ Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
- ~~**AI / MCP 整合**~~(✅ 已於 [Phase 9](#development-phases) 實作:Serial MCP Server,讓 AI agent 直接操作 serial;未來可再擴充 SSH / Shell MCP 工具) - ~~**AI / MCP 整合**~~(✅ 已於 [Phase 9](#development-phases) 實作:Serial MCP Server,讓 AI agent 直接操作 serial;未來可再擴充 SSH / Shell MCP 工具)
- ~~**SFTP 檔案瀏覽**~~(✅ 已於 Phase 8 實作:sidebar SFTP 分頁) - ~~**SFTP 檔案瀏覽**~~(✅ 已於 Phase 8 實作:sidebar SFTP 分頁)
- ~~**AI Chat 氣泡版(WebView2**~~(✅ v0.7.0 已實作):AI 分頁訊息區改用 WebView2 渲染真氣泡(user 右 / AI 左)+ MarkdownMarkdig 轉 HTML:程式碼區塊、表格、清單)+ 送出後 thinking 動畫泡。HTML 模板 `Ai/ChatHtml.cs`(全內嵌 CSS/JS,`NavigateToString`,C# 經 `ExecuteScriptAsync` 呼叫 JS`addUser`/`addAI`/`addTool`/`showThinking`/`hideThinking`…;WebView2 未就緒前的呼叫先入佇列,`NavigationCompleted` 後 flush)。WebView2 使用者資料夾 `%LocalAppData%\ETTerms\WebView2`。底部控制列(輸入框 / 模型下拉 / Send)仍 WinForms。依賴 WebView2 RuntimeWin11 內建)。
- **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel` - **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel`
- **RDP / VNC** 分頁(KKTerm 用 mstscax.dllETTerms 可後期評估) - **RDP / VNC** 分頁(KKTerm 用 mstscax.dllETTerms 可後期評估)
- **tmux 自動 attach**SSH 斷線後自動回貼,仿 KKTerm) - **tmux 自動 attach**SSH 斷線後自動回貼,仿 KKTerm)
+18 -3
View File
@@ -8,7 +8,17 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
**開發策略:GUI 先行** — 先把視窗外殼 + 分頁 + 連線清單做出來,再逐步補 Serial → SSH → VT100 → 腳本引擎 → Settings/About → PDU/Shell/SFTP。 **開發策略:GUI 先行** — 先把視窗外殼 + 分頁 + 連線清單做出來,再逐步補 Serial → SSH → VT100 → 腳本引擎 → Settings/About → PDU/Shell/SFTP。
**進度:** 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、**Phase 10 ✅(v0.6.0)內建 AI Assistant**(見下)。打包待指示。
**v0.7.1** 新增 TTL 指令 **`sendlnretry '文字' '確認關鍵字' [最多送出次數]`**`TTLInterpreter.SendLnRetry` + `WaitConfirm`)——送出後等確認關鍵字,沒等到就**重送**;次數省略 = 無限重送,命中 `result=1`、用完次數 `result=0` 且**繼續執行**(不中止腳本)。**動機**:裝置開機、console 剛被 shell 接手的瞬間會丟棄輸入(tty 重開 / termios flush),`sendln` 送出的整行無聲消失(裝置不回顯、不執行),後面的 `wait` 就永遠卡住;這是機率性的,`pause` 只能調機率、無法根治(實測 45 輪 power-cycle 中 3 輪中招,且**中招時間點與成功時完全重疊**)。**為何需要新指令而非用 `wait` 重試**:單字串 `wait` 逾時是 throw 中止腳本(刻意設計,見下方「注意事項」),TeraTerm 的 `wait``if result = 0 then goto retry` 重送寫法在 ETTerms 寫不出來。**語意**:每次送出前清空 `_recv`(確保比對到的是這次送出的回應);命中只消費到**第一次**出現處,後續輸出留給接下來的 `wait`;刻意**不套 `SettleAndConsume` 的 settle**(關鍵字出現本身就證明裝置收到了);每次等待逾時 `timeout`/`mtimeout` 有設就用設定值、**沒設預設 3 秒**(此處 0=無限等於永不重送,故不沿用 `wait` 的 0=無限)。⚠️ 確認關鍵字要挑「命令真的有跑」的輸出,**不要挑指令回顯**——tty echo 由核心產生,不保證命令被 shell 讀走。文件:[docs/ttl-script-reference.md](docs/ttl-script-reference.md#送出並確認sendlnretry)。
**v0.7.0** **AI Chat 改用 WebView2 渲染**(真氣泡 + Markdown + thinking 動畫泡)。訊息區從 RichTextBox 換成 `WebView2`user 右泡 / AI 左泡、AI 回覆走 **Markdig** markdown→HTML(程式碼區塊/表格/清單)、送出後顯示會動的 thinking「…」泡(`AgentHost.Status "thinking"``showThinking()`,收到 `AssistantText``hideThinking()` 再加 AI 泡)。HTML 模板全內嵌於 `Ai/ChatHtml.cs``NavigateToString`,無外部依賴),C# 經 `ExecuteScriptAsync` 呼叫 JS 函式;WebView2 async 初始化,就緒前的呼叫先入 `_pending` 佇列、`NavigationCompleted` 後 flush。使用者資料夾 `%LocalAppData%\ETTerms\WebView2`(避開 Program Files 唯讀)。底部控制列(輸入框/模型下拉/Send)仍 WinForms。新增 NuGet `Microsoft.Web.WebView2` + `Markdig`;依賴 WebView2 RuntimeWin11 內建,缺時 hint 顯示錯誤)。⚠️ **publish 要確認 WebView2 native`runtimes/win-x64/native/WebView2Loader.dll`)有進產物**。**工具呼叫上限可設定**`AppSettings.AiMaxToolRounds`,Settings → AI Assistant,`AgentHost` 建構子傳入):**0 = 無上限**(自動化長跑;每輪燒 token,聊天視窗 Send 鈕在執行中變 **Stop**,經 `CancellationToken` 中止),預設 30。
**v0.6.0** **內建 AI AssistantPhase 10** — 不經 Claude / KiroGUI 內建 ✨ AI 聊天**分頁**,自然語言驅動 serial + PDU。**AI 是 Workspace 的一種 pane**(工具列 `✨ AI Chat` 開啟,`WorkspaceView.Session` 抽象化為可容納 `SessionPage``AiChatView``Content`),可用 Layout 與 serial 分頁**並排同時用**(像 Claude/Kiro);AI 分頁無 Group/Log/Script。**UI 走乾淨逐字稿風**user 靠右 accent、AI 靠左、工具灰字、thinking 收進 Send 按鈕不洗版;與終端機美學一致,非氣泡——真氣泡+Markdown 的 WebView2 版列 v0.7.0)。**模型下拉**(右下角)打端點 `/v1/models` 列可選模型、即時切換並記住;**Settings 只留 Base URL / API Key / 系統提示詞,不再設 model**(`configured` 只看 Base URL)。**BYO endpoint**`Settings → AI Assistant` 填 Base URL / Model / API Key(任意 OpenAI 相容 gateway:本機 Ollama、LiteLLM、公司 gateway、OpenAI…),**預設全空白=功能停用**;🚫 **發佈版不含任何私人端點**API key 存 Windows Credential Manager`ETTerms/AiApiKey`)、Base URL/Model 存 settings.json,程式碼範例一律 `localhost`/假 IP。**架構(in-process,不經 MCP**`Ai/OpenAiChatClient`(極簡 OpenAI 相容 `/chat/completions`HttpClient 非串流)+ `Ai/AgentHost`(手寫 agent looptool_calls→執行→role=tool 餵回→迴圈上限 8 輪)+ `Ai/AiTools`serial list/attach/write/read 經 `SerialBridge`——與 MCP 同一路徑、AI 的 TX 照樣 `[AI]` 標色顯示在終端機;PDU connect/status/set_port/power_cycle 經 `ETTerms.PduCore`)。**安全**:破壞性 PDU 動作(關插座 / power-cycle)一律 `AiTools.ConfirmAsync` → GUI MessageBox(預設 No)確認,AI 不能自己斷電;每筆工具呼叫寫 AppLogger(`[AI tool] …`)。**刻意不引入 `Microsoft.Extensions.AI`**(手寫 client+loop,依賴最小、對任意 gateway 相容性自己掌控)。既有 SerialMcp/PduMcpSettings → AI MCP**不受影響**,繼續服務外部 AI CLI;兩者是「內建 agentin-processvs 外部 AIMCP 跨行程)」的分工。新增 `src/ETTerms/Ai/`3 檔)+ `App/AiChatView.cs``ActivityRail``Ai` view`SettingsView` 加 AI Assistant 分頁,`AppSettings``AiBaseUrl/AiModel/AiSystemPrompt`
**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 行為一致。
@@ -33,6 +43,7 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
- **連線儲存:** SQLite`Microsoft.Data.Sqlite` - **連線儲存:** SQLite`Microsoft.Data.Sqlite`
- **密碼儲存:** Windows Credential Manager(不落地明碼) - **密碼儲存:** Windows Credential Manager(不落地明碼)
- **PDU** SnmpSharpNetiPoMan II/III via SNMP - **PDU** SnmpSharpNetiPoMan II/III via SNMP
- **內建 AI Assistantv0.6.0):** 手寫 OpenAI 相容 clientHttpClient+ agent loopin-process 直呼 serial/PDU 工具;BYO endpointProvider 預設空白,發佈版不含端點)。`src/ETTerms/Ai/`
- **AI / MCP(選用):** 兩個 stdio MCP server(官方 C# SDK `ModelContextProtocol`)。`ETTerms.SerialMcp`**不自己開 COM port**,經本機 named pipe 接上 GUI 持有的 serial sessionAI 的 TX/RX 同步顯示在 GUI。`ETTerms.PduMcp`v0.3.0):**直接打 SNMP** 控制 PDU 插座,非獨佔故不需 GUI 在跑。皆暴露給 Kiro CLI / Claude CLI - **AI / MCP(選用):** 兩個 stdio MCP server(官方 C# SDK `ModelContextProtocol`)。`ETTerms.SerialMcp`**不自己開 COM port**,經本機 named pipe 接上 GUI 持有的 serial sessionAI 的 TX/RX 同步顯示在 GUI。`ETTerms.PduMcp`v0.3.0):**直接打 SNMP** 控制 PDU 插座,非獨佔故不需 GUI 在跑。皆暴露給 Kiro CLI / Claude CLI
- **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json` - **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json`
@@ -79,6 +90,7 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm
## 注意事項 / 禁止事項 ## 注意事項 / 禁止事項
- 🚫 **密碼絕不寫進 SQLite / 程式碼 / log**,一律走 Windows Credential Manager。 - 🚫 **密碼絕不寫進 SQLite / 程式碼 / log**,一律走 Windows Credential Manager。
- 🚫 **內建 AIPhase 10)採 BYO endpoint**ProviderBase URL / API Key / Model**預設空白**=功能停用;**開發者私人的 LLM 端點 / API key 絕不寫進程式碼、預設值、文件範例、publish 產物**——只存在開發者本機的 settings.json / Credential Manager。文件與 UI 範例一律用 `http://localhost:11434/v1` 或占位符。發佈前驗收:publish 資料夾 grep 不到任何私人 IP / 網域 / key。
- 🚫 **不嵌 TeraTerm、不依賴 com0com** —— ETTerms 走全原生(這是與舊版 MyTeraTerm 的關鍵差異)。 - 🚫 **不嵌 TeraTerm、不依賴 com0com** —— ETTerms 走全原生(這是與舊版 MyTeraTerm 的關鍵差異)。
- 🚫 不要把 `For_AI/` 內容 commit 進 git。 - 🚫 不要把 `For_AI/` 內容 commit 進 git。
- ⚠️ Serial COM port 同時只能被一個 session 開啟,開啟前檢查可用性。 - ⚠️ Serial COM port 同時只能被一個 session 開啟,開啟前檢查可用性。
@@ -86,11 +98,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 視窗外殼 + 連線 / 終端機 / 腳本引擎)。內含 **`Ai/`**v0.6.0 內建 AI Assistant`OpenAiChatClient` + `AgentHost` + `AiTools`)與 `App/AiChatView.cs`
- **`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)
+53
View File
@@ -0,0 +1,53 @@
# v0.5.0 — Terminal search, keyword alerts & a major TTL scripting upgrade
## ✨ New features
**Find text in your scroll-back (Ctrl+F)**
* Press **Ctrl+F** to open a search bar right inside the terminal and find any text in the
scroll-back history — **Enter** jumps to the previous match, **Shift+Enter** to the next,
**F3** repeats, **Esc** closes.
* Every match is highlighted in **yellow**, the current one in **orange**, and matches stay
anchored to the right line even as old output scrolls off the top of the buffer.
**Keyword highlighting + per-tab alerts**
* Define your own keyword list under **Settings → Highlight** (add, remove, and enable/disable
each one). Matching text is highlighted on screen wherever it appears, case-insensitive.
* When a keyword shows up on a tab you're **not** currently looking at, that tab's dot turns
**red** so you notice it — switching to the tab clears the alert. Great for watching several
sessions at once for an error string.
**A much bigger, TeraTerm-compatible TTL scripting language**
* The TTL engine now understands a large set of **TeraTerm-compatible commands**, so many
existing TeraTerm macros run with little or no change.
* New control flow — `for`/`next`, `do`/`loop`, `while`/`until`, `call`, `goto`, `break`,
`continue`, `include` — plus a real expression parser (parentheses, logical & comparison
operators, hex `0x` / `$`), and dozens of new string, file/folder and utility commands
(`sprintf`, `strsplit`, `filereadln`, `getdate`, `random`, `crc32`, `inputbox`, `yesnobox`, …).
* New **serial-specific** script commands: `sendbreak`, `setbaud`, `setdtr`, `setrts`, `sendfile`.
* Note: a single-string `wait` keeps ETTerms' existing settle-and-timeout behavior; multi-string
`wait` follows TeraTerm semantics (times out and continues with `result=0`).
**See — or hide — what your scripts are doing**
* Scripts now echo their progress (waits, sends, errors) in **grey** directly in the terminal,
and you can switch this off under **Settings → Terminal** (**Show script trace**).
* The trace is display-only: it never reaches your session log, the AI bridge, or the device,
so your captured machine logs stay clean.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`ETTerms_v0.5.0`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`ETTerms_v0.5.0_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `ETTerms v0.5.0.exe`.
## 🔗 Links
* TTL reference:
[docs/ttl-script-reference.md](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
* 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.4.0...v0.5.0](https://github.com/ETWen/ETTerms/compare/v0.4.0...v0.5.0)
+37
View File
@@ -0,0 +1,37 @@
# v0.6.0 — Talk to your serial port & PDU: a built-in AI Assistant
## ✨ New features
**Built-in AI Assistant — drive serial & PDU in plain language**
* New **✨ AI Chat** button in the workspace toolbar opens an AI pane. Ask things like *"list the open serial sessions"*, *"attach to COM3, send `help` and show me the reply"*, or *"connect to the PDU and power-cycle outlet 3"* — the assistant calls the right tools for you.
* Whatever the AI sends to a serial port shows up in that terminal tagged `[AI]`, so you always see exactly what it did — no hidden actions.
* Runs entirely against your own machines: it talks to the serial session ETTerms already has open, and to the PDU over SNMP.
**Sits right next to your terminal — like Claude Code / Kiro**
* The AI Chat is a normal workspace pane, so you can use Layout (1×2, 2×2…) to put it side-by-side with a live Serial session — chat on one side, watch the device output on the other.
* Clean transcript layout that matches the terminal: your messages align right, the AI's replies read left, and tool activity stays as quiet gray notes. "Thinking" is folded into the Send button instead of spamming the log.
**Bring your own AI endpoint (nothing baked in)**
* Point it at any OpenAI-compatible endpoint — a local Ollama, a LiteLLM gateway, your company's gateway, or OpenAI. Set the Base URL and API key once in **Settings → AI Assistant**; leave it blank and the assistant simply stays off.
* Pick the model from a dropdown in the bottom-right of the chat pane — it lists whatever your endpoint offers and switches on the fly, no trip back to Settings. Your choice is remembered.
* Your API key is stored in Windows Credential Manager, never in a settings file — and **no endpoint ships inside the app**, so a copy you hand to a colleague has the assistant disabled by default.
**Safe by design**
* Turning a PDU outlet off or power-cycling always asks you to confirm first — the AI can't cut power on its own. Every tool call is written to the app log.
* This is separate from the existing Serial/PDU MCP servers (Settings → AI MCP), which keep working for external AI CLIs like Claude Code / Kiro.
## 📦 Downloads
| Build | Needs .NET Runtime? | Notes |
|---|---|---|
| **ETTerms_v0.6.0** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
| **ETTerms_v0.6.0_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
Run `ETTerms v0.6.0.exe`.
## 🔗 Links
* [Architecture](https://github.com/ETWen/ETTerms/blob/main/ARCHITECTURE.md) — see "Built-in AI Assistant (Phase 10)"
* [Serial MCP guide](https://github.com/ETWen/ETTerms/blob/main/docs/serial-mcp-guide.md)
**Full changelog:** [v0.5.0...v0.6.0](https://github.com/ETWen/ETTerms/compare/v0.5.0...v0.6.0)
+29
View File
@@ -0,0 +1,29 @@
# v0.7.0 — The AI Assistant grows up: real chat bubbles, Markdown & a thinking indicator
## ✨ New features
**A proper chat UI for the AI Assistant**
* The AI Chat pane now renders real chat bubbles — your messages on the right, the AI's on the left — with full **Markdown**: code blocks, tables, lists and inline `code` all display cleanly.
* When you send a prompt, an animated **"…" thinking bubble** appears while the AI works and disappears the moment the reply lands, so you always know it's running.
* Built on WebView2 (part of Windows 11). The model dropdown, `[AI]` serial tagging, and PDU confirmations all work exactly as before.
**Control how far the AI runs**
* New setting **Max tool calls / message** (Settings → AI Assistant): how many tool calls the assistant may chain before it stops — a runaway-loop guard. Default is 30.
* Set it to **0 for unlimited**, handy for long automation runs you leave going for hours.
* While the AI is working, the **Send button turns into Stop** so you can abort any run — bounded or unlimited — at any time.
## 📦 Downloads
| Build | Needs .NET Runtime? | Notes |
|---|---|---|
| **ETTerms_v0.7.0** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
| **ETTerms_v0.7.0_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
Run `ETTerms v0.7.0.exe`. (The WebView2 Runtime ships with Windows 11; both builds include the native loader.)
## 🔗 Links
* [Architecture](https://github.com/ETWen/ETTerms/blob/main/ARCHITECTURE.md) — see "Built-in AI Assistant (Phase 10)"
* [Serial MCP guide](https://github.com/ETWen/ETTerms/blob/main/docs/serial-mcp-guide.md)
**Full changelog:** [v0.6.0...v0.7.0](https://github.com/ETWen/ETTerms/compare/v0.6.0...v0.7.0)
+49
View File
@@ -0,0 +1,49 @@
# v0.7.1 — `sendlnretry`: stop losing commands to a booting device
## 🐛 Bug fixes
**Scripts no longer hang forever when a device swallows a command**
* A device that is still booting can silently **discard console input** the moment its shell takes over the tty. The line you sent is gone — the device never echoes it and never runs it — so the `wait` that follows blocks forever and your test rig sits dead until someone notices.
* This is a race, not a delay: adding `pause` before `sendln` only lowers the odds of hitting the window, it can never close it. In a 45-cycle overnight power-cycle run, 3 cycles hung this way — and the sends that got swallowed landed at *exactly* the same moment as the ones that worked.
## ✨ New features
**New TTL command: `sendlnretry`**
```
sendlnretry '<text>' '<confirm keyword>' [max attempts]
```
* Sends the text, then **waits for proof the device actually ran it** — and **resends** if that proof doesn't arrive. Omit the attempt count and it retries until it gets through.
* On success `result = 1`. If it runs out of attempts, `result = 0` and **the script keeps going**, so you can handle it yourself:
```
wait 'root@(none):/#'
sendlnretry 'tpm2' 'TPM 2p0' ; retry until the DUT really runs it
wait 'PASS'
sendlnretry 'tpm2' 'TPM 2p0' 3 ; or cap it and handle failure
if result = 0 then
dispstr 'tpm2 got no response after 3 tries'
endif
```
* Pick a confirm keyword from the command's **output** (`TPM 2p0`), not its echo (`tpm2`) — a tty echo comes from the kernel and doesn't prove the shell ever read the line.
* Each send clears the receive buffer first, so a match can only come from *this* send; on a hit it consumes only up to the **first** occurrence, leaving the rest for your next `wait`.
* Per-attempt timeout follows `timeout` / `mtimeout` if you set them, otherwise **3 seconds**. (Unlike `wait`, `0` doesn't mean "wait forever" here — that would mean never retrying.)
Full command reference: [TTL Script Reference → 送出並確認](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md#送出並確認sendlnretry)
## 📦 Downloads
| Build | Needs .NET Runtime? | Notes |
|---|---|---|
| **ETTerms_v0.7.1** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
| **ETTerms_v0.7.1_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
Run `ETTerms v0.7.1.exe`.
## 🔗 Links
* [TTL script reference](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
* [Architecture](https://github.com/ETWen/ETTerms/blob/main/ARCHITECTURE.md)
**Full changelog:** [v0.7.0...v0.7.1](https://github.com/ETWen/ETTerms/compare/v0.7.0...v0.7.1)
+272 -108
View File
@@ -1,178 +1,342 @@
# 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 獨有指令
## 送出並確認(sendlnretry
| 指令 | 語法 | 說明 | | 指令 | 語法 | 說明 |
|------|------|------| |------|------|------|
| `send` | `send '文字'` | 送出文字(不加換行)到 active session。 | | `sendlnretry` | `sendlnretry '文字' '確認關鍵字' [最多送出次數]` | 送出 `文字` + `\r\n`,然後等 `確認關鍵字`;沒等到就**重送**。次數省略 = 一直重送到收到為止。命中 `result=1`;用完次數 `result=0` 且**繼續執行**(不中止腳本)。 |
| `sendln` | `sendln '文字'` | 送出文字並附加 `\r\n`。 |
| `wait` | `wait '字串'` | **一直等到**接收緩衝出現指定字串才往下;預設無限等待(可按 Stop 取消)。命中後會再確認裝置「安靜」約 300ms(無新資料)才接受,以排除夾在輸出中途的回顯(如 `SVOS> help`),並消費到該字串**最後一次**出現(含)之前的內容,`result=1`。 |
| `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,本階段尚未提供。 **為什麼需要它**:裝置在開機、console 剛被 shell 接手的瞬間,可能把收到的輸入直接丟掉
tty 重開 / termios flush)。此時 `sendln` 送出的整行會**無聲無息地消失**——裝置不會回顯、
也不會執行,後面的 `wait` 就永遠等不到,腳本整個卡死。這是**機率性**的:
`sendln` 前面加 `pause` 只是降低撞上那個窗口的機率,**不可能根治**。
`sendlnretry` 的作法是送完就確認對方真的有反應,沒反應就再送一次。
--- ```
; 開機後下 tpm2,沒跑到就自動重送(無限重送)
wait 'root@(none):/#'
sendlnretry 'tpm2' 'TPM 2p0'
wait 'PASS'
## Group 同步指令 ; 最多送 3 次,還是不行就自己處置
sendlnretry 'tpm2' 'TPM 2p0' 3
if result = 0 then
dispstr 'tpm2 送了 3 次都沒反應'
endif
```
以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1` / `▶ Group2` / `▶ Group3`)。 使用要點:
若在 `▶ Script`(分頁個別執行)或 `▶ Run All` 使用,會跳 Warning 並拒絕執行。
Group 內的成員依加入順序編為 **A, B, C, D...**,顯示在 cell footer(如 `[Group1-A]` - **確認關鍵字要挑「命令真的有跑」才會出現的輸出**(例:`TPM 2p0`
**不要挑指令回顯**(例:`tpm2`)——tty 的 echo 由核心產生,不保證命令有被 shell 讀走。
- **每次送出前會清空接收緩衝**,確保比對到的是「這次送出」的回應而不是殘留輸出。
命中後只消費到**第一次**出現處,後面的輸出留在緩衝裡給接下來的 `wait`
(所以上例的 `wait 'PASS'` 照常會等到)。
- 每次送出後等確認的逾時:`timeout`/`mtimeout` 有設就用設定值,**沒設預設 3 秒**
(注意這與 `wait` 不同——`wait` 的 0 是無限等,但無限等在這裡等於永不重送)。
- **指令最好是可重複執行的**:若逾時設得太短、而裝置其實只是回應慢,會造成同一個指令送兩次。
## Group 同步(多分頁協同)
以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1-3`)。
`▶ Script``▶ Run All` 會拒絕含這些指令的腳本。
Group 內的成員依序編為 **A, B, C...**(顯示在 cell footer,如 `[Group1-A]`)。
> ⚠ 刻意設計:group 同步時**一個成員停止(Stop / 逾時 / 錯誤),其他成員會停在
> barrier 等**——整組一起停下來,避免半組繼續跑造成狀態不一致。
| 指令 | 語法 | 說明 | | 指令 | 語法 | 說明 |
|------|------|------| |------|------|------|
| `waitall` | `waitall '字串'` | 各成員各自 `wait`指定字串出現,再等其他成員也完成,全員到齊才繼續下一步。 | | `waitall` | `waitall '字串'` | 各成員各自 `wait` 到字串出現,再等其他成員到齊才繼續。 |
| `sendlnall` | `sendlnall '文字'` | 等所有成員到達此行後,每人各自對自己的 channel `sendln` 同一段文字。 | | `sendlnall` | `sendlnall '文字'` | 等所有成員到達此行後,每人各自 `sendln` 同一段文字。 |
| `sendlngroup` | `sendlngroup A '文字'` | 只有指定 memberA/B/C...`sendln`,其他成員跳過。用於 Group 內各設備需送不同指令的情境。 | | `sendlngroup` | `sendlngroup A '文字'` | 只有指定 memberA/B/C...`sendln`,其他成員跳過。 |
### 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` 繼續),因此 TeraTerm 常見的 `wait``if result = 0 then goto retry` 重送寫法在單字串 `wait` 上做不到;要「送出後確認、沒回應就重送」請用 [`sendlnretry`](#送出並確認sendlnretry)。 |
| `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 });
} }
+94
View File
@@ -0,0 +1,94 @@
using System.Text.Json.Nodes;
using ETTerms.Infrastructure;
namespace ETTerms.Ai;
/// <summary>
/// 內建 AI Assistant 的 agent 迴圈:維護對話歷史,呼叫 <see cref="OpenAiChatClient"/>,
/// 模型回 tool_calls 時執行 <see cref="AiTools"/> 再把結果餵回,直到模型給出最終文字回應。
///
/// UI 事件(Status / AssistantText / ToolActivity)皆在背景緒觸發,訂閱者需自行 Invoke 回 UI thread。
/// </summary>
public sealed class AgentHost
{
private readonly OpenAiChatClient _client;
private readonly AiTools _tools;
private readonly JsonArray _messages = new();
// 單次 SendAsync 的工具呼叫輪數上限(防失控迴圈的保險)。由 Settings 設定,
// 0 = 無上限(自動化長跑用;執行中可按 Stop 中止,取消透過 CancellationToken)。
private readonly int _maxRounds;
public event Action<string>? AssistantText; // 最終文字回應
public event Action<string>? ToolActivity; // 「呼叫 serial_write …」之類過程
public event Action<string>? Status; // thinking / done
private static string DefaultSystemPrompt =>
"你是 ETTerms 內建的硬體工程助理。可透過工具收發序列埠(serial)與控制 PDU 電源插座。" +
"回答用繁體中文、簡潔。動手操作前先說明你要做什麼。" +
"破壞性動作(關插座 / power-cycle)會由使用者在 GUI 確認,你只需正常呼叫工具。" +
"serial 操作前必須先 serial_attach 到 GUI 已開啟的 session。";
public AgentHost(OpenAiChatClient client, AiTools tools, string? systemPrompt, int maxRounds)
{
_client = client;
_tools = tools;
_maxRounds = maxRounds;
_messages.Add(new JsonObject
{
["role"] = "system",
["content"] = string.IsNullOrWhiteSpace(systemPrompt) ? DefaultSystemPrompt : systemPrompt
});
}
/// <summary>送出一句使用者訊息,跑完 agent 迴圈(含工具呼叫)。</summary>
public async Task SendAsync(string userText, CancellationToken ct)
{
_messages.Add(new JsonObject { ["role"] = "user", ["content"] = userText });
var tools = _tools.GetSchemas();
// _maxRounds <= 0 → 無上限(自動化長跑;靠 Stop / CancellationToken 中止)
for (int round = 0; _maxRounds <= 0 || round < _maxRounds; round++)
{
ct.ThrowIfCancellationRequested();
Status?.Invoke("thinking");
var msg = await _client.CompleteAsync(_messages, tools, ct);
_messages.Add((JsonObject)msg.DeepClone());
var toolCalls = msg["tool_calls"]?.AsArray();
if (toolCalls == null || toolCalls.Count == 0)
{
var content = msg["content"]?.GetValue<string>() ?? "";
AssistantText?.Invoke(content);
Status?.Invoke("done");
return;
}
// 執行每個 tool call,把結果以 role=tool 加回歷史
foreach (var tcNode in toolCalls)
{
var tc = tcNode!.AsObject();
string id = tc["id"]?.GetValue<string>() ?? "";
var fn = tc["function"]?.AsObject();
string fname = fn?["name"]?.GetValue<string>() ?? "";
string argStr = fn?["arguments"]?.GetValue<string>() ?? "{}";
JsonObject args;
try { args = JsonNode.Parse(string.IsNullOrWhiteSpace(argStr) ? "{}" : argStr)!.AsObject(); }
catch { args = new JsonObject(); }
ToolActivity?.Invoke($"{fname}({argStr})");
string result = await _tools.InvokeAsync(fname, args, ct);
_messages.Add(new JsonObject
{
["role"] = "tool",
["tool_call_id"] = id,
["content"] = result
});
}
}
AssistantText?.Invoke($"(已達工具呼叫上限 {_maxRounds} 次,停止。可到 Settings → AI Assistant 調高或設 0 = 無上限,或分步再試。)");
Status?.Invoke("done");
}
}
+229
View File
@@ -0,0 +1,229 @@
using System.Text;
using System.Text.Json.Nodes;
using ETTerms.Infrastructure;
using ETTerms.PduCore;
using ETTerms.Sessions;
namespace ETTerms.Ai;
/// <summary>
/// 內建 AI Assistant 的工具集:serial 收發(重用 GUI 持有的 <see cref="SerialBridge"/> session
/// AI 的 TX 照樣以 [AI] 標色顯示在終端機)+ PDU 電源控制(<see cref="PduController"/>)。
///
/// 全部 in-process 直呼——不經 MCP 子行程 / named pipe(那是給外部 AI CLI 用的)。
/// 破壞性 PDU 動作(關插座 / power-cycle)一律經 <see cref="ConfirmAsync"/> 由 GUI 彈確認框;
/// 每筆工具呼叫寫 AppLogger 留跡。
/// </summary>
public sealed class AiTools : IDisposable
{
/// <summary>破壞性動作確認:回 true 才執行。由 UI 提供(彈 MessageBox)。</summary>
public Func<string, Task<bool>> ConfirmAsync { get; set; } = _ => Task.FromResult(false);
// ── serial attach 狀態(供 serial_read 累積 RX)──
private SerialBridgeEndpoint? _attached;
private readonly StringBuilder _rxBuffer = new();
private readonly object _rxLock = new();
private Action<byte[]>? _rxHandler;
private readonly System.Text.Decoder _dec = Encoding.UTF8.GetDecoder();
// ── PDU 連線登錄(本 AI session 內,IP → controller)──
private readonly Dictionary<string, PduController> _pdus = new(StringComparer.OrdinalIgnoreCase);
private const int PduPortCount = 12;
/// <summary>OpenAI tools schemafunction calling 用)。</summary>
public JsonArray GetSchemas()
{
JsonObject Fn(string name, string desc, JsonObject props, params string[] required)
{
var req = new JsonArray();
foreach (var r in required) req.Add(r);
return new JsonObject
{
["type"] = "function",
["function"] = new JsonObject
{
["name"] = name,
["description"] = desc,
["parameters"] = new JsonObject
{
["type"] = "object",
["properties"] = props,
["required"] = req
}
}
};
}
JsonObject Str(string d) => new() { ["type"] = "string", ["description"] = d };
JsonObject Int(string d) => new() { ["type"] = "integer", ["description"] = d };
JsonObject Bool(string d) => new() { ["type"] = "boolean", ["description"] = d };
return new JsonArray
{
Fn("serial_list", "List the serial sessions currently open in the ETTerms GUI (name + baud).",
new JsonObject()),
Fn("serial_attach", "Bind to an open GUI serial session by name (e.g. COM3). Required before write/read.",
new JsonObject { ["session"] = Str("Session name / COM port, e.g. COM3") }, "session"),
Fn("serial_write", "Send text to the attached serial session (echoes to the terminal tagged [AI]).",
new JsonObject { ["text"] = Str("Text to send"), ["appendNewline"] = Bool("Append the session newline (default true)") }, "text"),
Fn("serial_read", "Read accumulated RX from the attached serial session; optionally wait for a substring.",
new JsonObject { ["waitFor"] = Str("Optional substring to wait for"), ["timeoutMs"] = Int("Max wait ms (default 3000)") }),
Fn("pdu_connect", "Connect to an SNMP PDU by IP and verify it responds. Required before other pdu_* calls.",
new JsonObject { ["ip"] = Str("PDU IP address") }, "ip"),
Fn("pdu_status", "Read all outlets' state / current(mA) / power(W) of a connected PDU.",
new JsonObject { ["ip"] = Str("PDU IP address") }, "ip"),
Fn("pdu_set_port", "Turn a PDU outlet on or off (turning OFF requires user confirmation).",
new JsonObject { ["ip"] = Str("PDU IP"), ["port"] = Int("Outlet number"), ["on"] = Bool("true=on, false=off") }, "ip", "port", "on"),
Fn("pdu_power_cycle", "Power-cycle a PDU outlet (off → wait → on). Requires user confirmation.",
new JsonObject { ["ip"] = Str("PDU IP"), ["port"] = Int("Outlet number"), ["offSeconds"] = Int("Off duration seconds (default 5)") }, "ip", "port"),
};
}
/// <summary>執行一個工具呼叫,回傳給模型的 JSON 字串(統一 {ok, result/error})。</summary>
public async Task<string> InvokeAsync(string name, JsonObject args, CancellationToken ct)
{
AppLogger.Info($"[AI tool] {name} {args.ToJsonString()}");
try
{
return name switch
{
"serial_list" => SerialList(),
"serial_attach" => SerialAttach(Str(args, "session")),
"serial_write" => SerialWrite(Str(args, "text"), Bool(args, "appendNewline", true)),
"serial_read" => await SerialRead(Str(args, "waitFor"), Int(args, "timeoutMs", 3000), ct),
"pdu_connect" => PduConnect(Str(args, "ip")),
"pdu_status" => PduStatus(Str(args, "ip")),
"pdu_set_port" => await PduSetPort(Str(args, "ip"), Int(args, "port", 0), Bool(args, "on", false)),
"pdu_power_cycle" => await PduPowerCycle(Str(args, "ip"), Int(args, "port", 0), Int(args, "offSeconds", 5), ct),
_ => Err($"unknown tool '{name}'")
};
}
catch (Exception ex)
{
AppLogger.LogWarning($"[AI tool] {name} failed: {ex.Message}");
return Err(ex.Message);
}
}
// ── serial ──
private string SerialList()
{
var arr = new JsonArray();
foreach (var e in SerialBridge.All) arr.Add(new JsonObject { ["name"] = e.Name, ["baud"] = e.BaudRate });
return Ok(new JsonObject { ["sessions"] = arr });
}
private string SerialAttach(string session)
{
DetachRx();
var ep = SerialBridge.Find(session);
if (ep == null) return Err($"no open serial session '{session}' in the GUI — open it first");
_attached = ep;
lock (_rxLock) _rxBuffer.Clear();
_rxHandler = data =>
{
lock (_rxLock)
{
var chars = new char[data.Length];
int n = _dec.GetChars(data, 0, data.Length, chars, 0);
if (n > 0) _rxBuffer.Append(chars, 0, n);
if (_rxBuffer.Length > 1_000_000) _rxBuffer.Remove(0, _rxBuffer.Length - 1_000_000);
}
};
ep.Rx += _rxHandler;
return Ok(new JsonObject { ["attached"] = ep.Name });
}
private string SerialWrite(string text, bool appendNewline)
{
if (_attached == null) return Err("not attached — call serial_attach first");
_attached.Write(text, appendNewline);
return Ok(new JsonObject { ["sent"] = text });
}
private async Task<string> SerialRead(string? waitFor, int timeoutMs, CancellationToken ct)
{
if (_attached == null) return Err("not attached — call serial_attach first");
var deadline = Environment.TickCount64 + Math.Clamp(timeoutMs, 0, 120_000);
while (true)
{
string cur;
lock (_rxLock) cur = _rxBuffer.ToString();
if (string.IsNullOrEmpty(waitFor) || cur.Contains(waitFor)) { lock (_rxLock) _rxBuffer.Clear(); return Ok(new JsonObject { ["data"] = cur }); }
if (Environment.TickCount64 >= deadline) { lock (_rxLock) _rxBuffer.Clear(); return Ok(new JsonObject { ["data"] = cur, ["timedOut"] = true }); }
await Task.Delay(80, ct);
}
}
private void DetachRx()
{
if (_attached != null && _rxHandler != null) _attached.Rx -= _rxHandler;
_attached = null; _rxHandler = null;
}
// ── PDU ──
private PduController GetOrThrow(string ip) =>
_pdus.TryGetValue(ip, out var c) ? c : throw new InvalidOperationException($"PDU {ip} not connected — call pdu_connect first");
private string PduConnect(string ip)
{
if (_pdus.ContainsKey(ip)) return Ok(new JsonObject { ["ip"] = ip, ["already"] = true });
var c = new PduController(ip, m => AppLogger.Info(m), m => AppLogger.LogWarning(m));
var model = c.GetModelName();
if (string.IsNullOrEmpty(model)) { c.Dispose(); return Err($"PDU {ip} did not respond to SNMP"); }
_pdus[ip] = c;
return Ok(new JsonObject { ["ip"] = ip, ["model"] = model });
}
private string PduStatus(string ip)
{
var c = GetOrThrow(ip);
var all = c.GetAllPortsStatus(PduPortCount);
var arr = new JsonArray();
for (int i = 0; i < all.Length; i++)
arr.Add(new JsonObject
{
["port"] = i + 1,
["state"] = all[i].State is bool b ? (b ? "on" : "off") : "unknown",
["mA"] = all[i].CurrentMilliAmps,
["W"] = all[i].PowerWatts
});
return Ok(new JsonObject { ["ip"] = ip, ["ports"] = arr });
}
private async Task<string> PduSetPort(string ip, int port, bool on)
{
var c = GetOrThrow(ip);
if (!on && !await ConfirmAsync($"AI 要求關閉 PDU {ip} 的 outlet {port}。確定?"))
return Err("user declined");
bool ok = on ? c.SetPortOn(port) : c.SetPortOff(port);
return ok ? Ok(new JsonObject { ["ip"] = ip, ["port"] = port, ["state"] = on ? "on" : "off" }) : Err("SNMP set failed");
}
private async Task<string> PduPowerCycle(string ip, int port, int offSeconds, CancellationToken ct)
{
var c = GetOrThrow(ip);
offSeconds = Math.Clamp(offSeconds, 1, 60);
if (!await ConfirmAsync($"AI 要求 power-cycle PDU {ip} 的 outlet {port}(關 {offSeconds}s 再開)。確定?"))
return Err("user declined");
if (!c.SetPortOff(port)) return Err("SNMP set (off) failed");
await Task.Delay(offSeconds * 1000, ct);
if (!c.SetPortOn(port)) return Err("SNMP set (on) failed");
return Ok(new JsonObject { ["ip"] = ip, ["port"] = port, ["cycled"] = true, ["offSeconds"] = offSeconds });
}
// ── helpers ──
private static string Ok(JsonObject result) => new JsonObject { ["ok"] = true, ["result"] = result }.ToJsonString();
private static string Err(string msg) => new JsonObject { ["ok"] = false, ["error"] = msg }.ToJsonString();
private static string Str(JsonObject a, string k) => a[k]?.GetValue<string>() ?? "";
private static string? Str(JsonObject a, string k, string? def) => a[k]?.GetValue<string>() ?? def;
private static int Int(JsonObject a, string k, int def) { try { return a[k]?.GetValue<int>() ?? def; } catch { return def; } }
private static bool Bool(JsonObject a, string k, bool def) { try { return a[k]?.GetValue<bool>() ?? def; } catch { return def; } }
public void Dispose()
{
DetachRx();
foreach (var c in _pdus.Values) c.Dispose();
_pdus.Clear();
}
}
+96
View File
@@ -0,0 +1,96 @@
namespace ETTerms.Ai;
/// <summary>
/// 內建 AI Assistant 聊天視窗的 WebView2 HTML 模板(v0.7.0)。
/// 全內嵌(CSS + JS,無外部依賴,符合 NavigateToString 的離線/CSP 需求)。
///
/// C# 端透過 ExecuteScriptAsync 呼叫這裡的 JS 函式:
/// addUser(text) / addAI(html) / addTool(text) / addError(text) / addNote(text)
/// showThinking() / hideThinking()
/// AI 回覆的 markdown 由 C#Markdig)先轉成 HTML 再傳入 addAI。
/// </summary>
internal static class ChatHtml
{
public const string Page = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root {
--bg: #1c1c20; --text: #dedee2; --dim: #9696a0;
--user-bg: #56408a; --ai-bg: #2b2b33; --border: #3a3a42;
--tool: #b6a878; --err: #eb7878; --accent: #8a63d2;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: "Segoe UI", system-ui, sans-serif; font-size: 14px; line-height: 1.5;
}
#chat { padding: 14px 14px 20px; display: flex; flex-direction: column; gap: 10px; }
.row { display: flex; }
.row.user { justify-content: flex-end; }
.row.ai { justify-content: flex-start; }
.bubble {
max-width: 78%; padding: 8px 12px; border-radius: 14px;
white-space: normal; word-wrap: break-word; overflow-wrap: anywhere;
}
.user .bubble { background: var(--user-bg); border-bottom-right-radius: 4px; }
.ai .bubble { background: var(--ai-bg); border: 1px solid var(--border); border-bottom-left-radius: 4px; }
.bubble p { margin: 0 0 8px; } .bubble p:last-child { margin-bottom: 0; }
.bubble pre {
background: #14141a; border: 1px solid var(--border); border-radius: 8px;
padding: 10px; overflow-x: auto; margin: 8px 0;
}
.bubble code { font-family: "Cascadia Mono", Consolas, monospace; font-size: 13px; }
.bubble :not(pre) > code { background: #14141a; padding: 1px 5px; border-radius: 4px; }
.bubble ul, .bubble ol { margin: 6px 0; padding-left: 22px; }
.bubble table { border-collapse: collapse; margin: 8px 0; }
.bubble th, .bubble td { border: 1px solid var(--border); padding: 4px 8px; }
.bubble a { color: #9db4ff; }
.note { color: var(--dim); font-size: 12.5px; text-align: center; padding: 2px 0; }
.tool { color: var(--tool); font-size: 12.5px; font-family: "Cascadia Mono", monospace; padding-left: 4px; }
.err { color: var(--err); font-size: 13px; padding-left: 4px; }
/* thinking 動畫泡 */
#thinking { display: none; }
#thinking.on { display: flex; }
.dots { display: inline-flex; gap: 4px; align-items: center; }
.dots span {
width: 6px; height: 6px; border-radius: 50%; background: var(--dim);
animation: blink 1.2s infinite both;
}
.dots span:nth-child(2) { animation-delay: .2s; }
.dots span:nth-child(3) { animation-delay: .4s; }
@keyframes blink { 0%,80%,100% { opacity: .25; } 40% { opacity: 1; } }
</style>
</head>
<body>
<div id="chat">
<div class="row ai" id="thinking">
<div class="bubble"><span class="dots"><span></span><span></span><span></span></span></div>
</div>
</div>
<script>
var chat = document.getElementById('chat');
var thinking = document.getElementById('thinking');
function atBottom() { return window.innerHeight + window.scrollY >= document.body.scrollHeight - 40; }
function scroll() { window.scrollTo(0, document.body.scrollHeight); }
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function bubbleRow(cls, innerHtml) {
var row = document.createElement('div'); row.className = 'row ' + cls;
var b = document.createElement('div'); b.className = 'bubble'; b.innerHTML = innerHtml;
row.appendChild(b); chat.insertBefore(row, thinking); scroll();
}
function addUser(t) { bubbleRow('user', esc(t).replace(/\n/g, '<br>')); }
function addAI(html) { bubbleRow('ai', html); }
function addTool(t) { var d = document.createElement('div'); d.className = 'tool'; d.textContent = ' ' + t; chat.insertBefore(d, thinking); scroll(); }
function addError(t) { var d = document.createElement('div'); d.className = 'err'; d.textContent = ' ' + t; chat.insertBefore(d, thinking); scroll(); }
function addNote(t) { var d = document.createElement('div'); d.className = 'note'; d.textContent = t; chat.insertBefore(d, thinking); scroll(); }
function showThinking() { thinking.classList.add('on'); scroll(); }
function hideThinking() { thinking.classList.remove('on'); }
</script>
</body>
</html>
""";
}
+98
View File
@@ -0,0 +1,98 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ETTerms.Ai;
/// <summary>
/// 極簡 OpenAI 相容 chat-completions client(非串流)。
/// 只依賴 HttpClient + System.Text.Json,不引入 SDK——因為端點是使用者自帶(BYO),
/// 任何 OpenAI 相容 gatewayOllama / LiteLLM / 公司內部 gateway / OpenAI…)皆可接。
///
/// ⚠️ Base URL 與 API key 皆由使用者於 Settings 設定,不寫死於程式碼(見 AppSettings 註解)。
/// </summary>
public sealed class OpenAiChatClient : IDisposable
{
private readonly HttpClient _http;
/// <summary>目前使用的模型;可即時切換(下一輪對話生效),用於底部模型下拉選單。</summary>
public string Model { get; set; }
public OpenAiChatClient(string baseUrl, string apiKey, string model)
{
// baseUrl 例:"http://localhost:11434/v1" → endpoint = baseUrl + "/chat/completions"
var root = baseUrl.TrimEnd('/');
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(300) };
_http.BaseAddress = new Uri(root + "/");
if (!string.IsNullOrEmpty(apiKey))
_http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
Model = model;
}
/// <summary>列出端點可用模型 idOpenAI 相容 GET /models)。失敗回空清單。</summary>
public async Task<List<string>> ListModelsAsync(CancellationToken ct)
{
var list = new List<string>();
try
{
using var resp = await _http.GetAsync("models", ct);
if (!resp.IsSuccessStatusCode) return list;
var text = await resp.Content.ReadAsStringAsync(ct);
var data = JsonNode.Parse(text)?["data"]?.AsArray();
if (data == null) return list;
foreach (var m in data)
{
var id = m?["id"]?.GetValue<string>();
if (!string.IsNullOrEmpty(id)) list.Add(id);
}
}
catch { /* 端點不支援 /models 或連不上 → 回空,由 caller fallback */ }
return list;
}
/// <summary>
/// 送出一輪對話(含歷史 messages 與可用 tools),回傳 assistant 的回應訊息節點
/// (可能含 content 或 tool_calls)。呼叫端負責 agent loop。
/// </summary>
public async Task<JsonObject> CompleteAsync(JsonArray messages, JsonArray? tools, CancellationToken ct)
{
var body = new JsonObject
{
["model"] = Model,
["messages"] = messages.DeepClone(),
["temperature"] = 0.2,
};
if (tools != null && tools.Count > 0)
{
body["tools"] = tools.DeepClone();
body["tool_choice"] = "auto";
}
using var req = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
{
Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json")
};
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseContentRead, ct);
var text = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"AI endpoint HTTP {(int)resp.StatusCode}: {Trunc(text, 400)}");
JsonObject root;
try { root = JsonNode.Parse(text)!.AsObject(); }
catch (Exception ex) { throw new InvalidOperationException($"AI response parse error: {ex.Message}\n{Trunc(text, 400)}"); }
var choices = root["choices"]?.AsArray();
if (choices == null || choices.Count == 0)
throw new InvalidOperationException($"AI response has no choices: {Trunc(text, 400)}");
var msg = choices[0]?["message"]?.AsObject();
if (msg == null)
throw new InvalidOperationException($"AI response has no message: {Trunc(text, 400)}");
return (JsonObject)msg.DeepClone();
}
private static string Trunc(string s, int n) => s.Length <= n ? s : s.Substring(0, n) + "…";
public void Dispose() => _http.Dispose();
}
+38
View File
@@ -183,6 +183,44 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog = private static readonly ChangelogEntry[] Changelog =
[ [
new("0.7.0", new DateOnly(2026, 7, 5), "AI chat gets real bubbles, Markdown & a thinking indicator",
[
"The AI Chat pane now renders proper chat bubbles (your messages on the right, the AI's on the left) with full Markdown — code blocks, tables, lists and inline `code` all display nicely.",
"When you send a prompt, an animated \"…\" thinking bubble appears while the AI works and disappears the moment the reply arrives — so you always know it's running.",
"Under the hood this uses WebView2 (built into Windows 11); the model dropdown, [AI] serial tagging, and PDU confirmations all work exactly as before.",
"New setting: Max tool calls per message (Settings → AI Assistant). Set it to 0 for unlimited — handy for long automation runs you leave going — and press Stop in the chat to abort any run in progress.",
]),
new("0.6.0", new DateOnly(2026, 7, 5), "Built-in AI Assistant — drive serial & PDU in plain language",
[
"New ✨ AI Chat: click ✨ AI Chat in the toolbar to open an AI pane, then use Layout (1×2, 2×2…) to sit it right next to a Serial session — chat on one side while you watch the terminal on the other, just like Claude Code / Kiro.",
"Clean transcript layout: your messages align right in accent color, the AI's replies read left, and tool activity stays as quiet gray notes — same tidy feel as the terminal. A model dropdown at the bottom-right lists the models your endpoint offers, so you pick and switch models right there, no trip to Settings.",
"Talk in plain language to send serial commands and control PDU outlets — e.g. \"attach to COM3, send help and show me the reply\" or \"connect to the PDU and power-cycle outlet 3\".",
"Bring your own AI endpoint: point it at any OpenAI-compatible server (a local Ollama, a LiteLLM gateway, your company's gateway, or OpenAI). Set it up in Settings → AI Assistant; leave it blank and the assistant simply stays off.",
"Your API key is stored in Windows Credential Manager, never in a settings file — and no endpoint ships inside the app, so a copy you hand to someone else has the assistant disabled by default.",
"The AI drives your existing open Serial session, and everything it sends shows up in that terminal tagged [AI] so you always see what it did.",
"Turning an outlet off or power-cycling always pops up a confirmation first — the AI can't cut power on its own. Every tool call is written to the app log.",
"This is separate from the existing Serial/PDU MCP servers (Settings → AI MCP), which keep working for external AI CLIs like Claude Code / Kiro.",
]),
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.",
+280
View File
@@ -0,0 +1,280 @@
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Text.Json;
using System.Windows.Forms;
using ETTerms.Ai;
using ETTerms.Connections;
using ETTerms.Infrastructure;
using Markdig;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace ETTerms.App;
/// <summary>
/// 內建 AI Assistant 分頁(Workspace 的一種 pane,可與 serial 並排)。自然語言驅動 serial + PDU。
///
/// v0.7.0:訊息區改用 <b>WebView2</b> 渲染真聊天氣泡(user 右 / AI 左)+ Markdown(程式碼區塊、
/// 表格、清單)+ 送出後的 thinking 動畫泡。底部控制列(輸入框 / 模型下拉 / Send)仍為 WinForms。
///
/// Provider 未設定(Base URL 空)時停用並提示。⚠️ 端點 / 金鑰皆由使用者設定,程式不含任何預設私人端點。
/// </summary>
public sealed class AiChatView : UserControl
{
private readonly WebView2 _web;
private readonly TextBox _input;
private readonly Button _send;
private readonly Label _hint;
private readonly ComboBox _modelBox;
private bool _suppressModelEvent;
private bool _ready;
private readonly Queue<string> _pending = new();
private AgentHost? _agent;
private OpenAiChatClient? _client;
private AiTools? _tools;
private CancellationTokenSource? _cts;
private static readonly MarkdownPipeline Md =
new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
public AiChatView()
{
Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack;
_web = new WebView2 { Dock = DockStyle.Fill };
// ── 底部控制列:輸入框(Fill) + 右下角欄(模型下拉在上、Send 在下) ──
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 96, BackColor = Theme.RailBack, Padding = new Padding(10, 8, 10, 8) };
_input = new TextBox
{
Dock = DockStyle.Fill, Multiline = true, BackColor = Theme.TabBack, ForeColor = Theme.Text,
Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle
};
_input.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Enter && !e.Shift && !_running) { e.Handled = e.SuppressKeyPress = true; OnSend(); }
};
var rightCol = new Panel { Dock = DockStyle.Right, Width = 178, BackColor = Theme.RailBack, Padding = new Padding(8, 0, 0, 0) };
var modelWrap = new Panel { Dock = DockStyle.Top, Height = 24, BackColor = Theme.RailBack };
_modelBox = new ComboBox
{
Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList,
BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat, Font = Theme.UiFont
};
_modelBox.SelectedIndexChanged += (_, _) =>
{
if (_suppressModelEvent || _client == null || _modelBox.SelectedItem is not string m) return;
_client.Model = m;
AppSettings.Instance.AiModel = m; // 記住選擇,下次開 pane 用同一個
AppSettings.Instance.Save();
AddNote($"— 模型切換為 {m} —");
};
var refreshBtn = new Button
{
Text = "↻", Dock = DockStyle.Right, Width = 24, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.TextDim, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
};
refreshBtn.FlatAppearance.BorderColor = Theme.Border;
refreshBtn.Click += (_, _) => _ = LoadModelsAsync();
modelWrap.Controls.Add(_modelBox); // Fill
modelWrap.Controls.Add(refreshBtn); // Right
var gap = new Panel { Dock = DockStyle.Top, Height = 6, BackColor = Theme.RailBack };
_send = new Button
{
Text = "Send ⏎", Dock = DockStyle.Fill, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
};
_send.FlatAppearance.BorderColor = Theme.Accent;
_send.Click += (_, _) => OnSend();
rightCol.Controls.Add(_send); // Fill 先加
rightCol.Controls.Add(gap); // TopSend 上方間距)
rightCol.Controls.Add(modelWrap); // Top(最上:模型下拉)
bottom.Controls.Add(_input); // Fill 先加
bottom.Controls.Add(rightCol); // Right(右下角)
_hint = new Label
{
Dock = DockStyle.Top, Height = 40, BackColor = Color.FromArgb(60, 50, 30), ForeColor = Theme.Text,
Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleCenter, Visible = false,
Text = "尚未設定 AI Provider — 到 Settings → AI Assistant 填入 Base URL 與 API Key。"
};
Controls.Add(_web); // Fill
Controls.Add(_hint); // Top
Controls.Add(bottom); // Bottom
_ = InitWebAsync();
}
private async Task InitWebAsync()
{
try
{
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ETTerms", "WebView2");
Directory.CreateDirectory(dataDir);
var env = await CoreWebView2Environment.CreateAsync(null, dataDir);
await _web.EnsureCoreWebView2Async(env);
_web.CoreWebView2.Settings.AreDevToolsEnabled = false;
_web.CoreWebView2.Settings.IsZoomControlEnabled = false;
_web.CoreWebView2.NavigationCompleted += (_, _) =>
{
if (_ready) return;
_ready = true;
while (_pending.Count > 0) Exec(_pending.Dequeue());
AddNote("ETTerms AI Assistant — 用自然語言操作 serial 與 PDU。");
AddNote("例:「列出目前的 serial session」、「接上 COM3,送 help 看回應」、「連上 PDU 192.168.1.50,把 outlet 3 重開」");
};
_web.NavigateToString(ChatHtml.Page);
}
catch (Exception ex)
{
AppLogger.LogError("WebView2 init failed", ex);
_hint.Text = "AI 聊天需要 WebView2 RuntimeWin11 內建)。初始化失敗:" + ex.Message;
_hint.Visible = true;
}
}
/// <summary>開分頁時呼叫,依最新設定重建 client。Base URL 空=停用。</summary>
public void RefreshProvider()
{
var s = AppSettings.Instance;
bool configured = !string.IsNullOrWhiteSpace(s.AiBaseUrl);
_hint.Visible = !configured;
_input.Enabled = _send.Enabled = _modelBox.Enabled = configured;
_client?.Dispose(); _client = null;
_tools?.Dispose(); _tools = null;
_agent = null;
if (!configured) return;
var key = CredentialVault.Get("ETTerms/AiApiKey") ?? "";
_client = new OpenAiChatClient(s.AiBaseUrl, key, s.AiModel);
_tools = new AiTools { ConfirmAsync = ConfirmOnUiAsync };
_agent = new AgentHost(_client, _tools, s.AiSystemPrompt, s.AiMaxToolRounds);
_agent.AssistantText += t => Ui(() => { HideThinking(); AddAI(t); });
_agent.ToolActivity += t => Ui(() => AddTool(t));
_agent.Status += st => Ui(() => { if (st == "thinking") ShowThinking(); });
_ = LoadModelsAsync();
}
/// <summary>拉端點可用模型填入下拉選單(GET /v1/models);沒設過模型就自動選第一個。</summary>
private async Task LoadModelsAsync()
{
if (_client == null) return;
var current = _client.Model;
List<string> models;
try { models = await _client.ListModelsAsync(CancellationToken.None); }
catch { models = new(); }
if (!string.IsNullOrEmpty(current) && !models.Contains(current)) models.Insert(0, current);
Ui(() =>
{
_suppressModelEvent = true;
_modelBox.Items.Clear();
foreach (var m in models) _modelBox.Items.Add(m);
string pick = !string.IsNullOrEmpty(current) && models.Contains(current) ? current
: models.Count > 0 ? models[0] : "";
if (pick.Length > 0)
{
_modelBox.SelectedItem = pick;
_client!.Model = pick;
if (AppSettings.Instance.AiModel != pick)
{
AppSettings.Instance.AiModel = pick;
AppSettings.Instance.Save();
}
}
else AddError("端點未回報任何模型 — 確認 Base URL 是否為 OpenAI 相容 /v1 端點。");
_suppressModelEvent = false;
});
}
private Task<bool> ConfirmOnUiAsync(string message)
{
var tcs = new TaskCompletionSource<bool>();
Ui(() =>
{
var r = MessageBox.Show(this, message, "AI 動作確認",
MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2);
tcs.SetResult(r == DialogResult.Yes);
});
return tcs.Task;
}
private bool _running;
private async void OnSend()
{
if (_agent == null) return;
if (_running) { _cts?.Cancel(); return; } // 執行中再按 = 中止(長跑用)
var text = _input.Text.Trim();
if (text.Length == 0) return;
_input.Clear();
AddUser(text);
ShowThinking();
SetRunning(true);
_cts = new CancellationTokenSource();
try { await _agent.SendAsync(text, _cts.Token); }
catch (OperationCanceledException) { AddNote("(已停止)"); }
catch (Exception ex) { AddError(ex.Message); }
finally { HideThinking(); SetRunning(false); }
}
private void SetRunning(bool running)
{
_running = running;
_send.Text = running ? "■ Stop" : "Send ⏎";
_send.FlatAppearance.BorderColor = running ? Color.FromArgb(210, 120, 120) : Theme.Accent;
}
// ── WebView2 interop(呼叫 ChatHtml 裡的 JS 函式)──
private void AddUser(string t) => Exec($"addUser({Js(t)})");
private void AddAI(string markdown) => Exec($"addAI({Js(Markdown.ToHtml(markdown, Md))})");
private void AddTool(string t) => Exec($"addTool({Js(t)})");
private void AddError(string t) => Exec($"addError({Js(t)})");
private void AddNote(string t) => Exec($"addNote({Js(t)})");
private void ShowThinking() => Exec("showThinking()");
private void HideThinking() => Exec("hideThinking()");
private static string Js(string s) => JsonSerializer.Serialize(s);
private void Exec(string js)
{
if (!_ready) { _pending.Enqueue(js); return; }
try { _ = _web.CoreWebView2.ExecuteScriptAsync(js); } catch { }
}
private void Ui(Action a)
{
if (IsDisposed || !IsHandleCreated) return;
if (InvokeRequired) BeginInvoke(a); else a();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_cts?.Cancel();
_client?.Dispose();
_tools?.Dispose();
_web?.Dispose();
}
base.Dispose(disposing);
}
}
+224 -1
View File
@@ -1,10 +1,11 @@
using System.Drawing; using System.Drawing;
using System.Windows.Forms; using System.Windows.Forms;
using ETTerms.Connections;
using ETTerms.Infrastructure; using ETTerms.Infrastructure;
namespace ETTerms.App; namespace ETTerms.App;
/// <summary>Settings page with tabs: Terminal / AI MCP.</summary> /// <summary>Settings page with tabs: Terminal / Highlight / AI Assistant / AI MCP.</summary>
public sealed class SettingsView : UserControl public sealed class SettingsView : UserControl
{ {
public SettingsView() public SettingsView()
@@ -52,6 +53,8 @@ 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 Assistant", BuildAiAssistantTab()));
tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab())); tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab()));
Controls.Add(body); Controls.Add(body);
@@ -93,6 +96,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 +171,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 +183,217 @@ 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 Assistant Tab(內建 agent 的 BYO endpoint 設定)═══
private Panel BuildAiAssistantTab()
{
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 = "Built-in AI Assistant", AutoSize = true,
ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4)
});
flow.Controls.Add(new Label
{
Text = "Bring your own OpenAI-compatible endpoint (Ollama / LiteLLM / a company gateway / OpenAI…).\n" +
"Leave blank to keep the AI Assistant disabled. Pick the model from the dropdown inside the\n" +
"AI Chat pane (it lists what your endpoint offers). The model must support function calling.\n" +
"The API key is stored in Windows Credential Manager, never in settings.json or the app.",
AutoSize = false, Width = 620, Height = 68,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 10)
});
var baseUrl = new TextBox
{
Width = 380, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont,
BorderStyle = BorderStyle.FixedSingle, Text = s.AiBaseUrl,
PlaceholderText = "http://localhost:11434/v1"
};
var apiKey = new TextBox
{
Width = 380, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont,
BorderStyle = BorderStyle.FixedSingle, UseSystemPasswordChar = true,
Text = CredentialVault.Get("ETTerms/AiApiKey") ?? "",
PlaceholderText = "(stored in Credential Manager)"
};
var sysPrompt = new TextBox
{
Width = 560, Height = 70, Multiline = true, BackColor = Theme.TabBack, ForeColor = Theme.Text,
Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle, Text = s.AiSystemPrompt,
PlaceholderText = "(optional) override the assistant persona / system prompt"
};
var maxRounds = new NumericUpDown
{
Width = 100, Minimum = 0, Maximum = 100000, Increment = 10, Value = s.AiMaxToolRounds,
BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle
};
flow.Controls.Add(MakeRow("Base URL (with /v1)", baseUrl));
flow.Controls.Add(MakeRow("API Key", apiKey));
flow.Controls.Add(MakeRow("Max tool calls / message", maxRounds));
flow.Controls.Add(new Label
{
Text = "How many tool calls the assistant may chain per message before it stops (a runaway-loop guard).\n" +
"0 = unlimited — for long automation runs. Every round costs tokens; press Stop in the chat to abort.",
AutoSize = false, Width = 620, Height = 34,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 6)
});
flow.Controls.Add(MakeSpacer(4));
flow.Controls.Add(new Label
{
Text = "System prompt (optional):", AutoSize = true,
ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 4, 0, 2)
});
flow.Controls.Add(sysPrompt);
flow.Controls.Add(MakeSpacer(6));
flow.Controls.Add(new Label
{
Text = "Tools the assistant can call: serial send/read (via the GUI's open Serial session, shown as [AI]),\n" +
"and PDU control over SNMP. Turning an outlet off / power-cycling always asks you to confirm.",
AutoSize = false, Width = 620, Height = 36,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
});
var save = MakeButton("Save", Theme.Accent);
save.Click += (_, _) =>
{
s.AiBaseUrl = baseUrl.Text.Trim();
s.AiSystemPrompt = sysPrompt.Text.Trim();
s.AiMaxToolRounds = (int)maxRounds.Value;
s.Save();
var key = apiKey.Text;
if (string.IsNullOrEmpty(key)) CredentialVault.Delete("ETTerms/AiApiKey");
else CredentialVault.Set("ETTerms/AiApiKey", key);
MessageBox.Show(this,
string.IsNullOrWhiteSpace(s.AiBaseUrl)
? "Saved. AI Assistant stays disabled until you set a Base URL."
: "Saved. Open ✨ AI Chat in the workspace toolbar, then pick a model from the dropdown.",
"AI Assistant", 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++)
{ {
+75 -64
View File
@@ -17,9 +17,14 @@ public sealed class WorkspaceView : UserControl
{ {
public required string Title; public required string Title;
public required bool IsSsh; public required bool IsSsh;
public required SessionPage Page; public SessionPage? Page; // 連線分頁(serial/ssh/shell);AI 分頁時為 null
public AiChatView? Ai; // AI 聊天分頁;連線分頁時為 null
public bool IsAi => Ai != null;
/// <summary>可塞進格子的內容控制項(SessionPage 或 AiChatView,二擇一)。</summary>
public Control Content => (Control?)Page ?? Ai!;
public Rectangle TabBounds; public Rectangle TabBounds;
public Rectangle CloseRect; public Rectangle CloseRect;
public bool Alert; // 背景分頁出現高亮關鍵字 → 標紅點,切到該分頁時清除
} }
private readonly FlowLayoutPanel _toolbar; private readonly FlowLayoutPanel _toolbar;
@@ -60,6 +65,17 @@ public sealed class WorkspaceView : UserControl
Dock = DockStyle.Fill, BackColor = Theme.RailBack, Dock = DockStyle.Fill, BackColor = Theme.RailBack,
Padding = new Padding(8, 6, 8, 6), WrapContents = false Padding = new Padding(8, 6, 8, 6), WrapContents = false
}; };
// ── ✨ New AI Chat(開一個 AI 分頁,可用 Layout 與 serial 並排)──
var aiBtn = MakeActionButton("✨ AI Chat", 92, 0, (_, _) => OpenAiPane());
aiBtn.ForeColor = Theme.Accent;
aiBtn.FlatAppearance.BorderColor = Theme.Accent;
_toolbar.Controls.Add(aiBtn);
_toolbar.Controls.Add(new Label
{
Text = "│", AutoSize = true, ForeColor = Theme.Border,
Font = Theme.UiFont, Margin = new Padding(4, 6, 4, 0)
});
_toolbar.Controls.Add(new Label _toolbar.Controls.Add(new Label
{ {
Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim, Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim,
@@ -114,11 +130,28 @@ 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();
} }
/// <summary>開啟一個 AI Assistant 分頁(跟連線分頁一樣可用 Layout 並排,與 serial 同時使用)。</summary>
public void OpenAiPane()
{
var view = new AiChatView();
var s = new Session { Title = "AI Assistant", IsSsh = false, Ai = view };
_sessions.Add(s);
_active = s;
Relayout();
view.RefreshProvider();
}
private void OnConnectFailed(Session s, string msg) private void OnConnectFailed(Session s, string msg)
{ {
if (IsDisposed) return; if (IsDisposed) return;
@@ -143,17 +176,18 @@ public sealed class WorkspaceView : UserControl
private void CloseSession(Session s) private void CloseSession(Session s)
{ {
int idx = _sessions.IndexOf(s); int idx = _sessions.IndexOf(s);
s.Page.Parent = null; s.Content.Parent = null;
_sessions.Remove(s); _sessions.Remove(s);
s.Page.Dispose(); s.Content.Dispose();
if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null; if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null;
RefreshGroupLabels(); RefreshGroupLabels();
Relayout(); Relayout();
} }
// ── Group 管理 ─────────────────────────────────────────── // ── Group 管理AI 分頁無 Group─────────────────────────
private void SetSessionGroup(Session s, int group) private void SetSessionGroup(Session s, int group)
{ {
if (s.Page == null) return;
s.Page.Group = group; s.Page.Group = group;
RefreshGroupLabels(); RefreshGroupLabels();
Relayout(); Relayout();
@@ -164,17 +198,17 @@ public sealed class WorkspaceView : UserControl
for (int g = 1; g <= 3; g++) for (int g = 1; g <= 3; g++)
{ {
char letter = 'A'; char letter = 'A';
foreach (var s in _sessions.Where(x => x.Page.Group == g)) foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == g))
s.Page.GroupLabel = $"Group{g}-{letter++}"; s.Page!.GroupLabel = $"Group{g}-{letter++}";
} }
foreach (var s in _sessions.Where(x => x.Page.Group == 0)) foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == 0))
s.Page.GroupLabel = ""; s.Page!.GroupLabel = "";
} }
// ── 依目前 Layout 把分頁鋪進格子 ───────────────────────── // ── 依目前 Layout 把分頁鋪進格子 ─────────────────────────
private void Relayout() private void Relayout()
{ {
foreach (var s in _sessions) s.Page.Parent = null; // 先卸下(保留存活) foreach (var s in _sessions) s.Content.Parent = null; // 先卸下(保留存活)
_body.SuspendLayout(); _body.SuspendLayout();
for (int i = _body.Controls.Count - 1; i >= 0; i--) for (int i = _body.Controls.Count - 1; i >= 0; i--)
{ {
@@ -191,6 +225,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);
@@ -221,14 +256,16 @@ public sealed class WorkspaceView : UserControl
private Control MakeCell(Session s, bool withLabel) private Control MakeCell(Session s, bool withLabel)
{ {
var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) }; var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) };
s.Page.Dock = DockStyle.Fill; s.Content.Dock = DockStyle.Fill;
s.Page.Visible = true; s.Content.Visible = true;
cell.Controls.Add(s.Page); // Fill 先加 cell.Controls.Add(s.Content); // Fill 先加
if (withLabel) if (withLabel)
{ {
string labelText = string.IsNullOrEmpty(s.Page.GroupLabel) string icon = s.IsAi ? "✨" : s.IsSsh ? "🖧" : "🔌";
? $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title}" string glabel = s.Page?.GroupLabel ?? "";
: $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title} [{s.Page.GroupLabel}]"; string labelText = string.IsNullOrEmpty(glabel)
? $"{icon} {s.Title}"
: $"{icon} {s.Title} [{glabel}]";
var lbl = new Label var lbl = new Label
{ {
Dock = DockStyle.Bottom, Height = 22, Dock = DockStyle.Bottom, Height = 22,
@@ -245,8 +282,8 @@ public sealed class WorkspaceView : UserControl
private void FocusActive() private void FocusActive()
{ {
if (_active?.Page is { IsDisposed: false } p && p.IsHandleCreated) if (_active?.Content is { IsDisposed: false } c && c.IsHandleCreated)
p.Focus(); c.Focus();
} }
// ── 頂部 Tab 列 ────────────────────────────────────────── // ── 頂部 Tab 列 ──────────────────────────────────────────
@@ -268,7 +305,7 @@ public sealed class WorkspaceView : UserControl
{ {
foreach (var s in _sessions) foreach (var s in _sessions)
{ {
if (s.TabBounds.Contains(e.Location)) { ShowGroupMenu(s, e.Location); return; } if (s.TabBounds.Contains(e.Location)) { if (!s.IsAi) ShowGroupMenu(s, e.Location); return; }
} }
return; return;
} }
@@ -295,7 +332,7 @@ public sealed class WorkspaceView : UserControl
menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2)); menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2));
menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3)); menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3));
// Check current // Check current
int current = s.Page.Group; int current = s.Page?.Group ?? 0;
((ToolStripMenuItem)menu.Items[current]).Checked = true; ((ToolStripMenuItem)menu.Items[current]).Checked = true;
menu.Show(_tabStrip, pt); menu.Show(_tabStrip, pt);
} }
@@ -360,7 +397,10 @@ 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)) // 警示中的背景分頁:型別圓點改紅色,切過去看時清除。AI 分頁用 accent 紫。
Color dotColor = s.Alert ? Color.FromArgb(235, 85, 85)
: s.IsAi ? Theme.Accent : s.IsSsh ? Theme.SshColor : Theme.SerialColor;
using (var dot = new SolidBrush(dotColor))
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,
@@ -406,19 +446,20 @@ public sealed class WorkspaceView : UserControl
// ── Log All(一次開/關所有分頁側錄) ────────────────────── // ── Log All(一次開/關所有分頁側錄) ──────────────────────
private void OnToggleLogAll(object? sender, EventArgs e) private void OnToggleLogAll(object? sender, EventArgs e)
{ {
if (_sessions.Count == 0) var loggable = _sessions.Where(s => s.Page != null).Select(s => s.Page!).ToList();
if (loggable.Count == 0)
{ {
MessageBox.Show(this, "No open sessions to log.", "Log All", MessageBoxButtons.OK, MessageBoxIcon.Information); MessageBox.Show(this, "No open sessions to log.", "Log All", MessageBoxButtons.OK, MessageBoxIcon.Information);
return; return;
} }
// 只要還有分頁沒在側錄 → 全部開始;否則全部停止。 // 只要還有分頁沒在側錄 → 全部開始;否則全部停止。AI 分頁無側錄)
bool startAll = _sessions.Any(s => !s.Page.IsLogging); bool startAll = loggable.Any(p => !p.IsLogging);
if (startAll) if (startAll)
{ {
int failed = 0; int failed = 0;
foreach (var s in _sessions) foreach (var p in loggable)
if (!s.Page.StartLog()) failed++; if (!p.StartLog()) failed++;
SetLogAllActive(true); SetLogAllActive(true);
if (failed > 0) if (failed > 0)
MessageBox.Show(this, $"{failed} session(s) failed to start logging. See app log for details.", MessageBox.Show(this, $"{failed} session(s) failed to start logging. See app log for details.",
@@ -426,7 +467,7 @@ public sealed class WorkspaceView : UserControl
} }
else else
{ {
foreach (var s in _sessions) s.Page.StopLog(); foreach (var p in loggable) p.StopLog();
SetLogAllActive(false); SetLogAllActive(false);
} }
} }
@@ -442,8 +483,8 @@ public sealed class WorkspaceView : UserControl
private async void OnRunAllSerial(object? sender, EventArgs e) private async void OnRunAllSerial(object? sender, EventArgs e)
{ {
var serials = _sessions var serials = _sessions
.Where(s => !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning) .Where(s => s.Page != null && !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning)
.Select(s => s.Page) .Select(s => s.Page!)
.ToList(); .ToList();
if (serials.Count == 0) if (serials.Count == 0)
@@ -452,22 +493,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)));
} }
@@ -476,8 +504,8 @@ public sealed class WorkspaceView : UserControl
private async void OnRunGroup(int group) private async void OnRunGroup(int group)
{ {
var members = _sessions var members = _sessions
.Where(s => s.Page.Group == group && !s.Page.IsScriptRunning) .Where(s => s.Page != null && s.Page.Group == group && !s.Page.IsScriptRunning)
.Select(s => s.Page) .Select(s => s.Page!)
.ToList(); .ToList();
if (members.Count == 0) if (members.Count == 0)
@@ -486,33 +514,16 @@ 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)
foreach (var s in _sessions) { try { s.Page.Dispose(); } catch { } } foreach (var s in _sessions) { try { s.Content.Dispose(); } catch { } }
base.Dispose(disposing); base.Dispose(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
}); });
+10 -6
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName> <AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 --> <!-- 版本資訊 -->
<Version>0.3.3</Version> <Version>0.7.1</Version>
<Product>ETTerms</Product> <Product>ETTerms</Product>
<Company>ETTerms Project</Company> <Company>ETTerms Project</Company>
@@ -27,14 +27,18 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="1.3.2" />
<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"> <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
<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 +53,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>
+27
View File
@@ -22,11 +22,31 @@ 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; } = "";
// ── AI AssistantBYO endpoint)──
// ⚠️ 預設全空白=內建 AI 停用。任何私人端點 / 金鑰不得寫死於此或程式碼——
// 使用者自己在 Settings → AI Assistant 填。API key 存 Credential ManagerETTerms/AiApiKey),不在此檔。
/// <summary>OpenAI 相容端點,含 /v1(例:http://localhost:11434/v1)。空=AI 停用。</summary>
public string AiBaseUrl { get; set; } = "";
/// <summary>模型名(需支援 function calling)。</summary>
public string AiModel { get; set; } = "";
/// <summary>系統提示詞(人設);空則用內建預設。</summary>
public string AiSystemPrompt { get; set; } = "";
/// <summary>AI agent 單次訊息的工具呼叫輪數上限(防失控迴圈的保險)。
/// **0 = 無上限**(自動化長跑用;注意每輪都燒 token/費用,執行中可按 Stop 中止)。</summary>
public int AiMaxToolRounds { get; set; } = 30;
// ── 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 +103,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"