33 Commits
Author SHA1 Message Date
etwenandClaude Opus 4.8 90c1c45a56 merge: v0.7.1 — sendlnretry: stop losing commands to a booting device
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 10:13:06 +08:00
etwenandClaude Opus 4.8 6d84de819f docs(about): Add v0.7.1 to the in-app changelog
The About page changelog is a hardcoded array, so it did not list v0.7.1
even though the title bar and About header already read 0.7.1 (those come
from the assembly version).

Also rework docs/release-notes/v0.7.1.md to the house format: New features
before Bug fixes, bold group headings, and the standard Downloads table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKF7C75bhA4ArQhyMpnsxU
2026-07-16 10:12:59 +08:00
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
etwen 9628609eea merge: keep terminal focus after activity (v0.3.3) 2026-06-16 16:24:46 +08:00
etwenandClaude Opus 4.8 fc69adfae4 fix(terminal): keep keyboard focus on terminal after activity (v0.3.3)
The terminal's DarkScrollBar derives from Control, which is selectable by
default. Once enough output accumulated to create scrollback, the scrollbar
became enabled and was the only selectable child of the TerminalView
(a ContainerControl). On window re-activation (minimize/restore, click away
and back) WinForms restored focus down the container chain onto the scrollbar,
which has no keyboard handling, so typing/Enter went nowhere until the session
was reopened. This affected both Serial and PowerShell tabs (shared TerminalView),
and only after output appeared — matching the reported symptom.

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

Bump version to 0.3.3 and add the About changelog entry.

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

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

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

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

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

- McpRegistrar now registers both etterms-serial and etterms-pdu in one click
- Settings -> AI MCP shows both server paths
- publish target (PublishMcpServers) bundles both MCP servers
- bump version to 0.3.0, update About changelog, ARCHITECTURE.md, CLAUDE.md
2026-06-08 10:12:15 +08:00
etwen 89ec175669 docs: credit KKTerm (by ryantsai, MIT) in Acknowledgments 2026-06-05 13:54:29 +08:00
51 changed files with 5149 additions and 605 deletions
+116 -13
View File
@@ -32,7 +32,8 @@ ETTerms 是一個給工程師 / 韌體 / 硬體驗證人員用的**單一視窗
| 祕密儲存 | **Windows Credential Manager**DPAPI / CredMan | 連線密碼、SSH key passphrase,不落地明碼 | | 祕密儲存 | **Windows Credential Manager**DPAPI / CredMan | 連線密碼、SSH key passphrase,不落地明碼 |
| 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` | 獨立行程,但不自己開 port——經本機 named pipe 橋接 GUI 持有的 serial session,把收發暴露成 AI 可呼叫工具(Kiro CLI / Claude CLI),見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp-server) | | AI / MCP 整合(選用) | **stdio MCP server**(官方 C# SDK `ModelContextProtocol` | 兩個獨立 server`ETTerms.SerialMcp`不自己開 port經本機 named pipe 橋接 GUI 持有的 serial session)與 `ETTerms.PduMcp`(直接打 SNMP 控制 PDU 插座,不需 GUI);把收發 / 電源控制暴露成 AI 可呼叫工具(Kiro CLI / Claude CLI),見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp--pdu-mcp-server) |
| 內建 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`」。
@@ -118,8 +119,11 @@ ETTerms/
│ ├── MainForm.cs # 主視窗:Rail + Sidebar + Workspace(含深色標題列 DWM │ ├── MainForm.cs # 主視窗:Rail + Sidebar + Workspace(含深色標題列 DWM
│ ├── MainForm.Designer.cs │ ├── MainForm.Designer.cs
│ ├── Theme.cs # 全域深色配色 (KKTerm 風格) │ ├── Theme.cs # 全域深色配色 (KKTerm 風格)
│ ├── ActivityRail.cs # 左側圖示列 (Terminal/Scripts/Settings) │ ├── ActivityRail.cs # 左側圖示列 (Terminal/Status/Settings/About)
│ ├── ConnectionSidebar.cs# 仿 KKTerm 可編輯資料夾樹(搜尋/CRUD/拖曳分類) │ ├── ConnectionSidebar.cs# 仿 KKTerm 可編輯資料夾樹(搜尋/CRUD/拖曳分類)
│ ├── StatusView.cs # Status 檢視:分頁式(PDU…),PDU 連線後每 3 秒背景輪詢插座狀態,並可用表格內 Control 鈕直接開關各 Port
│ ├── SettingsView.cs # Settings 檢視:分頁式(Terminal / AI MCP
│ ├── AboutView.cs # About 檢視(版本 / 連結)
│ ├── Dialogs/ # ── 深色對話框 ── │ ├── Dialogs/ # ── 深色對話框 ──
│ │ ├── DarkDialog.cs # 對話框基底(深色 + DWM 標題列) │ │ ├── DarkDialog.cs # 對話框基底(深色 + DWM 標題列)
│ │ ├── TextPromptDialog.cs # 單行輸入(資料夾命名 / 改名) │ │ ├── TextPromptDialog.cs # 單行輸入(資料夾命名 / 改名)
@@ -131,7 +135,8 @@ ETTerms/
├── Terminal/ # ── 終端機渲染 ── ├── Terminal/ # ── 終端機渲染 ──
│ ├── TerminalView.cs # 自繪 VT100 控制項 (owner-drawn) │ ├── TerminalView.cs # 自繪 VT100 控制項 (owner-drawn)
│ ├── AnsiParser.cs # ANSI/VT100 escape 解析狀態機 │ ├── DarkScrollBar.cs # 自繪深色垂直捲軸 (細長/圓角滑塊, 配深色主題)
│ ├── AnsiParser.cs # ANSI/VT100 escape 解析狀態機 (含 DEC 2004 bracketed paste)
│ ├── ScreenBuffer.cs # 字格緩衝 (rows×cols, 屬性/顏色) │ ├── ScreenBuffer.cs # 字格緩衝 (rows×cols, 屬性/顏色)
│ └── TerminalInput.cs # 鍵盤 → byte 序列 (含特殊鍵) │ └── TerminalInput.cs # 鍵盤 → byte 序列 (含特殊鍵)
@@ -139,7 +144,7 @@ ETTerms/
│ ├── ISessionChannel.cs # Write(byte[]) + event DataReceived │ ├── ISessionChannel.cs # Write(byte[]) + event DataReceived
│ ├── SshChannel.cs # SSH.NET 實作 (ShellStream) │ ├── SshChannel.cs # SSH.NET 實作 (ShellStream)
│ ├── SerialChannel.cs # System.IO.Ports 實作 │ ├── SerialChannel.cs # System.IO.Ports 實作
│ ├── ShellChannel.cs # Windows ConPTY 本機 Shell │ ├── ShellChannel.cs # Windows ConPTY 本機 ShellStartupDirectory 不存在時 fallback 使用者家目錄)
│ ├── SessionPage.cs # 一個分頁 = TerminalView + Channel + 狀態 │ ├── SessionPage.cs # 一個分頁 = TerminalView + Channel + 狀態
│ ├── SessionManager.cs # 開 / 關 / 列舉所有 active session │ ├── SessionManager.cs # 開 / 關 / 列舉所有 active session
│ ├── SerialBridgeServer.cs# ✅ 本機 named pipe server:把 serial session 的讀寫橋接給 MCPPhase 9 │ ├── SerialBridgeServer.cs# ✅ 本機 named pipe server:把 serial session 的讀寫橋接給 MCPPhase 9
@@ -157,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)
@@ -295,6 +305,9 @@ ETTerms 是單視窗多分頁,沒有「路由」,以下以**功能面板**
- 接收 channel bytes → `AnsiParser``ScreenBuffer` → 繪製 - 接收 channel bytes → `AnsiParser``ScreenBuffer` → 繪製
- 鍵盤輸入 → `TerminalInput` → byte 序列 → channel - 鍵盤輸入 → `TerminalInput` → byte 序列 → channel
- 支援:scrollback、選取 / 複製、貼上、字型 / 配色(從 `TerminalProfile` - 支援:scrollback、選取 / 複製、貼上、字型 / 配色(從 `TerminalProfile`
- **捲動:** 滑鼠滾輪或右側自繪深色捲軸 `DarkScrollBar`(拖曳滑塊 / 點軌道翻頁);`ContentWidth` 扣掉捲軸寬避免文字被蓋,捲軸範圍 / 位置在 `Feed` / 滾輪 / resize 時同步
- **貼上:** 對方啟用 bracketed pasteDEC mode 2004,如 PSReadLine / Kiro CLI)時,整段以 `ESC[200~ … ESC[201~` 包夾送出,避免多行貼上被逐行 Enter 立即送出;未啟用則逐字送出
- **複製:** 右鍵複製選取內容後自動清除反白(提示已複製)
### Script Editor / RunnerScripts 檢視) ### Script Editor / RunnerScripts 檢視)
- 載入 / 編輯 `.ttl` 腳本(語法沿用 MyTeraTerm - 載入 / 編輯 `.ttl` 腳本(語法沿用 MyTeraTerm
@@ -356,7 +369,16 @@ ScriptRunner.RunAsync(scriptText, activeChannel)
--- ---
## AI / MCP IntegrationSerial MCP Server ## AI / MCP IntegrationSerial MCP + PDU MCP Server
ETTerms 提供**兩個獨立的 stdio MCP server**給 AI agentKiro CLI / Claude CLI):
- **`ETTerms.SerialMcp`** — 收發 serial。COM port 獨佔,故由 GUI 唯一持有、MCP 經本機 named pipe 橋接(見下方)。
- **`ETTerms.PduMcp`** — 控制 SNMP PDU 電源插座。SNMP(UDP) 非獨佔,故 MCP **直接打 SNMP**,不需 GUI 在跑、也不經 pipe。
兩者都能用 GUI **Settings → AI MCP** 一鍵 Setup`McpRegistrar` 會同時註冊 `etterms-serial``etterms-pdu`)。
### Serial MCP Server
> 讓 **Kiro CLI / Claude CLI** 等 AI agent 收發 serial**且使用者能在 ETTerms GUI 即時看到 AI 的每筆收發**。 > 讓 **Kiro CLI / Claude CLI** 等 AI agent 收發 serial**且使用者能在 ETTerms GUI 即時看到 AI 的每筆收發**。
> >
@@ -425,6 +447,68 @@ kiro-cli mcp add --name serial --command dotnet `
或寫進 agent.json 的 `mcpServers`Claude CLI 則用其對應的 `mcpServers` 設定。**使用前提:先在 ETTerms GUI 開好要操作的 serial 連線**,AI 才能 `serial_attach` 上去。註冊後即可對 AI 說「列出目前 serial session → 接上 COM3 → 送指令看回應」。 或寫進 agent.json 的 `mcpServers`Claude CLI 則用其對應的 `mcpServers` 設定。**使用前提:先在 ETTerms GUI 開好要操作的 serial 連線**,AI 才能 `serial_attach` 上去。註冊後即可對 AI 說「列出目前 serial session → 接上 COM3 → 送指令看回應」。
### PDU MCP Serverv0.3.0
> 讓 AI agent 直接控制 SNMP PDU 的電源插座,**典型用途:測試中自動 power-cycle DUT**。
**關鍵設計:直接打 SNMP,不經 GUI 橋接。** 與 serial 不同,PDU 走 SNMPUDP)**非獨佔**——多個行程可同時對同一台 PDU 下命令。因此 `ETTerms.PduMcp` 不需要像 serial 那樣繞 GUI 的 named pipe,而是內含一份精簡版 `PduController`OID 邏輯複製自 GUI 的 `Scripting/Pdu/PduController`,診斷改走 stderr 以免污染 stdio JSON-RPC)直接與 PDU 對話。**好處:GUI 不必開著,AI 也能控制 PDU;最少程式碼、最穩。**
```
Kiro/Claude CLI ── 啟動子行程 ETTerms.PduMcpstdio / JSON-RPC
└─ SnmpSharpNet ──(SNMP/UDP 161)──► PDUiPoMan II/III
```
連線狀態(device IP → controller)以行程內單例 `PduRegistry` 保存,跨工具呼叫保留,直到 `pdu_disconnect` 或行程結束。
**暴露的工具:**
| 工具 | 參數 | 說明 |
|------|------|------|
| `pdu_connect` | ip | 以 SNMP 連線並驗證 PDU 回應,成功回傳 model name;控制前必須先呼叫 |
| `pdu_list` | — | 列出本 session 已連線的 PDU(依 IP |
| `pdu_set_port` | ip, port, on | 將某插座開(on=true)/關(off=false) |
| `pdu_get_port` | ip, port | 讀單一插座的狀態 / 電流(mA) / 功率(W) |
| `pdu_status` | ip | 讀全部 12 個插座的狀態 / 電流 / 功率 |
| `pdu_power_cycle` | ip, port, offSeconds? | 關 → 等 offSeconds → 開(重啟 DUT |
| `pdu_disconnect` | ip | 解除本 session 的 PDU 連線(不改變插座狀態) |
> 所有工具回傳統一的 `{ "ok": bool, "result"/"error": ... }` JSON。SNMP community 目前沿用 GUI 版的 `"private"`。
### 內建 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
@@ -439,6 +523,7 @@ kiro-cli mcp add --name serial --command dotnet `
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。
--- ---
@@ -447,6 +532,7 @@ kiro-cli mcp add --name serial --command dotnet `
- **密碼儲存:** 一律使用 **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 傳入。
@@ -504,17 +590,20 @@ dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained
**內容結構:** **內容結構:**
``` ```
ETTerms_v0.2.0\ ETTerms_v0.3.0\
├── ETTerms v0.2.0.exe # 主程式 apphost,改名為「ETTerms v{Version}.exe」 ├── ETTerms v0.3.0.exe # 主程式 apphost,改名為「ETTerms v{Version}.exe」
├── ETTerms.dll + 各相依 dll # SSH.NET / SQLite / SnmpSharpNet / System.IO.Ports … ├── ETTerms.dll + 各相依 dll # SSH.NET / SQLite / SnmpSharpNet / System.IO.Ports …
── ETTerms.SerialMcp\ # Serial MCP server,獨立發佈到子資料夾(相依 dll 與 GUI 隔離) ── ETTerms.SerialMcp\ # Serial MCP server,獨立發佈到子資料夾(相依 dll 與 GUI 隔離)
├── ETTerms.SerialMcp.exe ├── ETTerms.SerialMcp.exe
└── ETTerms.SerialMcp.dll + 相依 └── ETTerms.SerialMcp.dll + 相依
└── ETTerms.PduMcp\ # PDU MCP serverv0.3.0),同樣獨立發佈到子資料夾
├── ETTerms.PduMcp.exe
└── ETTerms.PduMcp.dll + 相依(含 SnmpSharpNet
``` ```
**規則:** **規則:**
1. **GUI publish 會自動帶上 MCP**`ETTerms.csproj``PublishSerialMcp` target`AfterTargets="Publish"`),會把 `ETTerms.SerialMcp` 一併發佈到 `<publish>\ETTerms.SerialMcp\` **子資料夾**(與 GUI 相依 dll 隔離)。因此**只要發佈 GUI 一個指令**即可,不必再單獨發 MCP。 1. **GUI publish 會自動帶上兩個 MCP server**`ETTerms.csproj``PublishMcpServers` target`AfterTargets="Publish"`),會把 `ETTerms.SerialMcp``ETTerms.PduMcp` 一併發佈到各自的 `<publish>\<server>\` **子資料夾**(與 GUI 相依 dll 隔離)。因此**只要發佈 GUI 一個指令**即可,不必再單獨發 MCP。
- 對齊 `McpRegistrar.ResolveServerExe()`:它解析的 `<ETTerms.exe>\ETTerms.SerialMcp\ETTerms.SerialMcp.exe` 因此**必定存在**,AI MCP 一鍵設定寫進去的路徑才不會落空。 - 對齊 `McpRegistrar.ResolveServerExe()`:它解析的 `<ETTerms.exe>\<server>\<server>.exe` 因此**必定存在**,AI MCP 一鍵設定寫進去的路徑才不會落空。
- MCP 子發佈會**跟隨 GUI 的 `SelfContained` 設定**target 內以 `$(SelfContained)` 傳入):框架相依版的 MCP 也框架相依;portable 版的 MCP 也免 runtime。 - MCP 子發佈會**跟隨 GUI 的 `SelfContained` 設定**target 內以 `$(SelfContained)` 傳入):框架相依版的 MCP 也框架相依;portable 版的 MCP 也免 runtime。
2. **主 exe 改名**`dotnet publish` 產生的 `ETTerms.exe` 重新命名為 **`ETTerms v{Version}.exe`**。 2. **主 exe 改名**`dotnet publish` 產生的 `ETTerms.exe` 重新命名為 **`ETTerms v{Version}.exe`**。
- 可安全改名:.NET apphost 內部記錄要載入的 `ETTerms.dll`,**不靠自身檔名**,改名後仍正常啟動。 - 可安全改名:.NET apphost 內部記錄要載入的 `ETTerms.dll`,**不靠自身檔名**,改名後仍正常啟動。
@@ -545,7 +634,7 @@ dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained
Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe" Rename-Item (Join-Path $proot "ETTerms.exe") "ETTerms v$ver.exe"
``` ```
> 兩版的 `ETTerms.SerialMcp\` 子資料夾都由 `PublishSerialMcp` target 自動產生;portable 版的 MCP 也是 self-contained,故 AI MCP 功能在無 runtime 環境同樣可用。 > 兩版的 `ETTerms.SerialMcp\` 與 `ETTerms.PduMcp\` 子資料夾都由 `PublishMcpServers` target 自動產生;portable 版的 MCP 也是 self-contained,故 AI MCP 功能在無 runtime 環境同樣可用。
--- ---
@@ -658,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
@@ -666,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)
+29 -7
View File
@@ -8,9 +8,25 @@ 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.2.1** 新增 GUI **Settings → AI MCP** 分頁(`McpRegistrar`):對 Claude Code`~/.claude.json`)與 Kiro`~/.kiro/settings/mcp.json`**一鍵 Setup / Remove** 註冊 `etterms-serial` MCP serverread-modify-write 保留檔內其他設定、原子寫回;卡片附 CLI 驗證指令。`ETTerms.csproj``PublishSerialMcp` target`AfterTargets=Publish`),GUI publish 會自動把 `ETTerms.SerialMcp` 帶到 `\ETTerms.SerialMcp\` 子資料夾,與 `McpRegistrar.ResolveServerExe()` 解析路徑對齊 **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.1** 終端機體驗修正(皆在 `src/ETTerms/Terminal/`)。**(1) 深色垂直捲軸** — 新增自繪 `DarkScrollBar`(細長、無箭頭、圓角滑塊,配合 KKTerm 深色主題),`TerminalView` 右側 `Dock=Right` 掛上,`ContentWidth` 扣掉捲軸寬避免文字被蓋,`UpdateScrollBar()``Feed` / 滾輪 / resize 時同步滑塊範圍與位置,滾輪與拖曳互通。**(2) 多行貼上修正** — `AnsiParser` 新增 `BracketedPaste`DEC mode 2004);`TerminalView.Paste()` 在對方啟用 bracketed pastePSReadLine / Kiro CLI 等)時以 `ESC[200~ … ESC[201~` 包夾整段,視為「單次貼上」而非逐行 Enter 立即送出;未啟用時退回原本逐字送出。**(3) 右鍵複製清反白** — 右鍵複製後清掉 `_hasSel` 並重繪,讓使用者知道已複製。
**v0.3.0** 新增 **`ETTerms.PduMcp`**stdio MCP server):讓 AI agent 直接控制 SNMP PDU 插座。與 serial 不同,PDU 走 SNMP(UDP) **非獨佔**,故 PduMcp **直接打 SNMP、不經 GUI 橋接**(內含精簡版 `PduController`OID 邏輯複製自 GUI 版,log 走 stderr),GUI 不開著也能用。工具:`pdu_connect` / `pdu_list` / `pdu_set_port` / `pdu_get_port` / `pdu_status` / `pdu_power_cycle` / `pdu_disconnect`,回傳統一 `{ok, result/error}` JSON;連線狀態以行程內單例 `PduRegistry`IP→controller)保存。`McpRegistrar` 改為多 server**Settings → AI MCP 一鍵同時註冊 `etterms-serial``etterms-pdu`**`ETTerms.csproj` 的 publish target 更名 `PublishMcpServers`GUI publish 會把兩個 MCP 各自帶到 `\ETTerms.SerialMcp\``\ETTerms.PduMcp\` 子資料夾。
**v0.2.1** 新增 GUI **Settings → AI MCP** 分頁(`McpRegistrar`):對 Claude Code`~/.claude.json`)與 Kiro`~/.kiro/settings/mcp.json`**一鍵 Setup / Remove** 註冊 `etterms-serial` MCP serverread-modify-write 保留檔內其他設定、原子寫回;卡片附 CLI 驗證指令。`ETTerms.csproj` 加 publish target`AfterTargets=Publish`),GUI publish 會自動把 MCP server 帶到子資料夾,與 `McpRegistrar.ResolveServerExe()` 解析路徑對齊。
**v0.2.0** Phase 9 完成 — `ETTerms.SerialMcp`stdio MCP server+ GUI `SerialBridgeServer`named pipe `\\.\pipe\etterms-serial`)上線,提供 `serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach` 五個工具,AI 的 TX 在 GUI 以 `[AI]` 標色即時 echo;視窗 / 工作列 / About 改用 Choco 圖示,標題列顯示版本號。見 [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md)。 **v0.2.0** Phase 9 完成 — `ETTerms.SerialMcp`stdio MCP server+ GUI `SerialBridgeServer`named pipe `\\.\pipe\etterms-serial`)上線,提供 `serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach` 五個工具,AI 的 TX 在 GUI 以 `[AI]` 標色即時 echo;視窗 / 工作列 / About 改用 Choco 圖示,標題列顯示版本號。見 [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md)。
@@ -27,7 +43,8 @@ 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 / MCP(選用):** stdio MCP server`ETTerms.SerialMcp`,官方 C# SDK `ModelContextProtocol`)。**不自己開 COM port**,而是經本機 named pipe 接上 GUI 持有的 serial session,把 serial 收發暴露給 Kiro CLI / Claude CLIAI 的 TX/RX 同步顯示在 GUI - **內建 AI 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
- **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json` - **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json`
## 常用指令 ## 常用指令
@@ -41,8 +58,8 @@ dotnet run --project src\ETTerms\ETTerms.csproj
dotnet add src\ETTerms package SSH.NET dotnet add src\ETTerms package SSH.NET
# 打包(見「Publish / 打包慣例」)—— 兩種版本都產出,輸出到 src\ETTerms\Publish\ # 打包(見「Publish / 打包慣例」)—— 兩種版本都產出,輸出到 src\ETTerms\Publish\
# GUI publish 會「自動」把 ETTerms.SerialMcp 一併發到 \ETTerms.SerialMcp\ 子資料夾 # GUI publish 會「自動」把 ETTerms.SerialMcp ETTerms.PduMcp 一併發到各自的子資料夾
# ETTerms.csproj 的 PublishSerialMcp targetAfterTargets=Publish),且 MCP 跟隨 GUI 的 self-contained 設定。 # ETTerms.csproj 的 PublishMcpServers targetAfterTargets=Publish),且 MCP 跟隨 GUI 的 self-contained 設定。
$ver = ([regex]::Match((Get-Content src\ETTerms\ETTerms.csproj -Raw), '<Version>([^<]+)</Version>')).Groups[1].Value $ver = ([regex]::Match((Get-Content src\ETTerms\ETTerms.csproj -Raw), '<Version>([^<]+)</Version>')).Groups[1].Value
# A. 框架相依版(需目標機已裝 .NET 8 Desktop Runtime)→ ETTerms_v{Version}\ # A. 框架相依版(需目標機已裝 .NET 8 Desktop Runtime)→ ETTerms_v{Version}\
@@ -64,7 +81,7 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm
## 開發慣例 ## 開發慣例
- **命名:** PascalCase 類別 / 方法,`_camelCase` 私有欄位;檔名 = 類別名。 - **命名:** PascalCase 類別 / 方法,`_camelCase` 私有欄位;檔名 = 類別名。
- **Publish / 打包:** 輸出到 `src\ETTerms\Publish\`;主 exe 改名為 `ETTerms v{Version}.exe``ETTerms.SerialMcp` 一併發到其下 `ETTerms.SerialMcp\` 子資料夾(且跟隨 GUI 的 self-contained 設定)。**兩種版本都產出**:框架相依 `ETTerms_v{Version}\``--self-contained false`,需裝 .NET 8 Desktop Runtime)+ portable 免安裝 `ETTerms_v{Version}_portable\``--self-contained true`runtime 內含)。不要開 trimmingWinForms 反射)。詳見 [ARCHITECTURE.md](ARCHITECTURE.md#publish--打包慣例)。 - **Publish / 打包:** 輸出到 `src\ETTerms\Publish\`;主 exe 改名為 `ETTerms v{Version}.exe``ETTerms.SerialMcp``ETTerms.PduMcp` 一併發到其下 `ETTerms.SerialMcp\``ETTerms.PduMcp\` 子資料夾(且跟隨 GUI 的 self-contained 設定)。**兩種版本都產出**:框架相依 `ETTerms_v{Version}\``--self-contained false`,需裝 .NET 8 Desktop Runtime)+ portable 免安裝 `ETTerms_v{Version}_portable\``--self-contained true`runtime 內含)。不要開 trimmingWinForms 反射)。詳見 [ARCHITECTURE.md](ARCHITECTURE.md#publish--打包慣例)。
- **分層:** UI`App/`)只認 `ISessionChannel` 抽象,不直接相依 SSH.NET / SerialPort。 - **分層:** UI`App/`)只認 `ISessionChannel` 抽象,不直接相依 SSH.NET / SerialPort。
- **執行緒:** channel I/O 在背景;所有 UI 更新一律 `Control.Invoke` 回 UI thread。 - **執行緒:** channel I/O 在背景;所有 UI 更新一律 `Control.Invoke` 回 UI thread。
- **commit** 走 Conventional Commits`feat:` / `fix:` / `refactor:` …)。 - **commit** 走 Conventional Commits`feat:` / `fix:` / `refactor:` …)。
@@ -73,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 開啟,開啟前檢查可用性。
@@ -80,10 +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**,不經 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。
+2
View File
@@ -2,5 +2,7 @@
<Folder Name="/src/"> <Folder Name="/src/">
<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.PduCore/ETTerms.PduCore.csproj" />
</Folder> </Folder>
</Solution> </Solution>
+35 -4
View File
@@ -4,7 +4,7 @@
> A native Windows terminal workspace (C# .NET 8 WinForms) — **SSH**, **Serial Port**, and **local Shell (ConPTY)** in one window, with a **TTL scripting engine** ported from MyTeraTerm for automation, plus an optional **Serial MCP server** that lets AI agents (Kiro CLI / Claude CLI) drive the serial port directly. Standalone, no cloud, no login. > A native Windows terminal workspace (C# .NET 8 WinForms) — **SSH**, **Serial Port**, and **local Shell (ConPTY)** in one window, with a **TTL scripting engine** ported from MyTeraTerm for automation, plus an optional **Serial MCP server** that lets AI agents (Kiro CLI / Claude CLI) drive the serial port directly. Standalone, no cloud, no login.
![version](https://img.shields.io/badge/version-0.2.1-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-beta-yellow.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg) ![version](https://img.shields.io/badge/version-0.3.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)
--- ---
@@ -63,6 +63,7 @@
-**PDU power control (optional)** -**PDU power control (optional)**
- Control PDU outlets over SNMP (`SnmpSharpNet`) to power-cycle devices during tests - Control PDU outlets over SNMP (`SnmpSharpNet`) to power-cycle devices during tests
- **Status → PDU** tab: connect to a PDU, auto-poll outlet state every 3 s, and toggle any port on/off with per-row **Control** buttons
- Script commands: `pduconnect` / `pductrl` - Script commands: `pduconnect` / `pductrl`
- 🤖 **AI / MCP integration (optional)** - 🤖 **AI / MCP integration (optional)**
@@ -77,9 +78,10 @@
### Terminal Rendering ### Terminal Rendering
- 🎨 Owner-drawn VT100 / ANSI control, double-buffered cell grid - 🎨 Owner-drawn VT100 / ANSI control, double-buffered cell grid
- 🌑 KKTerm-style dark theme (incl. DWM dark title bar) - 🌑 KKTerm-style dark theme (incl. DWM dark title bar + dark terminal scrollbar)
- 🔤 Configurable font / size / palette / scrollback - 🔤 Configurable font / size / palette / scrollback
- 📋 Select / copy / paste - 🖱️ Scroll back through long output with the mouse wheel or the dark scrollbar
- 📋 Select / copy / paste — right-click copy clears the highlight; multi-line paste uses **bracketed paste** so it isn't submitted line-by-line
--- ---
@@ -402,6 +404,35 @@ 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
- **Status → PDU tab** — a dedicated `Status` rail view hosts the PDU panel: connect by IP, auto-poll all 12 outlets every 3 s (background thread, no manual Refresh), with live current / power readouts
- **Manual outlet control buttons** — each row has a **Control** button that toggles that port on/off over SNMP; the label tracks state (ON → "Turn OFF", OFF → "Turn ON"), the SNMP set runs off the UI thread, and the grid refreshes after the command
### v0.3.1
- **Terminal usability fixes** — added a **dark vertical scrollbar** (drag or click-to-page through long output instead of long mouse-wheel scrolling)
- **Multi-line paste** into Kiro CLI / PSReadLine now pastes as a single block (**bracketed paste**, DEC mode 2004) instead of submitting each line immediately
- Right-click copy now **clears the highlight** so you know it worked
### v0.3.0
- **AI-driven PDU power control (MCP)** — new `ETTerms.PduMcp` server lets AI agents (Kiro CLI / Claude CLI) control an SNMP PDU directly: outlets on/off, status, and power-cycle a DUT during automated tests
- PDU runs over SNMP (UDP, non-exclusive), so the AI talks to it directly — the ETTerms GUI does not need to be running
- Tools: `pdu_connect` / `pdu_list` / `pdu_set_port` / `pdu_get_port` / `pdu_status` / `pdu_power_cycle` / `pdu_disconnect`
- **Settings → AI MCP** now registers both `etterms-serial` and `etterms-pdu` with one click; publishing the GUI auto-bundles both MCP servers
### v0.2.2
- **Terminal stability** — the terminal no longer freezes after minimizing or switching tabs (most noticeable with full-screen TUIs like Kiro CLI in PowerShell); a degenerate 1×1 size is no longer sent to the pseudo-console
- **Shift+Enter** inserts a newline in the shell, so you can type multi-line commands (plain Enter still submits)
- **High-DPI fixes** — text and buttons no longer clipped at 125% / 150% scaling (PerMonitorV2); toolbar / settings / sidebar / About now scale adaptively
### v0.2.1 ### v0.2.1
- **AI MCP one-click setup** — new **Settings → AI MCP** tab registers the Serial MCP server into Claude Code (`~/.claude.json`) or Kiro (`~/.kiro/settings/mcp.json`) with a single button; existing MCP servers are preserved, and each card shows the verify command - **AI MCP one-click setup** — new **Settings → AI MCP** tab registers the Serial MCP server into Claude Code (`~/.claude.json`) or Kiro (`~/.kiro/settings/mcp.json`) with a single button; existing MCP servers are preserved, and each card shows the verify command
@@ -432,7 +463,7 @@ Licensed under the **MIT License** — see [LICENSE](LICENSE).
## 🙏 Acknowledgments ## 🙏 Acknowledgments
- **[KKTerm](https://github.com/)** — UI design reference (Activity Rail + tabbed workspace + Saved Connections) - **[KKTerm](https://github.com/ryantsai/KKTerm)** — by ryantsai (MIT) — UI design reference (Activity Rail + tabbed workspace + Saved Connections)
- **MyTeraTerm** — source of the TTL scripting engine and `AppLogger` - **MyTeraTerm** — source of the TTL scripting engine and `AppLogger`
- **[SSH.NET](https://github.com/sshnet/SSH.NET)**, **[SnmpSharpNet](http://www.snmpsharpnet.com/)** — open-source connectivity / SNMP libraries - **[SSH.NET](https://github.com/sshnet/SSH.NET)**, **[SnmpSharpNet](http://www.snmpsharpnet.com/)** — open-source connectivity / SNMP libraries
- **Microsoft** — .NET 8, WinForms, ConPTY, Credential Manager - **Microsoft** — .NET 8, WinForms, ConPTY, Credential Manager
+35 -4
View File
@@ -4,7 +4,7 @@
> 原生 Windows 終端機工作台(C# .NET 8 WinForms)—— 一個視窗整合 **SSH**、**Serial Port**、**本機 Shell (ConPTY)** 連線,內建從 MyTeraTerm 移植的 **TTL 腳本引擎**做自動化,並提供選用的 **Serial MCP server**,讓 AI agentKiro CLI / Claude CLI)直接操作 serial port。單機、無雲、無登入系統。 > 原生 Windows 終端機工作台(C# .NET 8 WinForms)—— 一個視窗整合 **SSH**、**Serial Port**、**本機 Shell (ConPTY)** 連線,內建從 MyTeraTerm 移植的 **TTL 腳本引擎**做自動化,並提供選用的 **Serial MCP server**,讓 AI agentKiro CLI / Claude CLI)直接操作 serial port。單機、無雲、無登入系統。
![version](https://img.shields.io/badge/version-0.2.1-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-beta-yellow.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg) ![version](https://img.shields.io/badge/version-0.3.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)
--- ---
@@ -63,6 +63,7 @@
-**PDU 電源控制(選用)** -**PDU 電源控制(選用)**
- 透過 SNMP`SnmpSharpNet`)控制 PDU 插座,測試中遠端電源循環 - 透過 SNMP`SnmpSharpNet`)控制 PDU 插座,測試中遠端電源循環
- **Status → PDU** 分頁:連線 PDU 後每 3 秒自動輪詢插座狀態,並可用各列的 **Control** 鈕直接開 / 關該 Port
- 腳本指令:`pduconnect` / `pductrl` - 腳本指令:`pduconnect` / `pductrl`
- 🤖 **AI / MCP 整合(選用)** - 🤖 **AI / MCP 整合(選用)**
@@ -77,9 +78,10 @@
### 終端機渲染 ### 終端機渲染
- 🎨 自繪 VT100 / ANSI 控制項(owner-drawn),雙緩衝繪字格 - 🎨 自繪 VT100 / ANSI 控制項(owner-drawn),雙緩衝繪字格
- 🌑 KKTerm 風格深色主題(含 DWM 深色標題列) - 🌑 KKTerm 風格深色主題(含 DWM 深色標題列 + 深色終端機捲軸
- 🔤 可調字型 / 字級 / 配色 / scrollback 行數 - 🔤 可調字型 / 字級 / 配色 / scrollback 行數
- 📋 選取 / 複製 / 貼上 - 🖱️ 用滑鼠滾輪或深色捲軸往回捲長輸出
- 📋 選取 / 複製 / 貼上 —— 右鍵複製會清掉反白;多行貼上採 **bracketed paste**,不會被逐行送出
--- ---
@@ -402,6 +404,35 @@ ETTerms/
## 📜 版本紀錄 ## 📜 版本紀錄
### v0.3.3
- **終端機輸入修正** —— 終端機印過輸出後(Serial 或 PowerShell),切走再切回(縮小視窗、或點其他程式)可能導致無法打字或按 **Enter**,只能重開 session;閒置時則不受影響
- 原因:輸出累積出 scrollback 後,視窗重新取得焦點時終端機捲軸可能搶走鍵盤焦點。捲軸已改為純滑鼠操作、永不吃焦點,無論印出多少都能正常輸入
### v0.3.2
- **Status → PDU 分頁** —— 新增 `Status` rail 檢視,PDU 面板移到此處:輸入 IP 連線後每 3 秒自動輪詢全部 12 個插座(背景執行緒,免手動 Refresh),即時顯示電流 / 功率
- **手動插座開關鈕** —— 每列新增 **Control** 鈕,按一下即透過 SNMP 切換該 Port 開 / 關;按鈕文字隨狀態變動(ON →「Turn OFF」、OFF →「Turn ON」),SNMP 設定在 UI 執行緒外執行,下命令後自動回讀刷新表格
### v0.3.1
- **終端機體驗修正** —— 新增**深色垂直捲軸**(拖曳滑塊或點軌道翻頁,不必狂滾滑鼠看長輸出)
- 貼多行到 Kiro CLI / PSReadLine 現在會**整段一次貼上****bracketed paste**DEC mode 2004),不再逐行立即送出
- 右鍵複製後會**清掉反白**,讓你知道已複製
### v0.3.0
- **AI 控制 PDU 電源(MCP** —— 新增 `ETTerms.PduMcp` server,讓 AI agentKiro CLI / Claude CLI)直接控制 SNMP PDU:插座開 / 關、讀狀態、自動化測試時 power-cycle 一台 DUT
- PDU 走 SNMPUDP,非獨佔),AI 直接打 PDU —— ETTerms GUI 不需開著
- 工具:`pdu_connect` / `pdu_list` / `pdu_set_port` / `pdu_get_port` / `pdu_status` / `pdu_power_cycle` / `pdu_disconnect`
- **設定 → AI MCP** 一鍵同時註冊 `etterms-serial``etterms-pdu`publish 會自動把兩個 MCP server 一併打包
### v0.2.2
- **終端機穩定性** —— 視窗最小化或切換分頁後終端機不再卡住(跑全螢幕 TUI 如 PowerShell 裡的 Kiro CLI 最明顯);不再把退化的 1×1 尺寸送給 pseudo-console
- **Shift+Enter** 在 shell 插入換行,可輸入多行指令(單純 Enter 仍是送出)
- **高 DPI 修正** —— 125% / 150% 縮放下文字與按鈕不再被裁切(PerMonitorV2);toolbar / 設定 / 側欄 / About 改為自適應縮放
### v0.2.1 ### v0.2.1
- **AI MCP 一鍵設定** —— 新增 **設定 → AI MCP** 分頁,一個按鈕就把 Serial MCP server 註冊進 Claude Code`~/.claude.json`)或 Kiro`~/.kiro/settings/mcp.json`);保留檔內其他既有 MCP server,卡片並顯示驗證指令 - **AI MCP 一鍵設定** —— 新增 **設定 → AI MCP** 分頁,一個按鈕就把 Serial MCP server 註冊進 Claude Code`~/.claude.json`)或 Kiro`~/.kiro/settings/mcp.json`);保留檔內其他既有 MCP server,卡片並顯示驗證指令
@@ -432,7 +463,7 @@ ETTerms/
## 🙏 致謝 ## 🙏 致謝
- **[KKTerm](https://github.com/)** — UI 設計參考(Activity Rail + 分頁工作區 + Saved Connections - **[KKTerm](https://github.com/ryantsai/KKTerm)** — by ryantsaiMITUI 設計參考(Activity Rail + 分頁工作區 + Saved Connections
- **MyTeraTerm** — TTL 腳本引擎與 `AppLogger` 來源 - **MyTeraTerm** — TTL 腳本引擎與 `AppLogger` 來源
- **[SSH.NET](https://github.com/sshnet/SSH.NET)**、**[SnmpSharpNet](http://www.snmpsharpnet.com/)** — 開源連線 / SNMP 函式庫 - **[SSH.NET](https://github.com/sshnet/SSH.NET)**、**[SnmpSharpNet](http://www.snmpsharpnet.com/)** — 開源連線 / SNMP 函式庫
- **Microsoft** — .NET 8、WinForms、ConPTY、Credential Manager - **Microsoft** — .NET 8、WinForms、ConPTY、Credential Manager
+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)
+59
View File
@@ -0,0 +1,59 @@
# v0.7.1 — Scripts stop losing commands to a booting device
## ✨ New features
**`sendlnretry` — send a command, confirm it ran, resend if it didn't**
* New TTL command: `sendlnretry '<text>' '<confirm keyword>' [max attempts]`.
It sends the text, then **waits for proof the device actually ran it**, and **resends** if that
proof never arrives. Leave the attempt count out and it retries until it gets through.
* Drop it in wherever you send a command right after a boot — the fragile
`pause` + `sendln` + `wait` dance becomes one line:
```
wait 'root@(none):/#'
sendlnretry 'tpm2' 'TPM 2p0' ; retry until the device really runs it
wait 'PASS'
```
* Cap the attempts and handle the failure yourself — on success `result` is **1**;
when the attempts run out `result` is **0** and **the script carries on** instead of dying:
```
sendlnretry 'tpm2' 'TPM 2p0' 3
if result = 0 then
dispstr 'tpm2 got no response after 3 tries'
endif
```
* Pick the confirm keyword from the command's **own output** (`TPM 2p0`), not its echo (`tpm2`) —
an echo is produced by the device's tty and doesn't prove the shell ever read the line.
* Each attempt waits **3 seconds** by default, or your `timeout` / `mtimeout` if you've set one.
(Unlike `wait`, `0` doesn't mean "wait forever" here — that would mean never retrying.)
## 🐛 Bug fixes
**Scripts no longer hang forever when a booting device swallows a command**
* Fixed: 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 the 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 one overnight 45-cycle power-cycle run, **3 cycles hung this
way** — and the swallowed sends landed at exactly the same moment as the 42 that worked.
* Use the new `sendlnretry` above to make these sends reliable.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`ETTerms_v0.7.1`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`ETTerms_v0.7.1_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `ETTerms v0.7.1.exe`.
## 🔗 Links
* TTL reference (see **送出並確認 / sendlnretry**):
[docs/ttl-script-reference.md](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
* Architecture:
[ARCHITECTURE.md](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)
+271 -107
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() { }
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ETTerms.PduMcp</RootNamespace>
<AssemblyName>ETTerms.PduMcp</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ETTerms.PduCore\ETTerms.PduCore.csproj" />
</ItemGroup>
</Project>
+35
View File
@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using ETTerms.PduCore;
namespace ETTerms.PduMcp;
/// <summary>
/// Process-wide registry of connected PDUs, keyed by IP. The MCP server is long-lived,
/// so connections established by <c>pdu_connect</c> persist across tool calls.
/// </summary>
public sealed class PduRegistry
{
public static readonly PduRegistry Instance = new();
/// <summary>iPoMan II/III outlet count.</summary>
public const int PortCount = 12;
private readonly ConcurrentDictionary<string, PduController> _pdus = new(StringComparer.OrdinalIgnoreCase);
public PduController GetOrAdd(string ip) =>
_pdus.GetOrAdd(ip.Trim(), static k => new PduController(k, 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) =>
_pdus.TryGetValue(ip.Trim(), out pdu!);
public bool Remove(string ip)
{
if (_pdus.TryRemove(ip.Trim(), out var pdu)) { pdu.Dispose(); return true; }
return false;
}
public IReadOnlyCollection<string> ConnectedIps => _pdus.Keys.ToArray();
}
+144
View File
@@ -0,0 +1,144 @@
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using ETTerms.PduCore;
using ModelContextProtocol.Server;
namespace ETTerms.PduMcp;
/// <summary>
/// MCP tools exposed to the AI for controlling an SNMP PDU (iPoMan II/III).
///
/// Unlike the serial bridge, the PDU is reached directly over SNMP — the ETTerms GUI
/// does NOT need to be running. Connections are held per-IP and persist for the lifetime
/// of this MCP server. Always <c>pdu_connect</c> an IP first, then control its ports.
/// </summary>
[McpServerToolType]
public static class PduTools
{
private static readonly JsonSerializerOptions Json = new() { WriteIndented = false };
[McpServerTool, Description("Connect to a PDU over SNMP by IP and verify it responds. Required before controlling ports. Returns the model name on success.")]
public static Task<string> pdu_connect(
[Description("PDU IP address, e.g. 192.168.1.21")] string ip)
{
ip = (ip ?? "").Trim();
if (ip.Length == 0) return Err("ip is required");
var pdu = PduRegistry.Instance.GetOrAdd(ip);
var model = pdu.GetModelName();
bool ok = !string.IsNullOrEmpty(model) && model.Contains("PDU");
if (!ok)
{
PduRegistry.Instance.Remove(ip);
return Err($"no SNMP response from PDU at {ip} (check IP/network/community)");
}
return Ok(new { ip, model });
}
[McpServerTool, Description("List PDUs currently connected in this MCP session (by IP).")]
public static Task<string> pdu_list()
=> Ok(new { connected = PduRegistry.Instance.ConnectedIps });
[McpServerTool, Description("Turn a PDU outlet on or off. The PDU must be connected first via pdu_connect.")]
public static Task<string> pdu_set_port(
[Description("PDU IP address")] string ip,
[Description("Outlet/port number (1-12)")] int port,
[Description("true = ON, false = OFF")] bool on)
{
if (!TryResolve(ip, port, out var pdu, out var error)) return Err(error);
bool ok = on ? pdu.SetPortOn(port) : pdu.SetPortOff(port);
if (!ok) return Err($"SNMP set failed for {ip} port {port}");
return Ok(new { ip, port, state = on ? "on" : "off" });
}
[McpServerTool, Description("Read a single outlet's state, current (mA) and power (W).")]
public static Task<string> pdu_get_port(
[Description("PDU IP address")] string ip,
[Description("Outlet/port number (1-12)")] int port)
{
if (!TryResolve(ip, port, out var pdu, out var error)) return Err(error);
return Ok(PortSnapshot(pdu, port));
}
[McpServerTool, Description("Read the state, current (mA) and power (W) of all outlets on the PDU.")]
public static Task<string> pdu_status(
[Description("PDU IP address")] string ip)
{
if (!PduRegistry.Instance.TryGet(ip, out var pdu))
return Err($"PDU {ip} not connected. Call pdu_connect first.");
// 批次 SNMP GET12 port 只需 3 個 UDP 來回,而非逐 port 逐 OID 36 個。
var all = pdu.GetAllPortsStatus(PduRegistry.PortCount);
var ports = new List<object>();
for (int p = 0; p < all.Length; 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 });
}
[McpServerTool, Description("Power-cycle an outlet: turn it OFF, wait offSeconds, then turn it ON. Useful for rebooting a DUT.")]
public static async Task<string> pdu_power_cycle(
[Description("PDU IP address")] string ip,
[Description("Outlet/port number (1-12)")] int port,
[Description("Seconds to stay off before powering back on")] int offSeconds = 5)
{
if (!TryResolve(ip, port, out var pdu, out var error)) return await Err(error);
if (offSeconds < 0) offSeconds = 0;
if (!pdu.SetPortOff(port)) return await Err($"SNMP off failed for {ip} port {port}");
await Task.Delay(offSeconds * 1000);
if (!pdu.SetPortOn(port)) return await Err($"SNMP on failed for {ip} port {port}");
return await Ok(new { ip, port, action = "power_cycle", offSeconds, state = "on" });
}
[McpServerTool, Description("Disconnect a PDU from this MCP session (does not change outlet states).")]
public static Task<string> pdu_disconnect(
[Description("PDU IP address")] string ip)
=> Ok(new { ip, removed = PduRegistry.Instance.Remove(ip) });
// ── helpers ──
private static bool TryResolve(string ip, int port, out PduController pdu, out string error)
{
pdu = null!;
error = "";
if (port < 1 || port > PduRegistry.PortCount)
{
error = $"port must be 1-{PduRegistry.PortCount}";
return false;
}
if (!PduRegistry.Instance.TryGet(ip, out pdu))
{
error = $"PDU {ip} not connected. Call pdu_connect first.";
return false;
}
return true;
}
private static object PortSnapshot(PduController pdu, int port)
{
var state = pdu.GetPortState(port);
return new
{
port,
state = state == true ? "on" : state == false ? "off" : "unknown",
currentMilliAmps = pdu.GetPortCurrent(port),
powerWatts = pdu.GetPortPowerWatts(port)
};
}
private static Task<string> Ok(object payload)
{
var node = new { ok = true, result = payload };
return Task.FromResult(JsonSerializer.Serialize(node, Json));
}
private static Task<string> Err(string message)
=> Task.FromResult(JsonSerializer.Serialize(new { ok = false, error = message }, Json));
}
+17
View File
@@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
// stdio MCP server:把 SNMP PDU 控制暴露給 AIKiro / Claude CLI)。
// 與 ETTerms.SerialMcp 不同,PDU 走 SNMP(UDP) 非獨佔,故直接打 SNMP,不需 GUI 在跑。
var builder = Host.CreateApplicationBuilder(args);
// stdio 傳輸:stdout 專供 JSON-RPClog 一律走 stderr,否則會污染協議。
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
+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();
}
+71
View File
@@ -183,6 +183,77 @@ public sealed class AboutView : UserControl
private static readonly ChangelogEntry[] Changelog = private static readonly ChangelogEntry[] Changelog =
[ [
new("0.7.1", new DateOnly(2026, 7, 16), "Scripts no longer hang when a booting device swallows a command",
[
"Fixed: a device that is still booting can silently throw away what you send it, the moment its shell takes over the console — the line never echoes and never runs, so the wait after it sat there forever and your test rig was dead until someone noticed. Adding a pause before the send only made it rarer: in one overnight 45-cycle power-cycle run, 3 cycles still hung this way.",
"New scripting command: sendlnretry '<text>' '<confirm keyword>' [max attempts] it sends, waits for proof the device actually ran the command, and sends again if that proof never arrives. Leave the attempt count out and it keeps trying until it gets through.",
"Use it anywhere you currently send a command right after a boot: sendlnretry 'tpm2' 'TPM 2p0' followed by wait 'PASS'. Pick the confirm keyword from the command's own output, not its echo — an echo comes from the device's tty and doesn't prove the command was ever read.",
"If it runs out of attempts, result is 0 and the script carries on, so you can handle the failure yourself. Each attempt waits 3 seconds, or your timeout / mtimeout if you've set one.",
"See docs/ttl-script-reference.md for the full details.",
]),
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",
[
"Fixed: after the terminal had printed output (Serial or PowerShell), switching away and back — minimizing, or clicking another window — left the terminal unable to accept any typing or Enter, forcing you to reopen the session.",
"Cause: once enough output scrolled by, the terminal's scrollbar could steal the keyboard focus when the window regained focus. The scrollbar is now mouse-only and never takes focus, so input keeps working.",
]),
new("0.3.2", new DateOnly(2026, 6, 11), "New Status page — control PDU outlets with buttons",
[
"New Status page (the ⚡ icon on the left) with a PDU tab — connect to your PDU by IP and see every outlet at a glance.",
"Each outlet now has its own on/off button: the button shows \"Turn ON\" or \"Turn OFF\" depending on the current state, so one click flips it.",
"Outlet status, current and power refresh automatically every 3 seconds — no need to hit Refresh anymore.",
"Fixed: the local Shell no longer fails to start when its saved folder is gone (e.g. an unplugged USB drive); it now falls back to your home folder.",
]),
new("0.3.1", new DateOnly(2026, 6, 8), "Bugfix — terminal usability",
[
"Added a scrollbar on the right of the terminal — just drag it to look back through long output, instead of spinning the mouse wheel for ages.",
"Fixed pasting multiple lines into Kiro CLI (and similar tools): the whole block now pastes in one go, instead of each line being sent off immediately.",
"After you right-click to copy, the highlight now clears so you can tell the copy actually worked.",
]),
new("0.3.0", new DateOnly(2026, 6, 8), "AI power control for PDU outlets",
[
"AI assistants (Kiro / Claude) can now switch your PDU power outlets on and off for you.",
"Great for automated testing — the AI can power-cycle (restart) a connected device by itself.",
"Works even when the main ETTerms window isn't open.",
"One click in Settings → AI MCP now sets up both the Serial and PDU AI helpers at the same time.",
]),
new("0.2.2", new DateOnly(2026, 6, 5), "Bugfix — terminal stability", new("0.2.2", new DateOnly(2026, 6, 5), "Bugfix — terminal stability",
[ [
"Fixed: the terminal no longer freezes after minimizing or switching tabs (notably in PowerShell / Kiro).", "Fixed: the terminal no longer freezes after minimizing or switching tabs (notably in PowerShell / Kiro).",
+2 -1
View File
@@ -10,7 +10,7 @@ namespace ETTerms.App;
/// </summary> /// </summary>
public sealed class ActivityRail : UserControl public sealed class ActivityRail : UserControl
{ {
public enum RailView { Terminal, Settings, About } public enum RailView { Terminal, Status, Settings, About }
public event EventHandler<RailView>? ViewSelected; public event EventHandler<RailView>? ViewSelected;
@@ -23,6 +23,7 @@ public sealed class ActivityRail : UserControl
private static readonly (RailView view, string glyph, string tip)[] Items = private static readonly (RailView view, string glyph, string tip)[] Items =
{ {
(RailView.Terminal, "▤", "Terminal"), (RailView.Terminal, "▤", "Terminal"),
(RailView.Status, "⚡", "Status"),
(RailView.Settings, "⚙", "Settings"), (RailView.Settings, "⚙", "Settings"),
(RailView.About, "", "About"), (RailView.About, "", "About"),
}; };
+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);
}
}
+4
View File
@@ -14,6 +14,7 @@ public partial class MainForm : Form
private readonly ActivityRail _rail = new(); private readonly ActivityRail _rail = new();
private readonly ConnectionSidebar _sidebar = new(); private readonly ConnectionSidebar _sidebar = new();
private readonly WorkspaceView _workspace = new(); private readonly WorkspaceView _workspace = new();
private readonly StatusView _statusView = new();
private readonly SettingsView _settings = new(); private readonly SettingsView _settings = new();
private readonly AboutView _about = new(); private readonly AboutView _about = new();
private readonly StatusStrip _status = new(); private readonly StatusStrip _status = new();
@@ -45,11 +46,13 @@ public partial class MainForm : Form
private void BuildLayout() private void BuildLayout()
{ {
Controls.Add(_workspace); // Fill Controls.Add(_workspace); // Fill
Controls.Add(_statusView); // Fill (hidden)
Controls.Add(_settings); // Fill (hidden) Controls.Add(_settings); // Fill (hidden)
Controls.Add(_about); // Fill (hidden) Controls.Add(_about); // Fill (hidden)
Controls.Add(_sidebar); // Left (內側) Controls.Add(_sidebar); // Left (內側)
Controls.Add(_rail); // Left (最外側) Controls.Add(_rail); // Left (最外側)
_statusView.Visible = false;
_settings.Visible = false; _settings.Visible = false;
_about.Visible = false; _about.Visible = false;
@@ -68,6 +71,7 @@ public partial class MainForm : Form
_statusLabel.Text = $"View: {view}"; _statusLabel.Text = $"View: {view}";
_sidebar.Visible = view == ActivityRail.RailView.Terminal; _sidebar.Visible = view == ActivityRail.RailView.Terminal;
_workspace.Visible = view == ActivityRail.RailView.Terminal; _workspace.Visible = view == ActivityRail.RailView.Terminal;
_statusView.Visible = view == ActivityRail.RailView.Status;
_settings.Visible = view == ActivityRail.RailView.Settings; _settings.Visible = view == ActivityRail.RailView.Settings;
_about.Visible = view == ActivityRail.RailView.About; _about.Visible = view == ActivityRail.RailView.About;
AppLogger.LogInfo($"View selected: {view}"); AppLogger.LogInfo($"View selected: {view}");
+196 -73
View File
@@ -1,11 +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;
using ETTerms.Scripting.Pdu;
namespace ETTerms.App; namespace ETTerms.App;
/// <summary>Settings page with tabs: Terminal / PDU / AI MCP.</summary> /// <summary>Settings page with tabs: Terminal / Highlight / AI Assistant / AI MCP.</summary>
public sealed class SettingsView : UserControl public sealed class SettingsView : UserControl
{ {
public SettingsView() public SettingsView()
@@ -53,7 +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("PDU", BuildPduTab())); 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);
@@ -95,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
@@ -162,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();
@@ -173,10 +183,11 @@ public sealed class SettingsView : UserControl
return page; return page;
} }
// ═══ PDU Tab ═══ // ═══ Highlight Tab ═══
private Panel BuildPduTab() private Panel BuildHighlightTab()
{ {
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) }; var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
var s = AppSettings.Instance;
var flow = new FlowLayoutPanel var flow = new FlowLayoutPanel
{ {
@@ -184,93 +195,203 @@ public sealed class SettingsView : UserControl
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
}; };
// Connection row flow.Controls.Add(new Label
var ipBox = new TextBox { Width = 160, Text = "192.168.1.21", BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont }; {
var connectBtn = MakeButton("Connect", Theme.SerialColor); Text = "Keyword Highlighting", AutoSize = true,
var statusLabel = new Label { AutoSize = true, Text = "Disconnected", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(8, 8, 0, 0) }; 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 connRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Width = 500, Height = 36, WrapContents = false, Margin = new Padding(0, 0, 0, 8) }; var enable = new CheckBox
connRow.Controls.Add(new Label { Text = "PDU IP:", AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0) }); {
connRow.Controls.Add(ipBox); Text = "Enable keyword highlighting", Checked = s.KeywordHighlightEnabled,
connRow.Controls.Add(connectBtn); AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
connRow.Controls.Add(statusLabel); };
flow.Controls.Add(connRow); flow.Controls.Add(enable);
// Port status grid // 規則清單:每列 = 啟用勾選 + 關鍵字(可直接編輯)
var grid = new DataGridView var grid = new DataGridView
{ {
Width = 480, Height = 310, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, Width = 420, Height = 240,
BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border, BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border,
BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal,
DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text }, DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text },
ColumnHeadersDefaultCellStyle = { BackColor = Theme.RailBack, ForeColor = Theme.Accent, Font = Theme.UiFontBold }, ColumnHeadersDefaultCellStyle = { BackColor = Theme.RailBack, ForeColor = Theme.Accent, Font = Theme.UiFontBold },
ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single, ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single,
EnableHeadersVisualStyles = false, RowHeadersVisible = false, EnableHeadersVisualStyles = false, RowHeadersVisible = false,
AllowUserToAddRows = false, AllowUserToDeleteRows = false, ReadOnly = true, AllowUserToAddRows = false, AllowUserToDeleteRows = false,
AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect, AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
ScrollBars = ScrollBars.None, Font = Theme.UiFont, Font = Theme.UiFont, RowTemplate = { Height = 24 }, Margin = new Padding(0, 0, 0, 8)
RowTemplate = { Height = 24 },
Margin = new Padding(0, 8, 0, 8)
}; };
grid.Columns.Add("Port", "Port"); grid.Columns.Add(new DataGridViewCheckBoxColumn { Name = "On", HeaderText = "On", Width = 44 });
grid.Columns.Add("Status", "Status"); grid.Columns.Add(new DataGridViewTextBoxColumn
grid.Columns.Add("Current", "Current (mA)"); {
grid.Columns.Add("Power", "Power (W)"); Name = "Keyword", HeaderText = "Keyword",
for (int i = 1; i <= 12; i++) AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
grid.Rows.Add($"Port {i}", "—", "—", "—"); });
foreach (var r in s.KeywordRules) grid.Rows.Add(r.Enabled, r.Text);
flow.Controls.Add(grid); flow.Controls.Add(grid);
// Refresh button // 新增 / 移除
var refreshBtn = MakeButton("Refresh", Theme.Accent); var newKw = new TextBox { Width = 220, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle };
flow.Controls.Add(refreshBtn); var addBtn = MakeButton("Add", Theme.Accent);
var removeBtn = MakeButton("Remove Selected", Color.FromArgb(210, 120, 120));
// Logic void AddKeyword()
PduController? pdu = null;
connectBtn.Click += (_, _) =>
{ {
if (pdu != null) { pdu.Dispose(); pdu = null; statusLabel.Text = "Disconnected"; statusLabel.ForeColor = Theme.TextDim; connectBtn.Text = "Connect"; return; } var t = newKw.Text.Trim();
var ip = ipBox.Text.Trim(); if (t.Length == 0) return;
var p = new PduController(ip); grid.Rows.Add(true, t);
if (p.CheckConnection()) newKw.Clear();
{ newKw.Focus();
pdu = p;
statusLabel.Text = $"Connected to {ip}";
statusLabel.ForeColor = Theme.SerialColor;
connectBtn.Text = "Disconnect";
RefreshPduGrid(pdu, grid);
} }
else addBtn.Click += (_, _) => AddKeyword();
newKw.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) { AddKeyword(); e.Handled = e.SuppressKeyPress = true; } };
removeBtn.Click += (_, _) =>
{ {
p.Dispose(); foreach (DataGridViewRow row in grid.SelectedRows) grid.Rows.Remove(row);
MessageBox.Show(this, $"Failed to connect to PDU at {ip}", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}; };
refreshBtn.Click += (_, _) => var editRow = new FlowLayoutPanel
{ {
if (pdu == null) { MessageBox.Show(this, "Connect to PDU first.", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } FlowDirection = FlowDirection.LeftToRight, AutoSize = true,
RefreshPduGrid(pdu, grid); 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); page.Controls.Add(flow);
return page; return page;
} }
private static void RefreshPduGrid(PduController pdu, DataGridView grid) // ═══ AI Assistant Tab(內建 agent 的 BYO endpoint 設定)═══
private Panel BuildAiAssistantTab()
{ {
for (int i = 0; i < 12; i++) var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
var s = AppSettings.Instance;
var flow = new FlowLayoutPanel
{ {
int port = i + 1; Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
var state = pdu.GetPortState(port); WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
var current = pdu.GetPortCurrent(port); };
var power = pdu.GetPortPowerWatts(port);
var row = grid.Rows[i]; flow.Controls.Add(new Label
row.Cells["Status"].Value = state == true ? "ON" : state == false ? "OFF" : "—"; {
row.Cells["Current"].Value = current.HasValue ? $"{current.Value}" : "—"; Text = "Built-in AI Assistant", AutoSize = true,
row.Cells["Power"].Value = power.HasValue ? $"{power.Value:F1}" : "—"; ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4)
row.DefaultCellStyle.BackColor = state == true ? Color.FromArgb(40, 80, 40) : state == false ? Color.FromArgb(60, 40, 40) : Theme.TabBack; });
} 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 ═══
@@ -291,28 +412,30 @@ public sealed class SettingsView : UserControl
}); });
flow.Controls.Add(new Label flow.Controls.Add(new Label
{ {
Text = "One-click register the ETTerms Serial MCP server into your AI CLI's user-level\n" + Text = "One-click register the ETTerms MCP servers (Serial + PDU) into your AI CLI's\n" +
"config. ETTerms keeps sole ownership of the COM port; the AI drives serial through\n" + "user-level config. Serial: ETTerms owns the COM port, the AI drives it through a\n" +
"a local named pipe. Open a Serial session in ETTerms first, then the AI can attach.", "local named pipe (open a Serial session first). PDU: the AI controls outlets\n" +
AutoSize = false, Width = 600, Height = 56, "directly over SNMP — no GUI session required.",
AutoSize = false, Width = 600, Height = 64,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8) ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8)
}); });
// Resolved MCP server exe // Resolved MCP server exes (serial + pdu)
var exe = McpRegistrar.ResolveServerExe(); foreach (var (name, exe, exists) in McpRegistrar.ServerInfos())
var exists = McpRegistrar.ServerExeExists(); {
flow.Controls.Add(new Label flow.Controls.Add(new Label
{ {
Text = $"MCP server: {exe}", Text = $"{name}: {exe}",
AutoSize = false, Width = 600, Height = 20, AutoSize = false, Width = 600, Height = 20,
ForeColor = exists ? Theme.SerialColor : Color.FromArgb(210, 150, 120), ForeColor = exists ? Theme.SerialColor : Color.FromArgb(210, 150, 120),
Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2) Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2)
}); });
if (!exists) }
if (!McpRegistrar.ServerExeExists())
{ {
flow.Controls.Add(new Label flow.Controls.Add(new Label
{ {
Text = "⚠ Not found yet — publish the app (or build ETTerms.SerialMcp). Setup still writes this expected path.", Text = "⚠ Some servers not built yet — publish the app (or build the MCP projects). Setup still writes the expected paths.",
AutoSize = false, Width = 600, Height = 20, AutoSize = false, Width = 600, Height = 20,
ForeColor = Color.FromArgb(210, 150, 120), Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 4) ForeColor = Color.FromArgb(210, 150, 120), Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 4)
}); });
+286
View File
@@ -0,0 +1,286 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.Infrastructure;
using ETTerms.PduCore;
namespace ETTerms.App;
/// <summary>
/// Status page with tabs: PDU (more views to come).
/// 風格參考 <see cref="SettingsView"/>:自繪 tab strip + panel 切換,避免 TabControl 白邊。
/// PDU 分頁連線後每 3 秒於背景自動輪詢插座狀態(不需手動 Refresh)。
/// </summary>
public sealed class StatusView : UserControl
{
public StatusView()
{
Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack;
var tabBar = new FlowLayoutPanel
{
Dock = DockStyle.Top, Height = 32, BackColor = Theme.RailBack,
Padding = new Padding(4, 4, 4, 0), WrapContents = false
};
var body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack };
var pages = new List<Panel>();
Button? activeBtn = null;
Button MakeTab(string text, Panel page)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
body.Controls.Add(page);
pages.Add(page);
var b = new Button
{
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(70, 26), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand
};
b.FlatAppearance.BorderColor = Theme.Border;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
b.Click += (_, _) =>
{
foreach (var p in pages) p.Visible = p == page;
if (activeBtn != null) activeBtn.BackColor = Theme.TabBack;
b.BackColor = Theme.TabActiveBack;
activeBtn = b;
};
return b;
}
var pduBtn = MakeTab("PDU", BuildPduTab());
tabBar.Controls.Add(pduBtn);
Controls.Add(body);
Controls.Add(tabBar);
pduBtn.PerformClick();
}
// ═══ PDU Tab ═══
private Panel BuildPduTab()
{
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
var flow = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
};
// Connection row
var ipBox = new TextBox { Width = 160, Text = "192.168.1.21", BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont };
var connectBtn = MakeButton("Connect", Theme.SerialColor);
var statusLabel = new Label { AutoSize = true, Text = "Disconnected", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(8, 8, 0, 0) };
var connRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Width = 500, Height = 36, WrapContents = false, Margin = new Padding(0, 0, 0, 8) };
connRow.Controls.Add(new Label { Text = "PDU IP:", AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0) });
connRow.Controls.Add(ipBox);
connRow.Controls.Add(connectBtn);
connRow.Controls.Add(statusLabel);
flow.Controls.Add(connRow);
// Port status grid
var grid = new DataGridView
{
Width = 480, Height = 310, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border,
BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal,
DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text },
ColumnHeadersDefaultCellStyle = { BackColor = Theme.RailBack, ForeColor = Theme.Accent, Font = Theme.UiFontBold },
ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single,
EnableHeadersVisualStyles = false, RowHeadersVisible = false,
AllowUserToAddRows = false, AllowUserToDeleteRows = false, ReadOnly = true,
AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
ScrollBars = ScrollBars.None, Font = Theme.UiFont,
RowTemplate = { Height = 24 },
Margin = new Padding(0, 8, 0, 8)
};
grid.Columns.Add("Port", "Port");
grid.Columns.Add("Status", "Status");
grid.Columns.Add("Current", "Current (mA)");
grid.Columns.Add("Power", "Power (W)");
// 控制按鈕欄:按一下切換該 Port 的 ON/OFF
var actionCol = new DataGridViewButtonColumn
{
Name = "Action", HeaderText = "Control",
UseColumnTextForButtonValue = false, FlatStyle = FlatStyle.Flat,
FillWeight = 80,
DefaultCellStyle =
{
BackColor = Theme.TabBack, ForeColor = Theme.Text,
SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text,
Alignment = DataGridViewContentAlignment.MiddleCenter
}
};
grid.Columns.Add(actionCol);
for (int i = 1; i <= 12; i++)
grid.Rows.Add($"Port {i}", "—", "—", "—", "—");
flow.Controls.Add(grid);
// 連線後每 3 秒自動輪詢(背景執行緒讀 SNMP,Invoke 回 UI 更新)
var pollLabel = new Label { AutoSize = true, Text = "", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 4, 0, 0) };
flow.Controls.Add(pollLabel);
// Logic
PduController? pdu = null;
System.Threading.Timer? timer = null;
int polling = 0; // 0=idle, 1=in-flight(避免上一次未讀完又疊一次)
void StopPolling()
{
timer?.Dispose();
timer = null;
}
void PollOnce()
{
// 已在輪詢中就跳過這一輪
if (System.Threading.Interlocked.Exchange(ref polling, 1) == 1) return;
var current = pdu;
if (current == null) { System.Threading.Volatile.Write(ref polling, 0); return; }
try
{
// 批次 SNMP GET12 port 只需 3 個 UDP 來回(原本逐 port 逐 OID 36 個,
// 逾時時最壞一輪要 36×3 秒)。
var rows = current.GetAllPortsStatus(12);
if (!IsDisposed && IsHandleCreated)
{
BeginInvoke(new Action(() =>
{
if (pdu != current) return; // 期間已斷線
ApplyPduRows(grid, rows);
pollLabel.Text = $"Auto-refresh every 3s · last update {DateTime.Now:HH:mm:ss}";
}));
}
}
catch { /* 輪詢失敗忽略,下一輪再試 */ }
finally { System.Threading.Volatile.Write(ref polling, 0); }
}
connectBtn.Click += (_, _) =>
{
if (pdu != null)
{
StopPolling();
pdu.Dispose(); pdu = null;
statusLabel.Text = "Disconnected"; statusLabel.ForeColor = Theme.TextDim;
connectBtn.Text = "Connect";
pollLabel.Text = "";
// 斷線後清空表格,避免顯示過時的狀態
foreach (DataGridViewRow row in grid.Rows)
{
row.Cells["Status"].Value = "—";
row.Cells["Current"].Value = "—";
row.Cells["Power"].Value = "—";
row.Cells["Action"].Value = "—";
row.DefaultCellStyle.BackColor = Theme.TabBack;
}
return;
}
var ip = ipBox.Text.Trim();
var p = new PduController(ip, AppLogger.Info, AppLogger.LogWarning);
if (p.CheckConnection())
{
pdu = p;
statusLabel.Text = $"Connected to {ip}";
statusLabel.ForeColor = Theme.SerialColor;
connectBtn.Text = "Disconnect";
// 立即讀一次,之後每 3 秒一次
timer = new System.Threading.Timer(_ => PollOnce(), null,
dueTime: 0, period: 3000);
}
else
{
p.Dispose();
MessageBox.Show(this, $"Failed to connect to PDU at {ip}", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
};
// 控制項銷毀時收掉計時器與連線
Disposed += (_, _) => { StopPolling(); pdu?.Dispose(); };
// 按下 Control 欄按鈕:切換該 Port 的 ON/OFF(SNMP Set 在背景執行,避免卡 UI)
grid.CellContentClick += (_, e) =>
{
if (e.RowIndex < 0 || e.ColumnIndex < 0) return;
if (grid.Columns[e.ColumnIndex].Name != "Action") return;
var current = pdu;
if (current == null)
{
MessageBox.Show(this, "PDU is not connected. Please connect first.",
"PDU", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
int port = e.RowIndex + 1;
var statusVal = grid.Rows[e.RowIndex].Cells["Status"].Value?.ToString();
if (statusVal != "ON" && statusVal != "OFF") return; // 狀態未知時不動作
bool turnOn = statusVal != "ON"; // 目前 ON → 關;其餘 → 開
var actionCell = grid.Rows[e.RowIndex].Cells["Action"];
actionCell.Value = "…";
System.Threading.Tasks.Task.Run(() =>
{
bool ok = turnOn ? current.SetPortOn(port) : current.SetPortOff(port);
System.Threading.Thread.Sleep(400); // 等 PDU 套用後再讀回確認
PollOnce(); // 背景讀 SNMP 後 Invoke 回 UI 更新整張表
if (!ok && !IsDisposed && IsHandleCreated)
{
BeginInvoke(new Action(() =>
MessageBox.Show(this,
$"Failed to turn {(turnOn ? "ON" : "OFF")} Port {port}",
"PDU", MessageBoxButtons.OK, MessageBoxIcon.Error)));
}
});
};
page.Controls.Add(flow);
return page;
}
private static void ApplyPduRows(DataGridView grid, (bool? State, int? CurrentMilliAmps, double? PowerWatts)[] rows)
{
for (int i = 0; i < rows.Length && i < grid.Rows.Count; i++)
{
var (state, current, power) = rows[i];
var row = grid.Rows[i];
row.Cells["Status"].Value = state == true ? "ON" : state == false ? "OFF" : "—";
row.Cells["Current"].Value = current.HasValue ? $"{current.Value}" : "—";
row.Cells["Power"].Value = power.HasValue ? $"{power.Value:F1}" : "—";
// 按鈕文字代表「按下後會做的動作」:ON 時顯示 Turn OFF,反之亦然
row.Cells["Action"].Value = state == true ? "Turn OFF" : state == false ? "Turn ON" : "—";
row.DefaultCellStyle.BackColor = state == true ? Color.FromArgb(40, 80, 40) : state == false ? Color.FromArgb(60, 40, 40) : Theme.TabBack;
}
}
// ── Helpers ──
private static Button MakeButton(string text, Color borderColor)
{
var b = new Button
{
Text = text, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink,
MinimumSize = new Size(90, 28), Padding = new Padding(10, 2, 10, 2),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Cursor = Cursors.Hand, Margin = new Padding(8, 0, 0, 0)
};
b.FlatAppearance.BorderColor = borderColor;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
return b;
}
}
+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
}); });
+14 -8
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName> <AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 --> <!-- 版本資訊 -->
<Version>0.2.2</Version> <Version>0.7.1</Version>
<Product>ETTerms</Product> <Product>ETTerms</Product>
<Company>ETTerms Project</Company> <Company>ETTerms Project</Company>
@@ -27,30 +27,36 @@
</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 時,自動把 Serial MCP server 一併發佈到 <publish>\ETTerms.SerialMcp\ 子資料夾。 發佈 GUI 時,自動把 MCP serversSerial + PDU一併發佈到 <publish>\<server>\ 子資料夾。
這樣單一 `dotnet publish src\ETTerms` 就會產生完整自洽的 bundle, 這樣單一 `dotnet publish src\ETTerms` 就會產生完整自洽的 bundle,
且 McpRegistrar.ResolveServerExe() 解析的 <ETTerms.exe>\ETTerms.SerialMcp\ETTerms.SerialMcp.exe 必定存在。 且 McpRegistrar.ResolveServerExe() 解析的 <ETTerms.exe>\<server>\<server>.exe 必定存在。
刻意放子資料夾:與 GUI 的相依 dll 隔離,避免互相覆蓋。 刻意放子資料夾:與 GUI 的相依 dll 隔離,避免互相覆蓋。
MCP 跟隨 GUI 的 self-contained 設定:框架相依版 → MCP 也框架相依;portable(self-contained)版 → MCP 也免 runtime。 MCP 跟隨 GUI 的 self-contained 設定:框架相依版 → MCP 也框架相依;portable(self-contained)版 → MCP 也免 runtime。
--> -->
<Target Name="PublishSerialMcp" AfterTargets="Publish"> <Target Name="PublishMcpServers" AfterTargets="Publish">
<PropertyGroup> <PropertyGroup>
<_McpRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</_McpRid> <_McpRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</_McpRid>
<_McpRid Condition="'$(RuntimeIdentifier)' == ''">win-x64</_McpRid> <_McpRid Condition="'$(RuntimeIdentifier)' == ''">win-x64</_McpRid>
<_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 -&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;" />
</Target> </Target>
</Project> </Project>
+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;
}
+51 -23
View File
@@ -8,16 +8,25 @@ namespace ETTerms.Infrastructure;
public enum McpTarget { Claude, Kiro } public enum McpTarget { Claude, Kiro }
/// <summary> /// <summary>
/// 把 ETTerms 的 Serial MCP server<c>ETTerms.SerialMcp</c>)一鍵註冊 / 移除到 /// 把 ETTerms 的 MCP servers<c>ETTerms.SerialMcp</c> 與 <c>ETTerms.PduMcp</c>)一鍵註冊 /
/// 各 AI CLI 的「使用者層級」MCP 設定檔。採 read-modify-write,保留檔內其他既有伺服器。 /// 移除到各 AI CLI 的「使用者層級」MCP 設定檔。採 read-modify-write,保留檔內其他既有伺服器。
/// ///
/// - Claude Code<c>~/.claude.json</c> 頂層 <c>mcpServers</c>entry 需 <c>type:"stdio"</c>。 /// - Claude Code<c>~/.claude.json</c> 頂層 <c>mcpServers</c>entry 需 <c>type:"stdio"</c>。
/// - Kiro<c>%USERPROFILE%\.kiro\settings\mcp.json</c> 頂層 <c>mcpServers</c>。 /// - Kiro<c>%USERPROFILE%\.kiro\settings\mcp.json</c> 頂層 <c>mcpServers</c>。
///
/// 兩個 server 一起註冊 / 移除(一鍵同時設定 serial 與 pdu)。
/// </summary> /// </summary>
public static class McpRegistrar public static class McpRegistrar
{ {
/// <summary>註冊到各 CLI 時用的 MCP server 名。</summary> /// <summary>一個可被註冊的 MCP server 描述:CLI 內名稱 + 發佈子資料夾 + 執行檔名。</summary>
public const string ServerName = "etterms-serial"; public sealed record McpServer(string Name, string PublishFolder, string ExeName);
/// <summary>ETTerms 提供的所有 MCP servers。</summary>
public static readonly IReadOnlyList<McpServer> Servers = new[]
{
new McpServer("etterms-serial", "ETTerms.SerialMcp", "ETTerms.SerialMcp.exe"),
new McpServer("etterms-pdu", "ETTerms.PduMcp", "ETTerms.PduMcp.exe"),
};
public static string DisplayName(McpTarget t) => t switch public static string DisplayName(McpTarget t) => t switch
{ {
@@ -43,33 +52,33 @@ public static class McpRegistrar
{ {
McpTarget.Claude => McpTarget.Claude =>
"claude mcp list\r\n" + "claude mcp list\r\n" +
$"# 應看到:{ServerName} ✓ Connected\r\n" + "# 應看到:etterms-serial / etterms-pdu ✓ Connected\r\n" +
$"# 細節: claude mcp get {ServerName}", "# 細節: claude mcp get etterms-pdu",
McpTarget.Kiro => McpTarget.Kiro =>
"kiro-cli mcp list\r\n" + "kiro-cli mcp list\r\n" +
$"kiro-cli mcp status --name {ServerName}\r\n" + "kiro-cli mcp status --name etterms-pdu\r\n" +
"# 或在 Kiro IDE:點 ghost 圖示開 MCP Servers 面板查看狀態", "# 或在 Kiro IDE:點 ghost 圖示開 MCP Servers 面板查看狀態",
_ => "" _ => ""
}; };
/// <summary>找出 ETTerms.SerialMcp 執行檔路徑(找不到回傳最可能的位置作為註冊值)。</summary> /// <summary>找出某個 MCP server 執行檔路徑(找不到回傳最可能的位置作為註冊值)。</summary>
public static string ResolveServerExe() public static string ResolveServerExe(McpServer server)
{ {
var baseDir = AppContext.BaseDirectory; var baseDir = AppContext.BaseDirectory;
var candidates = new List<string> var candidates = new List<string>
{ {
Path.Combine(baseDir, "ETTerms.SerialMcp", "ETTerms.SerialMcp.exe"), // 發佈版(子資料夾) Path.Combine(baseDir, server.PublishFolder, server.ExeName), // 發佈版(子資料夾)
Path.Combine(baseDir, "ETTerms.SerialMcp.exe"), // 同層 Path.Combine(baseDir, server.ExeName), // 同層
}; };
// 開發版 fallbacksrc\ETTerms\bin\<cfg>\net8.0-windows → src\ETTerms.SerialMcp\bin\<cfg>\net8.0 // 開發版 fallbacksrc\ETTerms\bin\<cfg>\net8.0-windows → src\<folder>\bin\<cfg>\net8.0
try try
{ {
var binCfg = new DirectoryInfo(baseDir); // ...\net8.0-windows var binCfg = new DirectoryInfo(baseDir); // ...\net8.0-windows
var config = binCfg.Parent?.Name ?? "Debug"; // Debug / Release var config = binCfg.Parent?.Name ?? "Debug"; // Debug / Release
var srcDir = binCfg.Parent?.Parent?.Parent?.Parent; // ...\src var srcDir = binCfg.Parent?.Parent?.Parent?.Parent; // ...\src
if (srcDir != null) if (srcDir != null)
candidates.Add(Path.Combine(srcDir.FullName, "ETTerms.SerialMcp", "bin", config, "net8.0", "ETTerms.SerialMcp.exe")); candidates.Add(Path.Combine(srcDir.FullName, server.PublishFolder, "bin", config, "net8.0", server.ExeName));
} }
catch { /* 路徑推導失敗就略過開發版 fallback */ } catch { /* 路徑推導失敗就略過開發版 fallback */ }
@@ -78,22 +87,34 @@ public static class McpRegistrar
return candidates[0]; // 都找不到 → 回發佈版預期位置 return candidates[0]; // 都找不到 → 回發佈版預期位置
} }
public static bool ServerExeExists() => File.Exists(ResolveServerExe()); /// <summary>所有 server 執行檔是否都存在。</summary>
public static bool ServerExeExists() => Servers.All(s => File.Exists(ResolveServerExe(s)));
/// <summary>該目標是否已註冊 etterms-serial。</summary> /// <summary>列出每個 server 的解析路徑與是否存在(給 UI 顯示)。</summary>
public static IEnumerable<(string Name, string Exe, bool Exists)> ServerInfos()
{
foreach (var s in Servers)
{
var exe = ResolveServerExe(s);
yield return (s.Name, exe, File.Exists(exe));
}
}
/// <summary>該目標是否已註冊「全部」ETTerms MCP servers。</summary>
public static bool IsRegistered(McpTarget t) public static bool IsRegistered(McpTarget t)
{ {
try try
{ {
var path = ConfigPath(t); var path = ConfigPath(t);
if (!File.Exists(path)) return false; if (!File.Exists(path)) return false;
var root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject; var servers = (JsonNode.Parse(File.ReadAllText(path)) as JsonObject)?["mcpServers"] as JsonObject;
return (root?["mcpServers"] as JsonObject)?[ServerName] != null; if (servers == null) return false;
return Servers.All(s => servers[s.Name] != null);
} }
catch { return false; } catch { return false; }
} }
/// <summary>註冊(或更新)etterms-serial 到該目標設定檔。</summary> /// <summary>註冊(或更新)所有 ETTerms MCP servers 到該目標設定檔。</summary>
public static void Register(McpTarget t) public static void Register(McpTarget t)
{ {
var path = ConfigPath(t); var path = ConfigPath(t);
@@ -106,27 +127,34 @@ public static class McpRegistrar
servers = new JsonObject(); servers = new JsonObject();
root["mcpServers"] = servers; root["mcpServers"] = servers;
} }
servers[ServerName] = BuildEntry(t); foreach (var s in Servers)
servers[s.Name] = BuildEntry(t, s);
WriteRoot(path, root); WriteRoot(path, root);
AppLogger.Info($"MCP registered to {DisplayName(t)} at {path}"); AppLogger.Info($"MCP registered to {DisplayName(t)} at {path}");
} }
/// <summary>從該目標設定檔移除 etterms-serial。</summary> /// <summary>從該目標設定檔移除所有 ETTerms MCP servers。</summary>
public static void Unregister(McpTarget t) public static void Unregister(McpTarget t)
{ {
var path = ConfigPath(t); var path = ConfigPath(t);
if (!File.Exists(path)) return; if (!File.Exists(path)) return;
var root = LoadRoot(path); var root = LoadRoot(path);
if (root["mcpServers"] is JsonObject servers && servers.Remove(ServerName)) if (root["mcpServers"] is JsonObject servers)
{
bool changed = false;
foreach (var s in Servers)
changed |= servers.Remove(s.Name);
if (changed)
{ {
WriteRoot(path, root); WriteRoot(path, root);
AppLogger.Info($"MCP unregistered from {DisplayName(t)} at {path}"); AppLogger.Info($"MCP unregistered from {DisplayName(t)} at {path}");
} }
} }
}
private static JsonObject BuildEntry(McpTarget t) private static JsonObject BuildEntry(McpTarget t, McpServer server)
{ {
var exe = ResolveServerExe(); var exe = ResolveServerExe(server);
return t switch return t switch
{ {
// Claude Codestdio server 需 type 欄位 // Claude Codestdio server 需 type 欄位
+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;
+29 -6
View File
@@ -66,17 +66,32 @@ public sealed class ShellChannel : ISessionChannel
UpdateProcThreadAttribute(si.lpAttributeList, 0, (IntPtr)0x00020016 /* PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE */, UpdateProcThreadAttribute(si.lpAttributeList, 0, (IntPtr)0x00020016 /* PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE */,
_ptyHandle, IntPtr.Size, IntPtr.Zero, IntPtr.Zero); _ptyHandle, IntPtr.Size, IntPtr.Zero, IntPtr.Zero);
string workDir = string.IsNullOrWhiteSpace(_settings.StartupDirectory) // 啟動目錄:若未設定或該目錄已不存在(例如外接碟拔除、資料夾被刪),
? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) // 一律退回目前使用者的家目錄(C:\Users\<user>),避免 CreateProcess 失敗 267 (ERROR_DIRECTORY)。
: _settings.StartupDirectory; string configured = _settings.StartupDirectory;
string workDir = (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
? configured
: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
bool ok = CreateProcess(null, $"{exe} {args}".TrimEnd(), IntPtr.Zero, IntPtr.Zero, false, bool ok;
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" };
@@ -98,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)
@@ -173,6 +193,9 @@ public sealed class ShellChannel : ISessionChannel
[DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
private static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize); private static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize);
[DllImport("kernel32.dll")]
private static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern bool CreateProcess(string? lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFOEX lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); private static extern bool CreateProcess(string? lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFOEX lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
+20
View File
@@ -40,9 +40,22 @@ public sealed class SshChannel : ISessionChannel
var ci = BuildConnectionInfo(); var ci = BuildConnectionInfo();
_client = new SshClient(ci); _client = new SshClient(ci);
_client.HostKeyReceived += OnHostKey; _client.HostKeyReceived += OnHostKey;
// 斷線 / 錯誤要讓使用者看得到,否則只會覺得「打字沒反應」
_client.ErrorOccurred += (_, e) =>
{
if (_closed) return;
Emit($"\r\n\x1b[90m[SSH error] {e.Exception.Message}\x1b[0m\r\n");
AppLogger.LogWarning($"SSH error {_ssh.Host}: {e.Exception.Message}");
};
_client.Connect(); _client.Connect();
_shell = _client.CreateShellStream("xterm-256color", (uint)_cols, (uint)_rows, 0, 0, 4096); _shell = _client.CreateShellStream("xterm-256color", (uint)_cols, (uint)_rows, 0, 0, 4096);
_shell.DataReceived += (_, e) => DataReceived?.Invoke(e.Data); _shell.DataReceived += (_, e) => DataReceived?.Invoke(e.Data);
_shell.Closed += (_, _) =>
{
if (_closed) return;
Emit("\r\n\x1b[90m[SSH] connection closed by remote host\x1b[0m\r\n");
AppLogger.Info($"SSH shell closed: {_ssh.Host}");
};
AppLogger.Info($"SSH connected: {_ssh.Username}@{_ssh.Host}:{_ssh.Port}"); AppLogger.Info($"SSH connected: {_ssh.Username}@{_ssh.Host}:{_ssh.Port}");
} }
catch (Exception ex) catch (Exception ex)
@@ -102,9 +115,16 @@ public sealed class SshChannel : ISessionChannel
public void Write(byte[] data) public void Write(byte[] data)
{ {
if (_shell == null) return; if (_shell == null) return;
try
{
_shell.Write(data, 0, data.Length); _shell.Write(data, 0, data.Length);
_shell.Flush(); _shell.Flush();
} }
catch (Exception ex) // 連線已斷時從 UI thread 寫入不可讓例外炸掉視窗
{
if (!_closed) Emit($"\r\n\x1b[90m[SSH] write failed: {ex.Message}\x1b[0m\r\n");
}
}
public void Resize(int cols, int rows) public void Resize(int cols, int rows)
{ {
+20
View File
@@ -19,6 +19,14 @@ public sealed class AnsiParser
/// <summary>DECCKMapplication cursor keys(影響方向鍵送出序列)。</summary> /// <summary>DECCKMapplication cursor keys(影響方向鍵送出序列)。</summary>
public bool AppCursorKeys { get; private set; } public bool AppCursorKeys { get; private set; }
/// <summary>DEC mode 2004bracketed paste。啟用時貼上內容須以 ESC[200~ / ESC[201~ 包夾,
/// 讓 PSReadLine / Kiro CLI 等把多行貼上視為單一輸入而非逐行立即送出。</summary>
public bool BracketedPaste { get; private set; }
/// <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)
@@ -126,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;
@@ -138,6 +157,7 @@ public sealed class AnsiParser
case 25: _b.CursorVisible = set; break; case 25: _b.CursorVisible = set; break;
case 7: _b.AutoWrap = set; break; case 7: _b.AutoWrap = set; break;
case 1: AppCursorKeys = set; break; case 1: AppCursorKeys = set; break;
case 2004: BracketedPaste = set; break;
case 47: case 1047: case 1049: case 47: case 1047: case 1049:
if (set) _b.EnterAlt(); else _b.ExitAlt(); break; if (set) _b.EnterAlt(); else _b.ExitAlt(); break;
} }
+166
View File
@@ -0,0 +1,166 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
namespace ETTerms.Terminal;
/// <summary>
/// 自繪深色垂直捲軸:細長、無箭頭按鈕、圓角滑塊,配合終端機深色主題。
/// APIMinimum / Maximum / Value / LargeChange / SmallChange / Scroll)對齊 <see cref="VScrollBar"/>
/// 方便直接替換。<see cref="Scroll"/> 只在「使用者操作」造成值變動時觸發(程式設定 Value 不觸發)。
/// </summary>
public sealed class DarkScrollBar : Control
{
public int Minimum { get; set; } = 0;
public int Maximum { get; set; } = 0;
public int LargeChange { get; set; } = 1;
public int SmallChange { get; set; } = 1;
private int _value;
public int Value
{
get => _value;
set { int v = Clamp(value); if (v != _value) { _value = v; Invalidate(); } }
}
/// <summary>使用者拖曳 / 點軌道 / 滾輪造成值變動時觸發。</summary>
public event EventHandler? Scroll;
// 配色(KKTerm 深色)
private static readonly Color TrackColor = Color.FromArgb(24, 24, 28);
private static readonly Color ThumbColor = Color.FromArgb(70, 70, 82);
private static readonly Color ThumbHover = Color.FromArgb(100, 100, 118);
private static readonly Color ThumbDrag = Color.FromArgb(130, 130, 152);
private bool _hover, _dragging;
private int _dragOffset;
public DarkScrollBar()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
// 純滑鼠操作的捲軸,無任何鍵盤邏輯,絕不可吃鍵盤焦點:否則身為唯一可選取子控制項,
// 在 scrollback 出現(Enabled=true)後,視窗切走再切回時 WinForms 會把焦點還原到它身上,
// 導致 TerminalView 收不到鍵盤輸入(打字 / Enter 全失效),需重開 session 才恢復。
SetStyle(ControlStyles.Selectable, false);
TabStop = false;
Width = 12;
BackColor = TrackColor;
}
// ── 數值範圍工具 ─────────────────────────────────────────
/// <summary>可達的最大 Value(與 VScrollBar 相同:Maximum - LargeChange + 1)。</summary>
private int MaxValue => Math.Max(Minimum, Maximum - LargeChange + 1);
private int Clamp(int v) => Math.Clamp(v, Minimum, MaxValue);
private bool Scrollable => Enabled && MaxValue > Minimum;
// ── 滑塊幾何 ─────────────────────────────────────────────
private const int MinThumb = 24;
private const int Pad = 2;
private int TrackHeight => Math.Max(1, Height - Pad * 2);
private int ThumbHeight()
{
int total = Maximum - Minimum + 1;
if (total <= 0) return TrackHeight;
int h = (int)((long)TrackHeight * LargeChange / total);
return Math.Clamp(h, MinThumb, TrackHeight);
}
private int ThumbTop()
{
int range = MaxValue - Minimum;
if (range <= 0) return Pad;
int travel = TrackHeight - ThumbHeight();
return Pad + (int)((long)travel * (_value - Minimum) / range);
}
private Rectangle ThumbRect() => new(Pad, ThumbTop(), Math.Max(1, Width - Pad * 2), ThumbHeight());
// ── 繪製 ─────────────────────────────────────────────────
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.Clear(TrackColor);
if (!Scrollable) return; // 不可捲動時只畫底色(與停用態一致)
g.SmoothingMode = SmoothingMode.AntiAlias;
var r = ThumbRect();
var color = _dragging ? ThumbDrag : _hover ? ThumbHover : ThumbColor;
int radius = Math.Min(r.Width, 6);
using var path = RoundedRect(r, radius);
using var b = new SolidBrush(color);
g.FillPath(b, path);
}
private static GraphicsPath RoundedRect(Rectangle r, int radius)
{
int d = radius * 2;
var p = new GraphicsPath();
if (d <= 0 || d > r.Width || d > r.Height) { p.AddRectangle(r); return p; }
p.AddArc(r.X, r.Y, d, d, 180, 90);
p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
p.CloseFigure();
return p;
}
// ── 滑鼠互動 ─────────────────────────────────────────────
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left || !Scrollable) return;
var thumb = ThumbRect();
if (thumb.Contains(e.Location))
{
_dragging = true;
_dragOffset = e.Y - thumb.Top;
}
else
{
// 點軌道:往點擊方向翻一頁
SetValueFromUser(e.Y < thumb.Top ? _value - LargeChange : _value + LargeChange);
}
Invalidate();
}
protected override void OnMouseMove(MouseEventArgs e)
{
bool over = ThumbRect().Contains(e.Location);
if (over != _hover) { _hover = over; Invalidate(); }
if (!_dragging) return;
int travel = TrackHeight - ThumbHeight();
if (travel <= 0) return;
int range = MaxValue - Minimum;
int y = Math.Clamp(e.Y - _dragOffset - Pad, 0, travel);
SetValueFromUser(Minimum + (int)((long)y * range / travel));
}
protected override void OnMouseUp(MouseEventArgs e)
{
if (_dragging) { _dragging = false; Invalidate(); }
}
protected override void OnMouseLeave(EventArgs e)
{
if (_hover) { _hover = false; Invalidate(); }
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (!Scrollable) return;
int step = e.Delta / 120 * SmallChange * 3;
SetValueFromUser(_value - step);
}
private void SetValueFromUser(int v)
{
int nv = Clamp(v);
if (nv == _value) return;
_value = nv;
Invalidate();
Scroll?.Invoke(this, EventArgs.Empty);
}
}
+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());
}
} }
+415 -21
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,13 @@ 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 bool _suppressScroll; // 程式設定捲軸值時抑制回呼
private int _cellW, _cellH; private int _cellW, _cellH;
private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback
private int _lastCols = -1, _lastRows = -1; private int _lastCols = -1, _lastRows = -1;
@@ -26,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
@@ -33,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);
@@ -42,18 +79,31 @@ 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。內容寬度會扣掉捲軸寬,故文字不會被蓋住。
_vscroll = new DarkScrollBar { Dock = DockStyle.Right, SmallChange = 1, Minimum = 0, Maximum = 0 };
_vscroll.Scroll += OnVScroll;
Controls.Add(_vscroll);
} }
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary> /// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
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();
Invalidate(); Invalidate();
} }
private int ContentWidth => Math.Max(_cellW, ClientSize.Width - (_vscroll?.Width ?? 0));
private int VisibleRows => Math.Max(1, ClientSize.Height / _cellH); private int VisibleRows => Math.Max(1, ClientSize.Height / _cellH);
private int VisibleCols => Math.Max(1, ClientSize.Width / _cellW); private int VisibleCols => Math.Max(1, ContentWidth / _cellW);
// ── resize → 通知 PTY ──────────────────────────────────── // ── resize → 通知 PTY ────────────────────────────────────
protected override void OnSizeChanged(EventArgs e) protected override void OnSizeChanged(EventArgs e)
@@ -66,11 +116,14 @@ 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;
_buf.Resize(cols, rows); _buf.Resize(cols, rows);
Resized?.Invoke(cols, rows); Resized?.Invoke(cols, rows);
UpdateScrollBar();
Invalidate(); Invalidate();
} }
@@ -83,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;
@@ -106,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);
} }
@@ -144,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));
} }
@@ -164,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; }
@@ -171,11 +262,8 @@ public sealed class TerminalView : UserControl
} }
protected override void OnKeyPress(KeyPressEventArgs e) protected override void OnKeyPress(KeyPressEventArgs e)
{
if (!char.IsControl(e.KeyChar) || e.KeyChar is '\r' or '\t' or '\b' or '\x1b')
{ {
// \r,\t,\b,\x1b 已由 OnKeyDown 送出,這裡只送一般可列印字元與 Ctrl 組合碼 // \r,\t,\b,\x1b 已由 OnKeyDown 送出,這裡只送一般可列印字元與 Ctrl 組合碼
}
if (e.KeyChar >= ' ' && e.KeyChar != '\x7f') if (e.KeyChar >= ' ' && e.KeyChar != '\x7f')
SendData?.Invoke(Encoding.UTF8.GetBytes(e.KeyChar.ToString())); SendData?.Invoke(Encoding.UTF8.GetBytes(e.KeyChar.ToString()));
else if (char.IsControl(e.KeyChar) && e.KeyChar is not ('\r' or '\t' or '\b' or '\x1b')) else if (char.IsControl(e.KeyChar) && e.KeyChar is not ('\r' or '\t' or '\b' or '\x1b'))
@@ -186,11 +274,57 @@ 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();
Invalidate(); Invalidate();
} }
// ── 垂直捲軸 ─────────────────────────────────────────────
private void OnVScroll(object? sender, EventArgs e)
{
if (_suppressScroll) return;
// 捲軸值 = 視窗第一列的絕對 index_scrollOffset = 距底部的列數。
_scrollOffset = Math.Clamp(_buf.ScrollbackCount - _vscroll.Value, 0, _buf.ScrollbackCount);
Invalidate();
}
/// <summary>依目前 scrollback / 捲動位置同步捲軸的範圍與滑塊位置。</summary>
private void UpdateScrollBar()
{
if (_vscroll is null) return;
_suppressScroll = true;
try
{
int visible = VisibleRows;
int total = _buf.TotalRows;
if (_buf.ScrollbackCount <= 0)
{
_vscroll.Enabled = false;
_vscroll.LargeChange = 1;
_vscroll.Maximum = 0;
_vscroll.Value = 0;
return;
}
_vscroll.Enabled = true;
// 先設 LargeChange/Maximum 再設 Value,避免 Value 超出可達範圍被夾掉。
_vscroll.LargeChange = Math.Max(1, visible);
_vscroll.Maximum = Math.Max(0, total - 1);
int top = _buf.ScrollbackCount - _scrollOffset; // 視窗第一列絕對 index
int maxValue = Math.Max(0, _vscroll.Maximum - _vscroll.LargeChange + 1);
_vscroll.Value = Math.Clamp(top, 0, maxValue);
}
finally { _suppressScroll = false; }
}
protected override void OnMouseDown(MouseEventArgs e) protected override void OnMouseDown(MouseEventArgs e)
{ {
Focus(); Focus();
@@ -202,7 +336,8 @@ public sealed class TerminalView : UserControl
} }
else if (e.Button == MouseButtons.Right) else if (e.Button == MouseButtons.Right)
{ {
if (_hasSel) CopySelection(); else Paste(); if (_hasSel) { CopySelection(); _hasSel = false; Invalidate(); } // 複製後清掉反白,讓 user 知道已動作
else Paste();
} }
} }
@@ -264,12 +399,258 @@ public sealed class TerminalView : UserControl
{ {
try try
{ {
if (Clipboard.ContainsText()) if (!Clipboard.ContainsText()) return;
SendData?.Invoke(Encoding.UTF8.GetBytes(Clipboard.GetText().Replace("\r\n", "\r"))); // 換行統一成 CR:剪貼簿多為 \r\n,也處理單獨的 \n。
var text = Clipboard.GetText().Replace("\r\n", "\r").Replace('\n', '\r');
// 應用程式啟用 bracketed pastePSReadLine / Kiro CLI 等)時,以 ESC[200~ … ESC[201~
// 包夾整段貼上內容,讓對方視為「單次貼上」而非逐行 Enter 立即送出;
// 未啟用時才退回原本逐字送出(一般 shell 的預期行為)。
var data = _parser.BracketedPaste
? Encoding.UTF8.GetBytes("\x1b[200~" + text + "\x1b[201~")
: Encoding.UTF8.GetBytes(text);
SendData?.Invoke(data);
} }
catch { } catch { }
} }
// ── 搜尋(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;
@@ -339,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"