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 { }
}