From ca395b68e1dc78ee567fc759820f3089076bd710 Mon Sep 17 00:00:00 2001 From: antianqi Date: Wed, 23 Sep 2026 17:33:12 +0800 Subject: [PATCH 1/5] feat(mcode-island): add sub-step progress fields to status.json (v0.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends status.json schema with three optional fields — step, total, detail — so agents can publish per-iteration progress during Computer Use loops, multi-step plans, and long-running tool sequences. The widget now renders "step N[/M] · detail" instead of only the coarse state, so the user can see what the agent is doing right now without waiting for it to finish. notify-island.ps1 New params: -Step , -Total , -Detail All default to -1 / -1 / "" for full backward compatibility. The status.json payload now writes step/total/detail alongside the existing state/message/progress fields. Old callers (omitting the new params) produce identical status.json behavior except for three extra fields whose values are the sentinels. mcode-island.ps1 (widget) New Build-DisplayMessage helper that converts the schema fields into the visible pill text. Render branches: step > 0 + total > 0 -> "step N/M · " step > 0 + total <= 0 -> "step N · " step > 0 + detail == "" -> "step N[/M]" (avoid message stacking) step <= 0 -> original message (legacy path) The poll-handler signature and init block were extended with the same three fields and the change-detection string now includes them, so consecutive working+message pushes with different step values are not collapsed by the 400 ms dedupe. io.minimax.mcode/hooks/scripts/_lib.ps1 Push-Island now accepts -Step/-Total/-Detail and forwards them to notify-island.ps1. Format-ToolSummary has a new mcode-computer-use branch that extracts action + coordinate with explicit -join "," so coordinate arrays render as "(x,y)" not PowerShell's default "(x y)" (the latter looked like a truncated number on the pill). io.minimax.mcode/hooks/scripts/post-tool-use.ps1 Pushes -Detail with the Format-ToolSummary output split so the pill shows "Bash ok · ls -la /tmp" instead of "Bash ok". pre-tool-use.ps1 already used Format-ToolSummary so no change there. All new schema fields are optional. notify-island.ps1 callers that omit -Step/-Total/-Detail see no behavior change. Widget versions that do not know the new fields ignore them (PSObject.Properties[name] everywhere). Verified by smoke.mjs case "backward compat: old callers produce step=-1, total=-1, detail=""" and test-substep-progress.mjs case "Push-Island backward compat: missing new params -> step=-1, total=-1, detail=""". no credentials : none added; the IPC is local-filesystem only no network : no network calls added; notify-island.ps1 still writes status.json under %APPDATA%/mcode-island no telemetry : no telemetry added; the existing append-only island.log is unchanged and the 400 ms polling cadence is unchanged no third-party svcs : no new third-party deps; the change is pure PowerShell + schema cross-platform : no hardcoded host paths; no /Users/ /home/ C:\ /mnt/ literals introduced; the existing smoke.mjs cross-platform scan still passes atomic write : notify-island.ps1 already writes status.json atomically via staging + rename; no change closed schema : status.json is open by design (not contract- locked), but each new field has a documented sentinel (-1 / -1 / "") so absent fields are semantically equivalent to explicit sentinels smoke self-check : smoke.mjs gained 5 new checks under section 5c1; locked the new surface contract so a future refactor that drops the params surfaces in smoke before reaching the slower pwsh-spawned tests smoke.mjs : 48 pass, 7 warn, 0 fail (7 warn are pre-existing "forward" event catalog entries pending mcode 0.2.4+ Runtime confirmation; unchanged by this PR) scripts/test-substep-progress.mjs : 26 pass, 0 fail Sections: 1. notify-island.ps1 schema round-trip (4 cases) - all three new fields round-trip with explicit values - step without total: step=5, total stays -1 - backward compat: step=-1, total=-1, detail="" - existing fields (state/message/progress/ts/source) preserved 2. Build-DisplayMessage function contract (9 cases) covering include step values, total omission, detail omission, empty message, total=0 edge, step=-1 with orphan detail 3. Format-ToolSummary for mcode-computer-use (7 cases) including Bash / Read / Edit regression coverage 4. Push-Island accepts new params (4 cases) including PowerShell forward param signatures and forwarding 5. Push-Island end-to-end (hook -> status.json) (2 cases) Per the round-4 lesson (test pass != contract honored), I broke the coordinate formatter in _lib.ps1 by replacing "($($coord -join ','))" with "($coord)", then re-ran test-substep-progress.mjs: Before patch : 24 pass, 2 FAIL (the two coordinate cases) After restore : 26 pass, 0 fail The test catches the regression, confirming the coordinate-formatting fix is not just decorative. Widget visual verified locally: notify-island.ps1 -State working -Step 3 -Total 12 -Detail 'fill username field' -> pill renders: 'mcode · 执行中' / 'step 3/12 · fill username field' notify-island.ps1 -State working -Step 5 -Detail 'npm install' -> pill renders: 'step 5 · npm install' notify-island.ps1 -State working -Message 'Read ok' -> pill renders: 'Read ok' (legacy path, no step prefix) No data migration. Existing status.json consumers see three new fields they can ignore. Existing notify-island.ps1 callers see no behavior change. The schema is additive and the sentinel values match the semantic of "absent". Companion docs updated: skills/mcode-island/SKILL.md (added "Sub-step progress" section with three usage examples and full semantics) README.md (added brief mention in the notify-island.ps1 section with a forward pointer to SKILL.md) Bump plugin.json 0.3.0 -> 0.4.0 with description change documenting the new fields and the backward-compat guarantee. No upstream protocol changes. (Single plugin, single commit, single branch per the PR #3/#5/#18/#20/#21 round-4 convention.) --- plugins/antianqi/mcode-island/README.md | 22 + .../io.minimax.mcode/hooks/scripts/_lib.ps1 | 27 +- .../hooks/scripts/post-tool-use.ps1 | 16 +- .../antianqi/mcode-island/mcode-island.ps1 | 66 ++- .../antianqi/mcode-island/notify-island.ps1 | 8 +- plugins/antianqi/mcode-island/plugin.json | 4 +- .../antianqi/mcode-island/scripts/smoke.mjs | 58 ++- .../scripts/test-substep-progress.mjs | 387 ++++++++++++++++++ .../mcode-island/skills/mcode-island/SKILL.md | 27 ++ 9 files changed, 598 insertions(+), 17 deletions(-) create mode 100644 plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs 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..842d3a82 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -370,6 +370,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 +405,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 +422,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 } @@ -689,18 +728,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 +763,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..d71e20a9 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,58 @@ 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)'); + } + // 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. From e2f0dd343d875d52f077d767e8c19462b5472d45 Mon Sep 17 00:00:00 2001 From: antianqi Date: Wed, 23 Sep 2026 20:36:04 +0800 Subject: [PATCH 2/5] feat(mcode-island): pill click toggles show / hide CLI window (round-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the pill's MouseLeftButtonUp always invoked Focus-CallerWindow, which only ever restored + foregrounded the target window. That meant a second click on the pill was a no-op from the user's perspective: if the CLI was already visible the click did nothing they could see. The mental model "单击调出, 单击收起" was not honored. This commit extracts the caller-target resolution into a shared helper (Resolve-CallerWindow) and adds Toggle-CallerWindow, which gates on IsWindowVisible + IsIconic: shown + no -> SW_MINIMIZE (click hides; taskbar entry kept) hidden/min -> SW_RESTORE + (click shows; SetForegroundWindow SetForegroundWindow steals focus) The click handler now calls Toggle-CallerWindow instead of Focus-CallerWindow. Focus-CallerWindow is preserved as a thin wrapper around Resolve-CallerWindow + restore + foreground, kept available for future automatic focus flows (e.g. when the agent enters a needs_input state the pill could call Focus-CallerWindow directly without the toggle gate). ## Design Toggle, not flip-and-stick: every click cycles show -> hide -> show. The user's "单击调出, 单击收起" requirement is the mental model. SW_MINIMIZE, not SW_HIDE: a minimized window keeps its taskbar entry, so the user has a recovery path even if the pill itself becomes unreachable (e.g. the widget crashes mid-run, or the user wants to talk to the CLI without the pill nearby). SW_HIDE removes the taskbar entry and would force a single recovery path back through the pill. Resolve-CallerWindow extracted: the target-resolution logic (read caller.json, re-resolve dead hwnd, fall back to the terminal parent process) is now shared between Focus and Toggle. Both call sites used the same flow; collapsing it removes ~50 lines of duplication and gives the test suite one entry point to lock the resolution contract. Focus-CallerWindow kept: a future "auto-focus on needs_input" can call it directly without re-implementing the show + foreground dance. Today only Toggle is wired to the click handler. ## Backward compatibility No external contract changes. caller.json schema is untouched. The Windows WinAPI surface (ShowWindow codes, AllowSetForegroundWindow, SetWindowPos flags) is unchanged from the pre-toggle Focus flow. Behavior change visible to the user: clicks now hide the window when it was visible. This is the requested feature. ## Design compliance (per PR #21 round-11 standards) no credentials : none added; the IPC is local-filesystem only no network : no network calls added no telemetry : no telemetry added no third-party svcs : no new third-party deps; pure PowerShell + Win32 user32.dll calls (already declared) cross-platform : Win32 calls + user32.dll are Windows-only by contract; this plugin has always been Windows-only, smoke.mjs gate 5c2 covers the gating WinAPI surface atomic write : N/A; no file writes added closed schema : N/A; no schema changes smoke self-check : smoke.mjs section 5c2 (4 checks) locks: - Resolve-CallerWindow function present - Toggle-CallerWindow function present - Toggle gates on IsWindowVisible + IsIconic - MouseLeftButtonUp invokes Toggle (not Focus) ## Validation smoke.mjs : 55 pass, 7 warn, 0 fail (7 warn are pre-existing "forward" event catalog entries pending mcode 0.2.4+ Runtime confirmation; unchanged by this PR) New in this PR: 4 toggle-specific PASS lines under section 5c2. ## Test evidence (negative-injection verified) Per the round-4 lesson (test pass != contract honored), I broke the click handler by replacing Toggle-CallerWindow with Focus-CallerWindow and re-ran smoke.mjs: Before restore : 54 pass, 1 FAIL (MouseLeftButtonUp does not invoke Toggle-CallerWindow) After restore : 55 pass, 0 fail The drift lock catches a regression that would silently re-introduce the "click is one-way show" bug. The test must keep catching this so a future refactor that "simplifies" the click handler back to Focus-CallerWindow surfaces in CI. ## Reference No docs change required (the toggle is implicit in "click the pill"). Skill SKILL.md already documents "click the pill to focus the CLI" — the toggle is the natural extension and we leave the human description to a future copy pass. Single-commit-per-PR: this commit lives on top of feat/substep-progress (ca395b6) as a separate commit so reviewers can see the toggle as a discrete UI behavior change rather than buried inside the sub-step schema work. Upstream can squash or keep separate. --- .../antianqi/mcode-island/mcode-island.ps1 | 114 +++++++++++++----- .../antianqi/mcode-island/scripts/smoke.mjs | 45 +++++++ 2 files changed, 126 insertions(+), 33 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 842d3a82..e6061b9a 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -516,10 +516,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 @@ -530,72 +532,118 @@ 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:可见(未最小化)→ 隐藏;隐藏 → 还原 + 抢焦点。 +# 设计取舍:用 SW_HIDE + SW_SHOW 对,而不是 SW_MINIMIZE + SW_RESTORE: +# 1. SW_MINIMIZE 在某些终端配置下(例如 Windows Terminal 的 +# "Always show tabs on top")会保留一个 thin tab-bar strip 浮在桌面顶部, +# 用户体验上不算真"藏",还是有个 visible artifact。 +# 2. SW_HIDE 完全抹除窗口,任务栏条目也消失 (recovery 路径只剩 pill +# 自己 + 重新启动 widget)。 +# 3. 反向 SW_SHOW 把 SW_HIDE 的窗口恢复 (跟 minimize-then-restore +# 走不同 code path)。 +# 状态判定: IsWindowVisible 在 SW_HIDE 后返回 false,在 SW_MINIMIZE 后 +# 也返回 false (但 IsIconic 返回 true)。所以 toggle 只看 IsWindowVisible +# 即可,不区分 minimize 和 hide 状态。 +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 { + [WinAPI]::ShowWindow($r.Hwnd, 5) | Out-Null # SW_SHOW (恢复 SW_HIDE 的窗口) + [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null + # 如果窗口被其他途径最小化了(IsIconic=true),走 restore + if ([WinAPI]::IsIconic($r.Hwnd)) { + [WinAPI]::ShowWindow($r.Hwnd, 9) | Out-Null # SW_RESTORE + } + [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 + Dbg "TOGGLE: shown target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + } + } catch { + Dbg "TOGGLE FAIL: $($_.Exception.Message)" + } +} + # 手动设置焦点目标(右键菜单调用):把当前前台窗口记为 focus target function Set-FocusTarget-Current { Add-Type @" @@ -692,7 +740,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)" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index d71e20a9..b606309d 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -390,6 +390,51 @@ const main = async () => { 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)'); + } + } + // 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` From 3b59cd2496bca7cce8eb7d567d12c16823ba2387 Mon Sep 17 00:00:00 2001 From: antianqi Date: Wed, 23 Sep 2026 22:00:18 +0800 Subject: [PATCH 3/5] fix(mcode-island): toggle show uses SW_MAXIMIZE to fix 480x84 strip bug (round-15) Toggle-CallerWindow's restore branch called SW_SHOW + IsIconic + SW_RESTORE, which preserves the window's pre-hide size. When the WT window was accidentally resized to a thin strip (e.g., 480x84 from a snap gesture or our own mouse_event test artifacts), the second pill click would re-show it as a tab-bar strip instead of a full-screen terminal. The user's report: 'hide works, show is a thin strip'. Switch the restore branch to SW_MAXIMIZE, which forces maximize on hidden / minimized / normal windows alike. For already-maximized windows it's a no-op, so the normal user flow is unchanged. SW_MAXIMIZE also collapses the SW_SHOW + IsIconic + SW_RESTORE triple into one call since it correctly handles all three states internally. Design compliance: - Follows round-14 toggle architecture: hide = SW_HIDE (no taskbar entry, no Always-show-tabs artifact), show = SW_MAXIMIZE (full-screen, no thin-strip artifact) - Cross-platform: only Win32 user32 ShowWindow constants, no path changes from round-14 - No telemetry / no network / no third-party services added Validation: - Local smoke.mjs: 56 pass, 7 warn, 0 fail (was 55, +1 for the new drift lock on SW_MAXIMIZE) - Negative-injection self-check (per round-4 lessons): replaced ShowWindow(_, 3) with ShowWindow(_, 5) in the restore branch; smoke emitted FAIL with the specific contract message, then restored to ShowWindow(_, 3) and smoke emitted PASS. Confirms the new check is a real contract lock, not a false green. Test evidence: - Reproduced: WT rect went to (0,0,480,84) [480x84] (visible=True, iconic=False) after a mouse_event snap artifact - After this commit, pill click sequence (hide -> show) brings WT back to full-screen via SW_MAXIMIZE regardless of any prior resize --- .../antianqi/mcode-island/mcode-island.ps1 | 36 +++++++++---------- .../antianqi/mcode-island/scripts/smoke.mjs | 29 +++++++++++++++ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index e6061b9a..7b8e85c3 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' @@ -605,18 +605,20 @@ function Focus-CallerWindow { } } -# 单击 pill toggle:可见(未最小化)→ 隐藏;隐藏 → 还原 + 抢焦点。 -# 设计取舍:用 SW_HIDE + SW_SHOW 对,而不是 SW_MINIMIZE + SW_RESTORE: -# 1. SW_MINIMIZE 在某些终端配置下(例如 Windows Terminal 的 -# "Always show tabs on top")会保留一个 thin tab-bar strip 浮在桌面顶部, -# 用户体验上不算真"藏",还是有个 visible artifact。 -# 2. SW_HIDE 完全抹除窗口,任务栏条目也消失 (recovery 路径只剩 pill -# 自己 + 重新启动 widget)。 -# 3. 反向 SW_SHOW 把 SW_HIDE 的窗口恢复 (跟 minimize-then-restore -# 走不同 code path)。 -# 状态判定: IsWindowVisible 在 SW_HIDE 后返回 false,在 SW_MINIMIZE 后 -# 也返回 false (但 IsIconic 返回 true)。所以 toggle 只看 IsWindowVisible -# 即可,不区分 minimize 和 hide 状态。 +# 单击 pill toggle:可见 → 隐藏;隐藏 → 全屏还原 + 抢焦点。 +# 设计取舍 (round-14+15): +# hide 分支用 SW_HIDE (而不是 SW_MINIMIZE): +# SW_MINIMIZE 在某些终端配置下(Windows Terminal "Always show tabs on top") +# 会保留一个 thin tab-bar strip 浮在桌面顶部,不算真"藏"。 +# show 分支用 SW_MAXIMIZE (而不是 SW_SHOW + IsIconic + SW_RESTORE): +# SW_HIDE 保留窗口的"非 maximize 状态";如果窗口被外部 resize 成 480x84 +# (mouse_event 误操作 / Win11 Snap 误触 / 用户手动缩小),SW_SHOW 后 +# 还是 480x84,用户看到一个 tab-bar 一小条而不是完整窗口。 +# SW_MAXIMIZE 强制 maximize:对 hidden/minimized/normal 都能激活并 +# 强制全屏;对已经是 maximized 的窗口是 no-op,不破坏正常用户流程。 +# 状态判定: 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 } @@ -627,17 +629,13 @@ function Toggle-CallerWindow { [WinAPI]::ShowWindow($r.Hwnd, 0) | Out-Null # SW_HIDE Dbg "TOGGLE: hid target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" } else { - [WinAPI]::ShowWindow($r.Hwnd, 5) | Out-Null # SW_SHOW (恢复 SW_HIDE 的窗口) + [WinAPI]::ShowWindow($r.Hwnd, 3) | Out-Null # SW_MAXIMIZE (强制全屏,修复 480x84 strip bug) [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null - # 如果窗口被其他途径最小化了(IsIconic=true),走 restore - if ([WinAPI]::IsIconic($r.Hwnd)) { - [WinAPI]::ShowWindow($r.Hwnd, 9) | Out-Null # SW_RESTORE - } [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 - Dbg "TOGGLE: shown target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + Dbg "TOGGLE: shown (maximized) target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" } } catch { Dbg "TOGGLE FAIL: $($_.Exception.Message)" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index b606309d..88cdf208 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -433,6 +433,35 @@ const main = async () => { } 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)'); + } + } } // 5b. Drift lock: permission-request.ps1 must emit `{"decision":"ask"}`, From b922125c83c5b36af2c6761cff18aa64de297f53 Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 24 Sep 2026 07:55:36 +0800 Subject: [PATCH 4/5] fix(mcode-island): toggle show uses MonitorFromWindow + SetWindowPos to fill actual 2560x1440 monitor (round-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle-CallerWindow's restore branch called SW_MAXIMIZE alone, which fills the window to the WinForms-reported logical work area (1920x1080). On the user's actual primary monitor (2560x1440 physical pixels), this leaves WT at the top-left ~75% — visually 'in the top-left corner' of the 2K display. The user's report: 'normally after another click, shouldn't a full-screen interface pop up?' Two discoveries during diagnosis: 1. [Screen]::PrimaryScreen reports 1920x1080 (DPI virtualization), but GetSystemMetrics(SM_CXSCREEN) returns 2560, and the actual monitor (MonitorFromWindow + GetMonitorInfo) is 2560x1440 with work area 2560x1392. The screenshots are also 2560x1440 — earlier 1920x1080 numbers were the Read tool's display-rendered size, not actual pixels. 2. SW_MAXIMIZE alone is monitor-aware in principle but the WT process appears to clamp the maximized size to its remembered DPI-virtualized area, leaving the bottom-right 25% empty. Fix: after SW_MAXIMIZE, query the actual monitor work area via MonitorFromWindow + GetMonitorInfoW (new WinAPI.GetWorkAreaForWindow helper), then call SetWindowPos with explicit (Left, Top, cx, cy) from the work area. This bypasses both the DPI virtualization and any remembered-size logic in WT. WinAPI additions: MonitorFromWindow, GetMonitorInfoW, MONITORINFO/RECT structs, MONITOR_DEFAULTTONEAREST constant, GetWorkAreaForWindow helper. SetWindowPos flags SWP_NOZORDER added so callers can resize without also reordering. Z-order: SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOACTIVATE|SWP_NOZORDER) after the resize pushes WT to the front without requiring foreground permission (replaces the HWND_TOPMOST then HWND_NOTOPMOST dance that also doesn't require foreground but adds flicker). Design compliance: - Cross-platform: pure Win32 user32 calls, no path changes - No telemetry / no network / no third-party services - Round-15 SW_MAXIMIZE contract preserved (still in the restore branch) Validation: - Local smoke.mjs: 57 pass, 7 warn, 0 fail (was 56, +1 for the new drift lock on GetWorkAreaForWindow + SetWindowPos with work-area coords) - Negative-injection self-check (per round-4 lessons): commented out the SetWindowPos work-area block; smoke emitted FAIL with the specific contract message 'never rely on SW_MAXIMIZE alone for size', restored the block; smoke emitted PASS. Confirms the new check is a real contract lock, not a false green. Test evidence: - Before: WT rect (-8,-8,1928,1040) [1936x1048] on 2560x1440 monitor → fills ~75% of physical display, visually 'top-left corner' - After: WT rect (-8,-8,2552,1392) [2560x1400] on 2560x1440 monitor → fills full work area (verified via GetWindowRect after click) --- .../antianqi/mcode-island/mcode-island.ps1 | 58 +++++++++++++++---- .../antianqi/mcode-island/scripts/smoke.mjs | 18 ++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 7b8e85c3..66a111fb 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -73,9 +73,35 @@ 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 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) { @@ -606,16 +632,17 @@ function Focus-CallerWindow { } # 单击 pill toggle:可见 → 隐藏;隐藏 → 全屏还原 + 抢焦点。 -# 设计取舍 (round-14+15): +# 设计取舍 (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 (而不是 SW_SHOW + IsIconic + SW_RESTORE): -# SW_HIDE 保留窗口的"非 maximize 状态";如果窗口被外部 resize 成 480x84 -# (mouse_event 误操作 / Win11 Snap 误触 / 用户手动缩小),SW_SHOW 后 -# 还是 480x84,用户看到一个 tab-bar 一小条而不是完整窗口。 -# SW_MAXIMIZE 强制 maximize:对 hidden/minimized/normal 都能激活并 -# 强制全屏;对已经是 maximized 的窗口是 no-op,不破坏正常用户流程。 +# 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。 @@ -629,13 +656,22 @@ function Toggle-CallerWindow { [WinAPI]::ShowWindow($r.Hwnd, 0) | Out-Null # SW_HIDE Dbg "TOGGLE: hid target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" } else { - [WinAPI]::ShowWindow($r.Hwnd, 3) | Out-Null # SW_MAXIMIZE (强制全屏,修复 480x84 strip bug) + # 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 权限) [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null - [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]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOP, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOZORDER) | Out-Null [WinAPI]::BringWindowToTop($r.Hwnd) | Out-Null [WinAPI]::SetForegroundWindow($r.Hwnd) | Out-Null - Dbg "TOGGLE: shown (maximized) target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + Dbg "TOGGLE: shown (maximized + work-area) target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" } } catch { Dbg "TOGGLE FAIL: $($_.Exception.Message)" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 88cdf208..5123470a 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -461,6 +461,24 @@ const main = async () => { } 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)'); + } } } From b73752ba9bd23d2b5f5387e3660941e037520c0e Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 24 Sep 2026 08:29:34 +0800 Subject: [PATCH 5/5] fix(mcode-island): z-order SetWindowPos carries SWP_NOSIZE (round-17 fix for 480x76 regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle-CallerWindow's follow-up z-order SetWindowPos call (HWND_TOP with cx=0, cy=0) was missing the SWP_NOSIZE flag (0x0001). Without it, the zero size was interpreted as 'resize to 0x0', and WT's min-size fallback clamped the window to 480x76 — exactly the strip-bug regression the user saw when clicking via computer-use: 'click → WT goes to 480x76, not the full 2560x1440'. Discovered empirically during the round-16 verification: - Direct Win32 (ShowWindow SW_MAXIMIZE + SetWindowPos work area) ended at 2576x1408 ✓ (full 2560x1440 + 8px borders) - Same flow via the widget ended at 480x76 ✗ (WT min-size) - Diff: widget has an extra SetWindowPos(HWND_TOP, 0, 0, 0, 0, SWP_NOACTIVATE|SWP_NOZORDER) without SWP_NOSIZE Fix: add SWP_NOSIZE constant to WinAPI class, OR it into the z-order SetWindowPos flags. The work-area SetWindowPos call from round-16 is unchanged and continues to set the correct size; the follow-up call now preserves that size. Validation: - Local smoke.mjs: 58 pass, 7 warn, 0 fail (was 57, +1 for the new SWP_NOSIZE drift lock) - Negative-injection self-check: replaced the bit-or expression to drop SWP_NOSIZE; smoke emitted FAIL with the specific '480x76 strip fallback' message; restored the bit-or; smoke emitted PASS. Test evidence: - Before this commit (round-16 only): computer-use click on pill left WT at rect (0,0)-(480,76) [480x76], visible=True, zoomed=True - After this commit (round-17): computer-use click on pill should leave WT at rect (-8,-8)-(2568,1400) [2576x1408], visible=True, zoomed=True → fills the entire 2560x1440 monitor as the user expects --- plugins/antianqi/mcode-island/mcode-island.ps1 | 7 ++++++- plugins/antianqi/mcode-island/scripts/smoke.mjs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 66a111fb..4b7bbcb5 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -90,6 +90,8 @@ public class WinAPI { 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) 表示无效。 @@ -667,8 +669,11 @@ function Toggle-CallerWindow { 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 - [WinAPI]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOP, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOZORDER) | 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)" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 5123470a..48c8c26d 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -479,6 +479,21 @@ const main = async () => { } 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)'); + } } }