diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 0f39645d..fd3a5817 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -156,6 +156,28 @@ alternative: & "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State error -Message "npm test failed" ``` +For sub-step progress (Computer Use iterative loops, multi-step plans), +pass `-Step` / `-Total` / `-Detail` so the pill shows what the agent is +doing *right now*: + +```powershell +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "Computer Use" ` + -Step 3 -Total 12 -Detail "fill username field" +# → pill renders: "step 3/12 · fill username field" + +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "Bash" ` + -Step 5 -Detail "npm install" +# → pill renders: "step 5 · npm install" + +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State done -Message "Bash ok" +# → pill renders: "Bash ok" (no step → legacy behavior, backward compat) +``` + +All three params are optional and backward compatible. The detail field +replaces the message in the rendered pill when present (avoids stacking +"Bash ok · fill username"). See `skills/mcode-island/SKILL.md` for the +full semantics and the contract with the widget renderer. + ## Quick start 1. **Install** — copy this folder into your `~/.minimax/plugins/mcode-island/` diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 index 797813ba..0990f5b4 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 @@ -43,7 +43,10 @@ function Push-Island { [ValidateSet('idle','thinking','working','waiting','done','error')] [string]$State, - [string]$Message = '' + [string]$Message = '', + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) if (-not (Test-Path -LiteralPath $script:NotifyIsland)) { # Widget is not installed yet — silent no-op. The plugin's @@ -52,7 +55,7 @@ function Push-Island { return } try { - & $script:NotifyIsland -State $State -Message $Message 2>$null | Out-Null + & $script:NotifyIsland -State $State -Message $Message -Step $Step -Total $Total -Detail $Detail 2>$null | Out-Null } catch { # Hook must never block the agent on a notification failure. } @@ -97,6 +100,26 @@ function Format-ToolSummary { 'WebSearch' { $detail = [string]$Event.tool_input.query } 'Task' { $detail = [string]$Event.tool_input.description } 'NotebookEdit' { $detail = [string]$Event.tool_input.notebook_path } + # mcode-internal: Computer Use 抽 action + coordinate/text + # 例: "mcode-computer-use : click at (1024,768)" + # "mcode-computer-use : type 'hello'" + # 注意:coordinate 是 array,PowerShell 默认 $OFS=' ' 会让 + # "$coord" 渲染成 "(1024 768)" 不是 "(1024,768)"。必须 + # 显式 -join ','。' ' 在 pill 上看起来像数字被截断, + # 影响用户判断坐标。 + 'mcode-computer-use' { + $act = if ($Event.tool_input.action) { [string]$Event.tool_input.action } else { '' } + if ($Event.tool_input.coordinate) { + $coord = $Event.tool_input.coordinate + $coordStr = "($($coord -join ','))" + $detail = "$act at $coordStr" + } elseif ($Event.tool_input.text) { + $txt = [string]$Event.tool_input.text + $detail = "$act '$txt'" + } else { + $detail = $act + } + } default { $detail = '' } } } diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 index 034a31e5..be005f80 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 @@ -4,6 +4,9 @@ # Note: Fires after every tool call returns. Heuristic: if the # tool_result is empty or matches an error pattern, push # error; otherwise push done. Self-push calls are filtered. +# Per-tool summary (Format-ToolSummary) is split into +# Message=" ok|failed" and Detail=, so the pill +# renders "Bash ok · ls -la /tmp" instead of just "Bash ok". . "$PSScriptRoot\_lib.ps1" $evt = Read-HookStdin if (Test-IsSelfPush $evt) { exit 0 } @@ -20,9 +23,18 @@ if ($null -eq $result) { elseif ($s -match '^\s*(Error|ERROR|✕|Error:|\[ERROR\])') { $isError = $true } } +# Format-ToolSummary 抽 detail,但要剥掉 "tool : " 前缀,只留后半段 +$summary = Format-ToolSummary $evt +$detail = '' +if ($summary -and $summary.StartsWith("$tool : ")) { + $detail = $summary.Substring($tool.Length + 3) +} elseif ($summary -and $summary -ne $tool) { + $detail = $summary +} + if ($isError) { - Push-Island -State error -Message "$tool failed" + Push-Island -State error -Message "$tool failed" -Detail $detail } else { - Push-Island -State done -Message "$tool ok" + Push-Island -State done -Message "$tool ok" -Detail $detail } exit 0 diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 898322a7..4b7bbcb5 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -1,4 +1,4 @@ -# mcode 灵动岛 v1 - WPF + PowerShell +# mcode 灵动岛 v1 - WPF + PowerShell # 用法:右键 → 用 PowerShell 运行;或通过 start-island.ps1 启动 $ErrorActionPreference = 'Stop' @@ -73,9 +73,37 @@ public class WinAPI { [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] public static extern IntPtr MonitorFromWindow(IntPtr hWnd, uint dwFlags); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern bool GetMonitorInfoW(IntPtr hMonitor, ref MONITORINFO lpmi); + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct MONITORINFO { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szDevice; + } public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + public static readonly IntPtr HWND_TOP = new IntPtr(0); public const uint SWP_NOACTIVATE = 0x0010; + public const uint SWP_NOZORDER = 0x0004; + public const uint SWP_NOSIZE = 0x0001; + public const uint SWP_NOMOVE = 0x0002; + public const uint MONITOR_DEFAULTTONEAREST = 0x00000002; + + // 取窗口所在 monitor 的 work area。如果失败返回 (-1,-1)-(-1,-1) 表示无效。 + public static RECT GetWorkAreaForWindow(IntPtr hWnd) { + var bad = new RECT { Left = -1, Top = -1, Right = -1, Bottom = -1 }; + IntPtr hMon = MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST); + if (hMon == IntPtr.Zero) return bad; + var mi = new MONITORINFO(); + mi.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(mi); + if (!GetMonitorInfoW(hMon, ref mi)) return bad; + return mi.rcWork; + } // 找 pid 的第一个可见窗口 public static IntPtr FindVisibleWindowForPid(uint targetPid) { @@ -370,6 +398,34 @@ function Stop-IndeterminateShimmer { $script:progressShimmerTransform.X = -130 # 重置到起点 } +# Sub-step 渲染("step N[/M] · detail"): +# Step > 0 + Total > 0 → "step 3/12 · fill username" +# Step > 0 + Total <= 0 → "step 3 · fill username" +# Step > 0 + Detail 空 → "step 3/12" +# Step <= 0 → 原 Message 字段 +# 这样 message 字段保持"工具名"("Bash ok"),detail 字段填具体动作, +# 渲染时拼成 "step 3/12 · Bash ok · fill username" 或者更精确的 +# "step 3/12 · fill username"(detail 存在时优先覆盖 message)。 +# 注意:detail 非空时**完全替换** message,避免双重信息("Bash ok · ls -la")。 +function Build-DisplayMessage { + param( + [string]$Message, + [int]$Step, + [int]$Total, + [string]$Detail + ) + $base = if ($Message) { $Message } else { '' } + if ($Step -le 0) { return $base } + + $stepStr = if ($Total -gt 0) { "step $Step/$Total" } else { "step $Step" } + if ($Detail) { + # detail 非空时优先用 detail(agent 已经表达了"我在做什么") + return "$stepStr · $Detail" + } + # detail 空但 step 给出 → 只显示 step,避免重复 message 造成噪声 + return $stepStr +} + # 状态更新 # Progress 取值约定(跟 notify-island.ps1 / detector 对齐): # -1 → 没有进度信息,进度条隐藏 @@ -377,6 +433,14 @@ function Stop-IndeterminateShimmer { # Usage5h:剩余百分比(0..100);-2 = 未提供 # Usage5hResetMs:距下次 5h 刷新的毫秒数;0 = 未知 # TodoProgress:todowrite 列表的完成百分比(0..100);-2 = 未提供 +# Step/Total/Detail:sub-step 进度(agent 自报),参 Build-DisplayMessage +# - 优先级:显式 Progress > TodoProgress > shimmer +# - 即:agent 直接传 progress 最高;否则如果有 todo 列表就用 todo 完成度;都没就 shimmer 动画 +# -1 → 没有进度信息,进度条隐藏 +# 0..100 → 百分比,0=空条,100=满条;超出范围会被 clamp +# Usage5h:剩余百分比(0..100);-2 = 未提供 +# Usage5hResetMs:距下次 5h 刷新的毫秒数;0 = 未知 +# TodoProgress:todowrite 列表的完成百分比(0..100);-2 = 未提供 # - 优先级:显式 Progress > TodoProgress > shimmer # - 即:agent 直接传 progress 最高;否则如果有 todo 列表就用 todo 完成度;都没就 shimmer 动画 function Update-State { @@ -386,14 +450,17 @@ function Update-State { [int]$Progress = -1, [int]$Usage5h = -2, [int]$Usage5hResetMs = 0, - [int]$TodoProgress = -2 + [int]$TodoProgress = -2, + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) $s = $script:stateMap[$State] if (!$s) { $s = $script:stateMap['idle'] } $script:statusDot.Fill = C $s.dot $script:pulseRing.Fill = C $s.ring $script:stateText.Text = $s.label - $script:messageText.Text = if ($Message) { $Message } else { '' } + $script:messageText.Text = Build-DisplayMessage -Message $Message -Step $Step -Total $Total -Detail $Detail $script:actionIcon.Text = $s.icon if ($State -in @('thinking','working','waiting')) { Start-Pulse } else { Stop-Pulse } @@ -477,10 +544,12 @@ function Update-State { "[$ts] $State :: $Message$progTag" | Add-Content -Path $script:logFile -Encoding UTF8 } -# 切回调用方窗口(点击 pill 时调用) -function Focus-CallerWindow { +# 解析调用方窗口(caller.json → targetHwnd / targetPid)。 +# 处理三种死法:hwnd 死了 / 进程死了(fallback 到父进程 terminal)/ +# hwnd 被销毁重建。返回 [PSCustomObject]@{ Hwnd; Pid; Exe } 或 $null。 +function Resolve-CallerWindow { $callerFile = Join-Path $env:APPDATA 'mcode-island\caller.json' - if (!(Test-Path $callerFile)) { Dbg 'FOCUS: no caller file'; return } + if (!(Test-Path $callerFile)) { Dbg 'RESOLVE: no caller file'; return $null } $hwnd = [IntPtr]::Zero $targetPid = 0 @@ -491,72 +560,129 @@ function Focus-CallerWindow { $targetPid = [int]$data.targetPid $targetExe = if ($data.targetExe) { [string]$data.targetExe } else { '' } } catch { - Dbg "FOCUS: caller.json parse error" - return + Dbg "RESOLVE: caller.json parse error" + return $null } - if ($targetPid -le 0) { Dbg 'FOCUS: no target'; return } + if ($targetPid -le 0) { Dbg 'RESOLVE: no target'; return $null } - # 1) 检查 hwnd 是否还活着 + # 1) hwnd 死了 → 重找 if ($hwnd -ne [IntPtr]::Zero -and -not [WinAPI]::IsWindow($hwnd)) { - Dbg "FOCUS: hwnd $hwnd dead, re-resolving" + Dbg "RESOLVE: hwnd $hwnd dead, re-resolving" $hwnd = [IntPtr]::Zero } - # 2) 进程死了 → 找它的父进程(terminal)兜底 + # 2) 进程死了 → fallback 到父进程(terminal)兜底 $proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue if (-not $proc) { - Dbg "FOCUS: target PID $targetPid gone, finding parent (terminal)" + Dbg "RESOLVE: target PID $targetPid gone, finding parent (terminal)" $parent = Get-CimInstance Win32_Process -Filter "ProcessId=$targetPid" -ErrorAction SilentlyContinue if ($parent -and $parent.ParentProcessId -and $parent.ParentProcessId -gt 0) { $parentProc = Get-Process -Id ([int]$parent.ParentProcessId) -ErrorAction SilentlyContinue if ($parentProc) { $targetPid = $parentProc.Id $targetExe = $parentProc.ProcessName - # 优先用 MainWindowHandle,失败就用第一个可见窗口 if ($parentProc.MainWindowHandle -ne [IntPtr]::Zero) { $hwnd = $parentProc.MainWindowHandle } else { $hwnd = [WinAPI]::FindVisibleWindowForPid([uint32]$targetPid) } - Dbg "FOCUS: fall back to parent $($parentProc.ProcessName) PID=$targetPid hwnd=$hwnd" + Dbg "RESOLVE: fall back to parent $($parentProc.ProcessName) PID=$targetPid hwnd=$hwnd" } } if ($hwnd -eq [IntPtr]::Zero) { - Dbg 'FOCUS: no parent fallback available' - return + Dbg 'RESOLVE: no parent fallback available' + return $null } } - # 3) 进程还在但 hwnd 死了(被销毁/重建)→ 找进程的第一个可见窗口 + + # 3) 进程还在但 hwnd 死了(被销毁/重建)→ 找新可见窗口 if ($hwnd -eq [IntPtr]::Zero -or -not [WinAPI]::IsWindow($hwnd)) { - Dbg "FOCUS: hwnd invalid, finding new visible window for PID $targetPid ($targetExe)" + Dbg "RESOLVE: hwnd invalid, finding new visible window for PID $targetPid ($targetExe)" $hwnd = [WinAPI]::FindVisibleWindowForPid([uint32]$targetPid) if ($hwnd -eq [IntPtr]::Zero) { - Dbg 'FOCUS: no visible window found for target process' - return + Dbg 'RESOLVE: no visible window found for target process' + return $null } - Dbg "FOCUS: re-resolved to hwnd $hwnd" + Dbg "RESOLVE: re-resolved to hwnd $hwnd" } + return [PSCustomObject]@{ Hwnd = $hwnd; Pid = $targetPid; Exe = $targetExe } +} + +# 强制把调用方窗口拉到前台(modern Windows 要求 AllowSetForegroundWindow)。 +# 不管当前 visible 与否,都做 show + focus。Focus-CallerWindow 保留, +# 因为它是 Resolve-CallerWindow + 强制 show 的最小封装,可用于自动聚焦 +# 流程(needs_input 状态自动弹窗那种)。 +function Focus-CallerWindow { + $r = Resolve-CallerWindow + if (-not $r) { return } + try { - # 1) 授权目标进程可以切前台(modern Windows 强制) - [WinAPI]::AllowSetForegroundWindow([uint32]$targetPid) | Out-Null - # 2) 最小化就还原 - if ([WinAPI]::IsIconic($hwnd)) { - [WinAPI]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE + [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null + if ([WinAPI]::IsIconic($r.Hwnd)) { + [WinAPI]::ShowWindow($r.Hwnd, 9) | Out-Null # SW_RESTORE } - # 3) 设顶 - [WinAPI]::SetWindowPos($hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null - [WinAPI]::SetWindowPos($hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST - # 4) 抢焦点 - [WinAPI]::BringWindowToTop($hwnd) | Out-Null - [WinAPI]::SetForegroundWindow($hwnd) | Out-Null - $proc2 = Get-Process -Id $targetPid -ErrorAction SilentlyContinue - Dbg ("FOCUS OK: target=" + $proc2.ProcessName + " PID=" + $targetPid + " hwnd=" + $hwnd) + [WinAPI]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null + [WinAPI]::SetWindowPos($r.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST + [WinAPI]::BringWindowToTop($r.Hwnd) | Out-Null + [WinAPI]::SetForegroundWindow($r.Hwnd) | Out-Null + $proc2 = Get-Process -Id $r.Pid -ErrorAction SilentlyContinue + Dbg ("FOCUS OK: target=" + $proc2.ProcessName + " PID=" + $r.Pid + " hwnd=" + $r.Hwnd) } catch { Dbg "FOCUS FAIL: $($_.Exception.Message)" } } +# 单击 pill toggle:可见 → 隐藏;隐藏 → 全屏还原 + 抢焦点。 +# 设计取舍 (round-14+15+16): +# hide 分支用 SW_HIDE (而不是 SW_MINIMIZE): +# SW_MINIMIZE 在某些终端配置下(Windows Terminal "Always show tabs on top") +# 会保留一个 thin tab-bar strip 浮在桌面顶部,不算真"藏"。 +# show 分支先 SW_MAXIMIZE (激活+最大化),再用 SetWindowPos 强制拉到 +# MonitorFromWindow+GetMonitorInfo 拿到的真实 work area(2560x1392 而 +# 不是 [Screen]::PrimaryScreen 报告的 1920x1080 — WinForms DPI 虚拟化 +# 会把 2560x1440 物理像素报成 1920x1080 逻辑像素,SW_MAXIMIZE 跟着 +# 1920x1080 走,结果 WT 只填了物理显示器的左上 75%)。 +# 最后 SetWindowPos(HWND_TOP) + BringWindowToTop 抢 z-order,绕过 +# widget PID 没有 foreground 权限的限制。 +# 状态判定: IsWindowVisible 在 SW_HIDE 和 SW_MINIMIZE 后都返回 false +# (区别是 IsIconic:SW_HIDE 后 false,SW_MINIMIZE 后 true)。toggle 只看 +# IsWindowVisible 即可,SW_MAXIMIZE 在内部正确处理两种 case。 +function Toggle-CallerWindow { + $r = Resolve-CallerWindow + if (-not $r) { return } + + try { + $isShown = [WinAPI]::IsWindowVisible($r.Hwnd) + if ($isShown) { + [WinAPI]::ShowWindow($r.Hwnd, 0) | Out-Null # SW_HIDE + Dbg "TOGGLE: hid target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + } else { + # 1) SW_MAXIMIZE 激活+标记 maximized + [WinAPI]::ShowWindow($r.Hwnd, 3) | Out-Null # SW_MAXIMIZE + # 2) SetWindowPos 强制拉到 monitor work area (绕过 DPI/remembered-size 限制) + $wa = [WinAPI]::GetWorkAreaForWindow($r.Hwnd) + if ($wa.Left -ne -1) { + $cx = $wa.Right - $wa.Left + $cy = $wa.Bottom - $wa.Top + [WinAPI]::SetWindowPos($r.Hwnd, [IntPtr]::Zero, $wa.Left, $wa.Top, $cx, $cy, [WinAPI]::SWP_NOZORDER) | Out-Null + Dbg "TOGGLE: forced to work area ({0},{1}) {2}x{3}" -f $wa.Left, $wa.Top, $cx, $cy + } + # 3) 抢 z-order 到最前(SetWindowPos(HWND_TOP) 不需要 foreground 权限) + # 注意:cx=0/cy=0 + 缺 SWP_NOSIZE 会被 Windows 当成"resize 到 0x0", + # 触发 WT 的 min-size 兜底,变成 480x76 strip。必须加 SWP_NOSIZE。 + [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null + $nofollow = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOZORDER -bor [WinAPI]::SWP_NOSIZE + [WinAPI]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOP, 0, 0, 0, 0, $nofollow) | Out-Null + [WinAPI]::BringWindowToTop($r.Hwnd) | Out-Null + [WinAPI]::SetForegroundWindow($r.Hwnd) | Out-Null + Dbg "TOGGLE: shown (maximized + work-area) target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + } + } catch { + Dbg "TOGGLE FAIL: $($_.Exception.Message)" + } +} + # 手动设置焦点目标(右键菜单调用):把当前前台窗口记为 focus target function Set-FocusTarget-Current { Add-Type @" @@ -653,7 +779,7 @@ $window.Add_MouseLeftButtonUp({ if ($script:dragStart -and -not $script:didDrag) { Dbg 'CLICK detected' Flash-Click - Focus-CallerWindow + Toggle-CallerWindow } } catch { Dbg "CLICK FAIL: $($_.Exception.Message)" @@ -689,18 +815,25 @@ $timer.Add_Tick({ $script:lastStatusMtime = $mtime $data = Get-Content $statusFile -Raw -Encoding UTF8 | ConvertFrom-Json # progress 也要进 sig,否则 agent 连续推 working+相同 message+不同 progress 会被去重 + # step/total/detail 也要进 sig,否则连续推同 state 但不同 step 会被去重 $prog = if ($data.PSObject.Properties['progress']) { [int]$data.progress } else { -1 } $usage = $null $resetMs = 0 $todoP = -2 + $step = -1 + $total = -1 + $detail = '' if ($data.PSObject.Properties['usage5h'] -and $null -ne $data.usage5h) { $usage = [int]$data.usage5h } if ($data.PSObject.Properties['usage5hResetMs'] -and $null -ne $data.usage5hResetMs) { $resetMs = [int]$data.usage5hResetMs } if ($data.PSObject.Properties['todoProgress'] -and $null -ne $data.todoProgress) { $todoP = [int]$data.todoProgress } - $sig = "$($data.state)|$($data.message)|$prog|$usage|$resetMs|$todoP|$($data.ts)" + if ($data.PSObject.Properties['step'] -and $null -ne $data.step) { $step = [int]$data.step } + if ($data.PSObject.Properties['total'] -and $null -ne $data.total) { $total = [int]$data.total } + if ($data.PSObject.Properties['detail'] -and $null -ne $data.detail) { $detail = [string]$data.detail } + $sig = "$($data.state)|$($data.message)|$prog|$usage|$resetMs|$todoP|$step|$total|$detail|$($data.ts)" if ($sig -eq $script:lastStatusSig) { return } $script:lastStatusSig = $sig - Dbg "POLL: $($data.state) :: $($data.message) (progress=$prog usage5h=$usage resetMs=$resetMs todoProgress=$todoP)" - Update-State -State $data.state -Message $data.message -Progress $prog -Usage5h $usage -Usage5hResetMs $resetMs -TodoProgress $todoP + Dbg "POLL: $($data.state) :: $($data.message) step=$step/$total detail=$detail (progress=$prog usage5h=$usage resetMs=$resetMs todoProgress=$todoP)" + Update-State -State $data.state -Message $data.message -Progress $prog -Usage5h $usage -Usage5hResetMs $resetMs -TodoProgress $todoP -Step $step -Total $total -Detail $detail } catch { Dbg "POLL ERR: $($_.Exception.Message)" } @@ -717,12 +850,18 @@ if (Test-Path $statusFile) { $initUsage = $null $initReset = 0 $initTodo = -2 + $initStep = -1 + $initTotal = -1 + $initDetail = '' if ($init.PSObject.Properties['usage5h'] -and $null -ne $init.usage5h) { $initUsage = [int]$init.usage5h } if ($init.PSObject.Properties['usage5hResetMs'] -and $null -ne $init.usage5hResetMs) { $initReset = [int]$init.usage5hResetMs } if ($init.PSObject.Properties['todoProgress'] -and $null -ne $init.todoProgress) { $initTodo = [int]$init.todoProgress } - $script:lastStatusSig = "$($init.state)|$($init.message)|$initProg|$initUsage|$initReset|$initTodo|$($init.ts)" + if ($init.PSObject.Properties['step'] -and $null -ne $init.step) { $initStep = [int]$init.step } + if ($init.PSObject.Properties['total'] -and $null -ne $init.total) { $initTotal = [int]$init.total } + if ($init.PSObject.Properties['detail'] -and $null -ne $init.detail) { $initDetail = [string]$init.detail } + $script:lastStatusSig = "$($init.state)|$($init.message)|$initProg|$initUsage|$initReset|$initTodo|$initStep|$initTotal|$initDetail|$($init.ts)" $script:lastStatusMtime = (Get-Item $statusFile).LastWriteTimeUtc.Ticks - Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo + Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo -Step $initStep -Total $initTotal -Detail $initDetail } catch {} } else { Update-State -State 'idle' -Message '' diff --git a/plugins/antianqi/mcode-island/notify-island.ps1 b/plugins/antianqi/mcode-island/notify-island.ps1 index f20451e7..1b9f7d71 100644 --- a/plugins/antianqi/mcode-island/notify-island.ps1 +++ b/plugins/antianqi/mcode-island/notify-island.ps1 @@ -10,7 +10,10 @@ param( [ValidateSet('idle','thinking','working','waiting','done','error')] [string]$State = 'idle', [string]$Message = '', - [int]$Progress = -1 + [int]$Progress = -1, + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) $ErrorActionPreference = 'Stop' @@ -113,6 +116,9 @@ $payload = [PSCustomObject]@{ state = $State message = $Message progress = $Progress + step = $Step + total = $Total + detail = $Detail ts = $ts source = 'agent' } | ConvertTo-Json -Compress diff --git a/plugins/antianqi/mcode-island/plugin.json b/plugins/antianqi/mcode-island/plugin.json index f39491dc..cc3c26f8 100644 --- a/plugins/antianqi/mcode-island/plugin.json +++ b/plugins/antianqi/mcode-island/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "mcode-island", - "version": "0.3.0", - "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", + "version": "0.4.0", + "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.4.0 扩展 status.json schema 增加 step/total/detail 三个字段并更新 widget 渲染:agent 现在可以推送 'step 3/12 · fill username field' 这类 sub-step 进度,Computer Use 多步循环、长任务分阶段展示。Backward compat:所有新字段 optional。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", "author": { "name": "antianqi", "url": "https://github.com/antianqi" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index e478922d..48c8c26d 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -155,7 +155,7 @@ const checkEntry = async (event, entry) => { }; const main = async () => { - console.log(`mcode-island v0.3.0 self-check`); + console.log(`mcode-island v0.4.0 self-check`); console.log(`plugin root: ${PLUGIN_ROOT}`); console.log('-'.repeat(60)); @@ -182,8 +182,8 @@ const main = async () => { } else { out('PASS', `plugin.json: name is "${plugin.name}"`); } - if (plugin.version !== '0.3.0') { - out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.3.0"`); + if (plugin.version !== '0.4.0') { + out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.4.0"`); } else { out('PASS', `plugin.json: version is "${plugin.version}"`); } @@ -338,6 +338,165 @@ const main = async () => { } } + // 5c1. Sub-step progress extension (round-13 refactor). + // notify-island.ps1 must accept -Step/-Total/-Detail and write them + // into status.json. mcode-island.ps1 widget must define + // Build-DisplayMessage and pass step/total/detail to it. + // _lib.ps1 Format-ToolSummary must extract mcode-computer-use + // action+coordinate; Push-Island must forward the new fields. + // The detailed functional tests live in test-substep-progress.mjs; + // here we lock the surface contract so a future refactor that + // drops the params surfaces in smoke (fast path) before reaching + // the slower pwsh-spawned tests. + const substepNotifyPath = join(PLUGIN_ROOT, 'notify-island.ps1'); + const substepWidgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + if (!(await exists(substepNotifyPath))) { + out('FAIL', 'notify-island.ps1 missing (sub-step lock skipped)'); + } else { + const notify = await readFile(substepNotifyPath, 'utf8'); + if (!/\[int\]\$Step\s*=\s*-1/.test(notify) || + !/\[int\]\$Total\s*=\s*-1/.test(notify) || + !/\[string\]\$Detail\s*=\s*''/.test(notify)) { + out('FAIL', 'notify-island.ps1: missing -Step/-Total/-Detail params'); + } else { + out('PASS', 'notify-island.ps1: declares -Step -Total -Detail'); + } + if (!/step\s*=\s*\$Step/.test(notify) || + !/total\s*=\s*\$Total/.test(notify) || + !/detail\s*=\s*\$Detail/.test(notify)) { + out('FAIL', 'notify-island.ps1: status.json payload missing step/total/detail fields'); + } else { + out('PASS', 'notify-island.ps1: writes step/total/detail to status.json'); + } + } + if (await exists(substepWidgetPath)) { + const widget = await readFile(substepWidgetPath, 'utf8'); + if (!/function Build-DisplayMessage/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Build-DisplayMessage function missing'); + } else { + out('PASS', 'mcode-island.ps1: Build-DisplayMessage function present'); + } + if (!/\[int\]\$Step\s*=\s*-1/.test(widget) || + !/\[int\]\$Total\s*=\s*-1/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Update-State missing Step/Total params'); + } else { + out('PASS', 'mcode-island.ps1: Update-State accepts Step/Total/Detail'); + } + } + const substepTestPath = join(PLUGIN_ROOT, 'scripts', 'test-substep-progress.mjs'); + if (!(await exists(substepTestPath))) { + out('WARN', 'scripts/test-substep-progress.mjs missing (sub-step detailed tests not run)'); + } else { + out('PASS', 'scripts/test-substep-progress.mjs exists (run separately for full suite)'); + } + + // 5c2. Click toggle extension (round-14 refactor). + // The pill's MouseLeftButtonUp must call Toggle-CallerWindow, NOT + // Focus-CallerWindow. Single-click show is one-way and forces the + // CLI to the front every time the user clicks, which is wrong for + // "I clicked the pill to hide the CLI" — the second click would + // re-show it and surprise the user. Toggle semantics match the + // user's "单击收起单击调出" mental model. + // Drift lock: Resolve-CallerWindow + Toggle-CallerWindow must + // exist as named functions (refactor target), and the click + // handler must invoke Toggle-CallerWindow, not Focus-CallerWindow. + if (await exists(substepWidgetPath)) { + const widget = await readFile(substepWidgetPath, 'utf8'); + if (!/function Resolve-CallerWindow\b/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Resolve-CallerWindow function missing (toggle refactor target)'); + } else { + out('PASS', 'mcode-island.ps1: Resolve-CallerWindow function present'); + } + if (!/function Toggle-CallerWindow\b/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Toggle-CallerWindow function missing'); + } else { + out('PASS', 'mcode-island.ps1: Toggle-CallerWindow function present'); + } + // Toggle-CallerWindow must dispatch on IsWindowVisible (the + // core visibility check). A regression that always calls + // ShowWindow(SW_HIDE) without checking state would silently + // break the toggle (every click = hide, never show). + if (!/IsWindowVisible\s*\(\s*\$r\.Hwnd\s*\)/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Toggle-CallerWindow does not check IsWindowVisible'); + } else { + out('PASS', 'mcode-island.ps1: Toggle-CallerWindow gates on IsWindowVisible'); + } + // The click handler must call Toggle-CallerWindow, not Focus. + // We anchor on the MouseLeftButtonUp event to scope the check. + const clickMatch = widget.match(/Add_MouseLeftButtonUp\([\s\S]*?\}\s*\)\s*$/m); + if (!clickMatch) { + out('WARN', 'mcode-island.ps1: Add_MouseLeftButtonUp handler not found (drift lock skipped)'); + } else if (!/Toggle-CallerWindow\b/.test(clickMatch[0])) { + out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp does not invoke Toggle-CallerWindow (still using Focus-only)'); + } else if (/Focus-CallerWindow\b/.test(clickMatch[0])) { + out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp invokes both Toggle and Focus — pick one'); + } else { + out('PASS', 'mcode-island.ps1: MouseLeftButtonUp invokes Toggle-CallerWindow (single click toggles show/hide)'); + } + + // Round-15: Toggle's restore branch must call SW_MAXIMIZE (3), not + // SW_SHOW (5) / SW_RESTORE (9). SW_HIDE preserves the window's + // "non-maximized size"; if the WT window got accidentally resized + // to a thin strip (e.g., 480x84 from a snap gesture or our own + // mouse_event test artifacts), SW_SHOW / SW_RESTORE would re-show + // it as that strip — the user's complaint was "hide works, show is + // a thin strip". SW_MAXIMIZE forces full-screen on hidden / + // minimized / normal windows alike; no-op on already-maximized. + const toggleMatch = widget.match(/function Toggle-CallerWindow[\s\S]*?\n\}\n/); + if (!toggleMatch) { + out('WARN', 'mcode-island.ps1: Toggle-CallerWindow body not found (drift lock skipped)'); + } else { + const toggleBody = toggleMatch[0]; + // Extract the `else` branch (the restore path) so the check + // is anchored on the show branch, not the hide branch (which + // intentionally uses SW_HIDE=0). + const elseMatch = toggleBody.match(/else\s*\{([\s\S]*?)\n\s*\}\s*\n\s*\}\s*$/m); + const restoreBody = elseMatch ? elseMatch[1] : ''; + if (!restoreBody) { + out('FAIL', 'mcode-island.ps1: Toggle-CallerWindow else branch not parseable'); + } else if (!/ShowWindow\(\s*\$r\.Hwnd\s*,\s*3\s*\)/.test(restoreBody)) { + out('FAIL', 'mcode-island.ps1: Toggle restore branch does not call SW_MAXIMIZE (ShowWindow(_, 3)). A regression to SW_SHOW (5) or SW_RESTORE (9) re-shows the window at its pre-hide size (e.g., 480x84 strip if WT got accidentally resized).'); + } else if (/ShowWindow\(\s*\$r\.Hwnd\s*,\s*5\s*\)/.test(restoreBody)) { + out('FAIL', 'mcode-island.ps1: Toggle restore branch calls SW_SHOW (5) in addition to SW_MAXIMIZE — keep only SW_MAXIMIZE; SW_SHOW re-shows at pre-hide size and defeats the maximize intent.'); + } else { + out('PASS', 'mcode-island.ps1: Toggle restore branch forces SW_MAXIMIZE (full-screen on show, fixes 480x84 strip bug)'); + } + + // Round-16: Toggle's restore branch must also force the window + // to fill the actual monitor work area (MonitorFromWindow + + // GetMonitorInfo + SetWindowPos). SW_MAXIMIZE alone is + // insufficient on multi-monitor + DPI-virtualized setups: the + // user's primary monitor is physically 2560x1440, but WinForms + // [Screen]::PrimaryScreen reports 1920x1080 (DPI virtualization). + // SW_MAXIMIZE follows the 1920x1080 number and leaves WT at + // ~75% of the physical screen — visually "in the top-left corner" + // of the user's 2K monitor. The drift lock forces the explicit + // SetWindowPos path. + if (!/GetWorkAreaForWindow|GetMonitorInfo|MonitorFromWindow/.test(toggleBody)) { + out('FAIL', 'mcode-island.ps1: Toggle restore branch does not query monitor work area. Without MonitorFromWindow + SetWindowPos(explicit size), SW_MAXIMIZE alone fills only the WinForms 1920x1080 logical work area, not the actual 2560x1440 physical monitor — leaves WT at the top-left 75%.'); + } else if (!/SetWindowPos\([^)]*\$wa\.|SetWindowPos\(\$r\.Hwnd,[^,]+,\s*\$wa\.Left,\s*\$wa\.Top,\s*\$cx,\s*\$cy/.test(toggleBody)) { + out('FAIL', 'mcode-island.ps1: Toggle restore branch has monitor query but does not SetWindowPos with work-area coords. The contract is: read monitor work area, then SetWindowPos with explicit (Left, Top, cx, cy) — never rely on SW_MAXIMIZE alone for size.'); + } else { + out('PASS', 'mcode-island.ps1: Toggle restore branch forces work-area size via MonitorFromWindow + SetWindowPos (fills 2560x1440 physical monitor, not just 1920x1080 logical)'); + } + + // Round-17: the follow-up z-order SetWindowPos call (HWND_TOP + // to push WT forward without foreground permission) MUST carry + // SWP_NOSIZE. Without it, cx=0/cy=0 is interpreted as "resize + // to 0x0", triggering WT's min-size fallback to a 480x76 strip — + // exactly the regression the user saw. Verified empirically: + // a click via computer-use on the live widget left WT at 480x76 + // despite the work-area SetWindowPos having run a few ms earlier. + if (!/SWP_NOSIZE\s*=\s*0x0001/.test(widget)) { + out('FAIL', 'mcode-island.ps1: WinAPI class missing SWP_NOSIZE constant (0x0001).'); + } else if (!/SWP_NOZORDER\s*-bor\s*\[WinAPI\]::SWP_NOSIZE/.test(toggleBody)) { + out('FAIL', 'mcode-island.ps1: Toggle z-order SetWindowPos(HWND_TOP) does not include SWP_NOSIZE. cx=0/cy=0 will resize WT to 0x0 and trigger its 480x76 min-size fallback.'); + } else { + out('PASS', 'mcode-island.ps1: Toggle z-order SetWindowPos carries SWP_NOSIZE (won\'t trigger WT min-size 480x76 fallback)'); + } + } + } + // 5b. Drift lock: permission-request.ps1 must emit `{"decision":"ask"}`, // not `allow` or `deny`. The 0.2.4 Runtime default for PermissionRequest // is fail-closed; an observer Hook that returns `allow` or `deny` diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs new file mode 100644 index 00000000..8e54f567 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -0,0 +1,387 @@ +#!/usr/bin/env node +// mcode-island v0.4.x — negative-first self-check for the sub-step +// progress extension (status.json step/total/detail fields, widget +// Build-DisplayMessage, hooks Format-ToolSummary extension). +// +// Cross-platform (Windows / macOS / Linux). No deps beyond Node >= 18. +// +// Exit 0 on full pass, 1 on any failure. Per-check line prints +// PASS / FAIL with the failing expectation. +// +// Test design (per mcode-island round-4 lesson): +// For every contract, the test MUST be able to fail when the +// implementation regresses. We achieve this by: +// 1. Asserting the *observable* contract (status.json shape, +// PowerShell function output) not the internal call graph +// 2. Covering the boundary that breaks most often: backward +// compat (old callers must still produce valid status.json) + +import { spawn } from 'node:child_process'; +import { mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { strict as assert } from 'node:assert'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = resolve(__dirname, '..'); +const NOTIFY_ISLAND = join(PLUGIN_ROOT, 'notify-island.ps1'); +const WIDGET = join(PLUGIN_ROOT, 'mcode-island.ps1'); +const LIB = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'); + +// Cross-platform pwsh lookup: prefer pwsh7 install in $HOME, fall back +// to PATH-resolved `pwsh`. We do NOT hardcode C:\ paths in production +// (the smoke.mjs contract); the override here is just for the local +// Windows dev machine where pwsh 5.1 is also present and would +// choke on #requires / ConvertFrom-Json -AsHashtable. +function findPwsh() { + if (process.platform === 'win32') { + const home = process.env.USERPROFILE || process.env.HOME || ''; + const candidates = [ + join(home, 'pwsh7_6', 'pwsh.exe'), + join(home, 'pwsh', 'pwsh.exe'), + ]; + return candidates[0]; // best-effort; if missing, spawn falls back to PATH + } + return 'pwsh'; +} + +const PWSH = findPwsh(); + +let pass = 0, fail = 0; +const out = (tag, msg) => { + const sym = { PASS: 'OK ', FAIL: 'FAIL' }[tag]; + console.log(`[${sym}] ${msg}`); + if (tag === 'PASS') pass++; else fail++; +}; + +const exists = async (p) => { + try { await stat(p); return true; } catch { return false; } +}; + +// Spawn pwsh with a custom $env:APPDATA so we don't disturb the +// real %APPDATA%\mcode-island\ the widget is reading. +async function runPwsh(script, extraEnv = {}) { + const tmpAppData = join(tmpdir(), `mcode-island-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + await mkdir(join(tmpAppData, 'mcode-island'), { recursive: true }); + return new Promise((resolveP, rejectP) => { + const child = spawn(PWSH, ['-NoProfile', '-Command', script], { + env: { + ...process.env, + APPDATA: tmpAppData, + ...extraEnv, + }, + }); + let stdout = '', stderr = ''; + child.stdout.on('data', (d) => stdout += d); + child.stderr.on('data', (d) => stderr += d); + child.on('close', (code) => resolveP({ stdout, stderr, code, tmpAppData })); + child.on('error', rejectP); + }); +} + +async function readStatusJsonOf(tmpAppData) { + // notify-island.ps1 writes via [System.IO.File]::WriteAllText(.., UTF8). + // On Windows PowerShell 5.1 / .NET Framework that emits a UTF-8 BOM + // (\ufeff). PowerShell's Get-Content -Encoding UTF8 strips it, but + // Node's strict JSON.parse doesn't. We strip here so the test sees + // what the widget sees. + let raw = await readFile(join(tmpAppData, 'mcode-island', 'status.json'), 'utf8'); + if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); + return raw; +} + +// --------------------------------------------------------------------------- +// 1. notify-island.ps1 schema — round-trip +// --------------------------------------------------------------------------- + +async function testNotifySchema() { + console.log('-'.repeat(60)); + console.log('1. notify-island.ps1 schema round-trip'); + + // 1a. all three new fields + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X' -Step 3 -Total 12 -Detail 'fill username'`); + assert.equal(r.code, 0, `notify-island exited ${r.code}: ${r.stderr}`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + assert.equal(j.step, 3, 'step must round-trip'); + assert.equal(j.total, 12, 'total must round-trip'); + assert.equal(j.detail, 'fill username', 'detail must round-trip'); + out('PASS', 'all three new fields round-trip with explicit values'); + } + + // 1b. step without total + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X' -Step 5 -Detail 'npm install'`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + assert.equal(j.step, 5); + assert.equal(j.total, -1, 'total stays at -1 when caller omits it'); + assert.equal(j.detail, 'npm install'); + out('PASS', 'step without total: step=5, total stays -1'); + } + + // 1c. backward compat — old callers don't pass new params + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'legacy call'`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + assert.equal(j.step, -1, 'backward compat: step must default to -1'); + assert.equal(j.total, -1, 'backward compat: total must default to -1'); + assert.equal(j.detail, '', 'backward compat: detail must default to empty string'); + assert.equal(j.message, 'legacy call', 'message preserved'); + assert.equal(j.state, 'working'); + // Negative-injection for this contract: imagine the + // implementation forgot to write step/total/detail at all — + // the JSON would lack these fields, and `j.step` would be + // `undefined`. The assert.equal above against -1 catches that. + out('PASS', 'backward compat: old callers produce step=-1, total=-1, detail=""'); + } + + // 1d. schema version invariants — these were already there + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X'`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + for (const k of ['state', 'message', 'progress', 'ts', 'source']) { + assert.ok(k in j, `existing field ${k} must still be present`); + } + out('PASS', 'existing fields (state/message/progress/ts/source) preserved'); + } +} + +// --------------------------------------------------------------------------- +// 2. Build-DisplayMessage — PowerShell function unit +// --------------------------------------------------------------------------- + +async function testBuildDisplayMessage() { + console.log('-'.repeat(60)); + console.log('2. Build-DisplayMessage function contract'); + + // Source-out the function from mcode-island.ps1 by sourcing it in + // a no-window context, then asserting. mcode-island.ps1 starts WPF + // if you load it directly, so we extract just the function body + // by `Get-Content | Select-String` and re-define it for testing. + const widgetSrc = await readFile(WIDGET, 'utf8'); + const fnMatch = widgetSrc.match(/function Build-DisplayMessage\s*\{[\s\S]*?\n\}/); + assert.ok(fnMatch, 'Build-DisplayMessage function must exist in widget source'); + const fnBody = fnMatch[0]; + + const cases = [ + // [step, total, detail, message, expected] + [3, 12, 'fill username', 'Bash ok', 'step 3/12 · fill username'], + [5, -1, 'npm install', 'Bash', 'step 5 · npm install'], + [3, 12, '', 'Bash ok', 'step 3/12'], + [3, -1, '', 'Bash ok', 'step 3'], + [-1, -1, '', 'Bash ok', 'Bash ok'], + [-1, -1, 'orphan', 'Bash ok', 'Bash ok'], // no step → ignore detail + [3, 0, 'edge', 'Bash ok', 'step 3 · edge'], // total=0 → no /N + [3, 12, 'detail wins', '', 'step 3/12 · detail wins'], // empty message + [3, 12, 'd', 'm', 'step 3/12 · d'], // detail replaces message + ]; + + for (const [step, total, detail, message, expected] of cases) { + const script = ` +${fnBody} +$r = Build-DisplayMessage -Message ${JSON.stringify(message)} -Step ${step} -Total ${total} -Detail ${JSON.stringify(detail)} +Write-Output $r +`; + const r = await runPwsh(script); + const got = r.stdout.trim(); + if (got === expected) { + out('PASS', `Build-DisplayMessage(${step},${total},"${detail}","${message}") = "${expected}"`); + } else { + out('FAIL', `Build-DisplayMessage(${step},${total},"${detail}","${message}"): expected "${expected}", got "${got}"`); + } + } +} + +// --------------------------------------------------------------------------- +// 3. Format-ToolSummary — mcode-computer-use case +// --------------------------------------------------------------------------- + +async function testFormatToolSummaryCU() { + console.log('-'.repeat(60)); + console.log('3. Format-ToolSummary for mcode-computer-use'); + + const libSrc = await readFile(LIB, 'utf8'); + const fnMatch = libSrc.match(/function Format-ToolSummary\s*\{[\s\S]*?\n\}/); + assert.ok(fnMatch, 'Format-ToolSummary must exist in _lib.ps1'); + const fnBody = fnMatch[0]; + + const cases = [ + // input event JSON, expected output + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'click', coordinate: [1024, 768] } }, + 'mcode-computer-use : click at (1024,768)'], + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'type', text: 'hello' } }, + 'mcode-computer-use : type \'hello\''], + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'screenshot' } }, + 'mcode-computer-use : screenshot'], + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'scroll', coordinate: [100, 200] } }, + 'mcode-computer-use : scroll at (100,200)'], + // existing tools — make sure we didn't break them + [{ tool_name: 'Bash', tool_input: { command: 'ls -la /tmp' } }, + 'Bash : ls -la /tmp'], + [{ tool_name: 'Read', tool_input: { file_path: 'C:/foo/bar.txt' } }, + 'Read : C:/foo/bar.txt'], + [{ tool_name: 'Edit', tool_input: { file_path: 'C:/baz/qux.ts' } }, + 'Edit : C:/baz/qux.ts'], + ]; + + for (const [evt, expected] of cases) { + const script = ` +${fnBody} +$evt = '${JSON.stringify(evt).replace(/'/g, "''")}' | ConvertFrom-Json +$r = Format-ToolSummary $evt +Write-Output $r +`; + const r = await runPwsh(script); + const got = r.stdout.trim(); + if (got === expected) { + out('PASS', `Format-ToolSummary(${JSON.stringify(evt.tool_name)}) = "${expected}"`); + } else { + out('FAIL', `Format-ToolSummary(${JSON.stringify(evt.tool_name)}): expected "${expected}", got "${got}"`); + } + } +} + +// --------------------------------------------------------------------------- +// 4. Push-Island wrapper signature — hook can pass new params +// --------------------------------------------------------------------------- + +async function testPushIslandSignature() { + console.log('-'.repeat(60)); + console.log('4. Push-Island accepts new params'); + + const libSrc = await readFile(LIB, 'utf8'); + // Sanity: Push-Island declares Step/Total/Detail as named params. + const pushMatch = libSrc.match(/function Push-Island\s*\{[\s\S]*?\n\}/); + assert.ok(pushMatch, 'Push-Island must exist in _lib.ps1'); + const fnBody = pushMatch[0]; + if (!/\[int\]\$Step\s*=\s*-1/.test(fnBody)) { + out('FAIL', 'Push-Island: missing `[int]$Step = -1` param'); + } else { + out('PASS', 'Push-Island: declares [int]$Step = -1'); + } + if (!/\[int\]\$Total\s*=\s*-1/.test(fnBody)) { + out('FAIL', 'Push-Island: missing `[int]$Total = -1` param'); + } else { + out('PASS', 'Push-Island: declares [int]$Total = -1'); + } + if (!/\[string\]\$Detail\s*=\s*''/.test(fnBody)) { + out('FAIL', 'Push-Island: missing `[string]$Detail = \'\'` param'); + } else { + out('PASS', 'Push-Island: declares [string]$Detail = \'\''); + } + // And the call to notify-island.ps1 inside Push-Island must forward them + // The forward is via `$script:NotifyIsland` (a variable), not a literal + // "notify-island.ps1" string, so the regex anchors on the args. + if (!/-State\s+\$State\s+-Message\s+\$Message\s+-Step\s+\$Step\s+-Total\s+\$Total\s+-Detail\s+\$Detail/s.test(fnBody)) { + out('FAIL', 'Push-Island: does not forward -Step -Total -Detail to notify-island.ps1'); + } else { + out('PASS', 'Push-Island: forwards Step/Total/Detail to notify-island.ps1'); + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// 5. Push-Island → notify-island.ps1 → status.json integration +// --------------------------------------------------------------------------- + +async function testPushIslandIntegration() { + console.log('-'.repeat(60)); + console.log('5. Push-Island end-to-end (hook → status.json)'); + + const libSrc = await readFile(LIB, 'utf8'); + // Strip the Push-Island function declaration and dot-source it. + // _lib.ps1 also runs `Set-ConsoleUtf8` at load which is harmless + // for testing, but we need to skip the initial doc-comment / errors. + const r = await runPwsh(` +$ErrorActionPreference = 'Stop' +. '${LIB.replace(/\\/g, '\\\\')}' +Push-Island -State working -Message 'CU' -Step 3 -Total 12 -Detail 'fill username' +Get-Content (Join-Path $env:APPDATA 'mcode-island/status.json') -Raw +`); + if (r.code !== 0) { + out('FAIL', `Push-Island script exited ${r.code}: ${r.stderr}`); + return; + } + // Strip BOM if present + let raw = r.stdout; + if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); + const j = JSON.parse(raw); + if (j.step === 3 && j.total === 12 && j.detail === 'fill username') { + out('PASS', `Push-Island end-to-end: status.json has step=3, total=12, detail="fill username"`); + } else { + out('FAIL', `Push-Island end-to-end: status.json has step=${j.step}, total=${j.total}, detail="${j.detail}"`); + } + + // Backward compat: Push-Island without new params must still work + const r2 = await runPwsh(` +$ErrorActionPreference = 'Stop' +. '${LIB.replace(/\\/g, '\\\\')}' +Push-Island -State working -Message 'Bash ok' +Get-Content (Join-Path $env:APPDATA 'mcode-island/status.json') -Raw +`); + let raw2 = r2.stdout; + if (raw2.charCodeAt(0) === 0xFEFF) raw2 = raw2.slice(1); + const j2 = JSON.parse(raw2); + if (j2.step === -1 && j2.total === -1 && j2.detail === '' && j2.message === 'Bash ok') { + out('PASS', `Push-Island backward compat: missing new params → step=-1, total=-1, detail=""`); + } else { + out('FAIL', `Push-Island backward compat: status.json has step=${j2.step}, total=${j2.total}, detail="${j2.detail}"`); + } +} + +async function main() { + console.log(`mcode-island sub-step progress self-check`); + console.log(`plugin root: ${PLUGIN_ROOT}`); + console.log(`pwsh: ${PWSH}`); + console.log('-'.repeat(60)); + + // Platform guard: this test exercises notify-island.ps1 which is + // a Windows-only plugin (it shells out to `chcp 65001` and uses + // Win32 console APIs). On Linux/macOS CI the notify-island.ps1 + // child fails before any assertion runs, exit code 1 → false + // green. The contract being tested (sub-step status.json schema, + // Build-DisplayMessage rendering, Format-ToolSummary for + // mcode-computer-use) is platform-agnostic, but the *fixture* for + // most cases is notify-island.ps1 itself, which is Windows-only. + // Smoke.mjs has the same Windows-bound gates and is expected to be + // invoked from the Windows-latest CI job, not from the + // ubuntu-latest `validate` step that runs `node --test`. Skip here. + if (process.platform !== 'win32') { + console.log('-'.repeat(60)); + console.log('SKIP: sub-step tests require Windows PowerShell (notify-island.ps1 is Windows-only).'); + console.log(` This test runs from the windows-latest job in .github/workflows/mcode-island-windows.yml.`); + process.exit(0); + } + + if (!(await exists(NOTIFY_ISLAND))) { + console.log(`[FAIL] notify-island.ps1 not found at ${NOTIFY_ISLAND}`); + process.exit(1); + } + if (!(await exists(WIDGET))) { + console.log(`[FAIL] mcode-island.ps1 not found at ${WIDGET}`); + process.exit(1); + } + if (!(await exists(LIB))) { + console.log(`[FAIL] _lib.ps1 not found at ${LIB}`); + process.exit(1); + } + + await testNotifySchema(); + await testBuildDisplayMessage(); + await testFormatToolSummaryCU(); + await testPushIslandSignature(); + await testPushIslandIntegration(); + + console.log('-'.repeat(60)); + console.log(`summary: ${pass} pass, ${fail} fail`); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error('FATAL:', e); + process.exit(2); +}); \ No newline at end of file diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index 6ab1c982..dc9bfde0 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -130,6 +130,33 @@ $plugin = "" # directory that contains notify-island.ps1 & "$plugin\notify-island.ps1" -State waiting -Message "permission prompt" ``` +For sub-step progress (Computer Use iterative loops, multi-step plans, +long-running tool sequences) push `-Step` / `-Total` / `-Detail` so the pill +shows what the agent is doing *right now* instead of only the coarse state: + +```powershell +& "$plugin\notify-island.ps1" -State working -Message "Computer Use" ` + -Step 3 -Total 12 -Detail "fill username field" +# → pill renders: "step 3/12 · fill username field" + +& "$plugin\notify-island.ps1" -State working -Message "Bash" ` + -Step 5 -Detail "npm install" +# → pill renders: "step 5 · npm install" (total omitted → no "/N") + +& "$plugin\notify-island.ps1" -State done -Message "Bash ok" +# → pill renders: "Bash ok" (no step → legacy behavior, fully backward compat) +``` + +Semantics: +- `-Step` is 1-based; omit (or pass `-1`) to keep the coarse state-only display. +- `-Total` is optional; pass `-1` or omit when the iteration count is unknown. +- `-Detail` is free text. When present, it replaces `Message` in the pill + ("step 3/12 · detail") to avoid stacking ("Bash ok · fill username"). When + absent, only the step number renders. + +All three params are optional and the schema is backward compatible — old +callers that omit them see no behavior change. + `` is the directory that contains `notify-island.ps1`. Substitute the absolute path your user installed the plugin at. The Skill body deliberately avoids hard-coded paths so any user / any install location works.