diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index be208fb..f2adc82 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -131,7 +131,8 @@ ETTerms/ │ ├── Terminal/ # ── 終端機渲染 ── │ ├── 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, 屬性/顏色) │ └── TerminalInput.cs # 鍵盤 → byte 序列 (含特殊鍵) │ @@ -295,6 +296,9 @@ ETTerms 是單視窗多分頁,沒有「路由」,以下以**功能面板** - 接收 channel bytes → `AnsiParser` → `ScreenBuffer` → 繪製 - 鍵盤輸入 → `TerminalInput` → byte 序列 → channel - 支援:scrollback、選取 / 複製、貼上、字型 / 配色(從 `TerminalProfile`) +- **捲動:** 滑鼠滾輪或右側自繪深色捲軸 `DarkScrollBar`(拖曳滑塊 / 點軌道翻頁);`ContentWidth` 扣掉捲軸寬避免文字被蓋,捲軸範圍 / 位置在 `Feed` / 滾輪 / resize 時同步 +- **貼上:** 對方啟用 bracketed paste(DEC mode 2004,如 PSReadLine / Kiro CLI)時,整段以 `ESC[200~ … ESC[201~` 包夾送出,避免多行貼上被逐行 Enter 立即送出;未啟用則逐字送出 +- **複製:** 右鍵複製選取內容後自動清除反白(提示已複製) ### Script Editor / Runner(Scripts 檢視) - 載入 / 編輯 `.ttl` 腳本(語法沿用 MyTeraTerm) diff --git a/CLAUDE.md b/CLAUDE.md index 08c9df3..bfb83bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,8 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台, **進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server:**GUI 持有 COM port,MCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)。打包待指示。 +**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 paste(PSReadLine / 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 server,read-modify-write 保留檔內其他設定、原子寫回;卡片附 CLI 驗證指令。`ETTerms.csproj` 加 publish target(`AfterTargets=Publish`),GUI publish 會自動把 MCP server 帶到子資料夾,與 `McpRegistrar.ResolveServerExe()` 解析路徑對齊。 diff --git a/src/ETTerms/App/AboutView.cs b/src/ETTerms/App/AboutView.cs index 0850d57..7548355 100644 --- a/src/ETTerms/App/AboutView.cs +++ b/src/ETTerms/App/AboutView.cs @@ -183,12 +183,18 @@ public sealed class AboutView : UserControl private static readonly ChangelogEntry[] Changelog = [ - new("0.3.0", new DateOnly(2026, 6, 8), "feat: PDU MCP server", + new("0.3.1", new DateOnly(2026, 6, 8), "Bugfix — terminal usability", [ - "New ETTerms.PduMcp server: AI agents (Kiro / Claude CLI) can now control SNMP PDU outlets directly.", - "Tools: pdu_connect / pdu_list / pdu_set_port / pdu_get_port / pdu_status / pdu_power_cycle / pdu_disconnect.", - "PDU runs over SNMP, so the AI can power-cycle a DUT without the GUI session being open.", - "Settings → AI MCP now registers both Serial and PDU MCP servers with one click.", + "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", [ diff --git a/src/ETTerms/ETTerms.csproj b/src/ETTerms/ETTerms.csproj index da3f8a9..78ac192 100644 --- a/src/ETTerms/ETTerms.csproj +++ b/src/ETTerms/ETTerms.csproj @@ -10,7 +10,7 @@ ETTerms - 0.3.0 + 0.3.1 ETTerms ETTerms Project diff --git a/src/ETTerms/Terminal/AnsiParser.cs b/src/ETTerms/Terminal/AnsiParser.cs index 179897c..89ba760 100644 --- a/src/ETTerms/Terminal/AnsiParser.cs +++ b/src/ETTerms/Terminal/AnsiParser.cs @@ -19,6 +19,10 @@ public sealed class AnsiParser /// DECCKM:application cursor keys(影響方向鍵送出序列)。 public bool AppCursorKeys { get; private set; } + /// DEC mode 2004:bracketed paste。啟用時貼上內容須以 ESC[200~ / ESC[201~ 包夾, + /// 讓 PSReadLine / Kiro CLI 等把多行貼上視為單一輸入而非逐行立即送出。 + public bool BracketedPaste { get; private set; } + public AnsiParser(ScreenBuffer buffer) => _b = buffer; public void Feed(byte[] data) @@ -138,6 +142,7 @@ public sealed class AnsiParser case 25: _b.CursorVisible = set; break; case 7: _b.AutoWrap = set; break; case 1: AppCursorKeys = set; break; + case 2004: BracketedPaste = set; break; case 47: case 1047: case 1049: if (set) _b.EnterAlt(); else _b.ExitAlt(); break; } diff --git a/src/ETTerms/Terminal/DarkScrollBar.cs b/src/ETTerms/Terminal/DarkScrollBar.cs new file mode 100644 index 0000000..c72a19d --- /dev/null +++ b/src/ETTerms/Terminal/DarkScrollBar.cs @@ -0,0 +1,161 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms; + +namespace ETTerms.Terminal; + +/// +/// 自繪深色垂直捲軸:細長、無箭頭按鈕、圓角滑塊,配合終端機深色主題。 +/// API(Minimum / Maximum / Value / LargeChange / SmallChange / Scroll)對齊 , +/// 方便直接替換。 只在「使用者操作」造成值變動時觸發(程式設定 Value 不觸發)。 +/// +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(); } } + } + + /// 使用者拖曳 / 點軌道 / 滾輪造成值變動時觸發。 + 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); + Width = 12; + BackColor = TrackColor; + } + + // ── 數值範圍工具 ───────────────────────────────────────── + /// 可達的最大 Value(與 VScrollBar 相同:Maximum - LargeChange + 1)。 + 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); + } +} diff --git a/src/ETTerms/Terminal/TerminalView.cs b/src/ETTerms/Terminal/TerminalView.cs index 4ff3759..5f33a9e 100644 --- a/src/ETTerms/Terminal/TerminalView.cs +++ b/src/ETTerms/Terminal/TerminalView.cs @@ -17,6 +17,8 @@ public sealed class TerminalView : UserControl private readonly ScreenBuffer _buf; private readonly AnsiParser _parser; private readonly Font _font; + private readonly DarkScrollBar _vscroll; // 右側深色垂直捲軸(可拖曳捲動 scrollback) + private bool _suppressScroll; // 程式設定捲軸值時抑制回呼 private int _cellW, _cellH; private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback private int _lastCols = -1, _lastRows = -1; @@ -42,6 +44,11 @@ public sealed class TerminalView : UserControl _buf = new ScreenBuffer(profile.Cols, profile.Rows, Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines); _parser = new AnsiParser(_buf); + + // 右側深色垂直捲軸:拖曳即捲動 scrollback。內容寬度會扣掉捲軸寬,故文字不會被蓋住。 + _vscroll = new DarkScrollBar { Dock = DockStyle.Right, SmallChange = 1, Minimum = 0, Maximum = 0 }; + _vscroll.Scroll += OnVScroll; + Controls.Add(_vscroll); } /// 餵入遠端資料(須在 UI thread 呼叫)。 @@ -49,11 +56,13 @@ public sealed class TerminalView : UserControl { _parser.Feed(data); _scrollOffset = 0; // 新輸出貼底 + UpdateScrollBar(); Invalidate(); } + private int ContentWidth => Math.Max(_cellW, ClientSize.Width - (_vscroll?.Width ?? 0)); 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 ──────────────────────────────────── protected override void OnSizeChanged(EventArgs e) @@ -71,6 +80,7 @@ public sealed class TerminalView : UserControl _lastCols = cols; _lastRows = rows; _buf.Resize(cols, rows); Resized?.Invoke(cols, rows); + UpdateScrollBar(); Invalidate(); } @@ -188,9 +198,47 @@ public sealed class TerminalView : UserControl { int delta = e.Delta / 120 * 3; _scrollOffset = Math.Clamp(_scrollOffset + delta, 0, _buf.ScrollbackCount); + UpdateScrollBar(); Invalidate(); } + // ── 垂直捲軸 ───────────────────────────────────────────── + private void OnVScroll(object? sender, EventArgs e) + { + if (_suppressScroll) return; + // 捲軸值 = 視窗第一列的絕對 index;_scrollOffset = 距底部的列數。 + _scrollOffset = Math.Clamp(_buf.ScrollbackCount - _vscroll.Value, 0, _buf.ScrollbackCount); + Invalidate(); + } + + /// 依目前 scrollback / 捲動位置同步捲軸的範圍與滑塊位置。 + 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) { Focus(); @@ -202,7 +250,8 @@ public sealed class TerminalView : UserControl } else if (e.Button == MouseButtons.Right) { - if (_hasSel) CopySelection(); else Paste(); + if (_hasSel) { CopySelection(); _hasSel = false; Invalidate(); } // 複製後清掉反白,讓 user 知道已動作 + else Paste(); } } @@ -264,8 +313,17 @@ public sealed class TerminalView : UserControl { try { - if (Clipboard.ContainsText()) - SendData?.Invoke(Encoding.UTF8.GetBytes(Clipboard.GetText().Replace("\r\n", "\r"))); + if (!Clipboard.ContainsText()) return; + // 換行統一成 CR:剪貼簿多為 \r\n,也處理單獨的 \n。 + var text = Clipboard.GetText().Replace("\r\n", "\r").Replace('\n', '\r'); + + // 應用程式啟用 bracketed paste(PSReadLine / 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 { } }