Skip to content

Commit e2f0dd3

Browse files
committed
feat(mcode-island): pill click toggles show / hide CLI window (round-14)
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.
1 parent ca395b6 commit e2f0dd3

2 files changed

Lines changed: 126 additions & 33 deletions

File tree

‎plugins/antianqi/mcode-island/mcode-island.ps1‎

Lines changed: 81 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -516,10 +516,12 @@ function Update-State {
516516
"[$ts] $State :: $Message$progTag" | Add-Content -Path $script:logFile -Encoding UTF8
517517
}
518518

519-
# 切回调用方窗口(点击 pill 时调用)
520-
function Focus-CallerWindow {
519+
# 解析调用方窗口(caller.json → targetHwnd / targetPid)。
520+
# 处理三种死法:hwnd 死了 / 进程死了(fallback 到父进程 terminal)/
521+
# hwnd 被销毁重建。返回 [PSCustomObject]@{ Hwnd; Pid; Exe } 或 $null。
522+
function Resolve-CallerWindow {
521523
$callerFile = Join-Path $env:APPDATA 'mcode-island\caller.json'
522-
if (!(Test-Path $callerFile)) { Dbg 'FOCUS: no caller file'; return }
524+
if (!(Test-Path $callerFile)) { Dbg 'RESOLVE: no caller file'; return $null }
523525

524526
$hwnd = [IntPtr]::Zero
525527
$targetPid = 0
@@ -530,72 +532,118 @@ function Focus-CallerWindow {
530532
$targetPid = [int]$data.targetPid
531533
$targetExe = if ($data.targetExe) { [string]$data.targetExe } else { '' }
532534
} catch {
533-
Dbg "FOCUS: caller.json parse error"
534-
return
535+
Dbg "RESOLVE: caller.json parse error"
536+
return $null
535537
}
536-
if ($targetPid -le 0) { Dbg 'FOCUS: no target'; return }
538+
if ($targetPid -le 0) { Dbg 'RESOLVE: no target'; return $null }
537539

538-
# 1) 检查 hwnd 是否还活着
540+
# 1) hwnd 死了 → 重找
539541
if ($hwnd -ne [IntPtr]::Zero -and -not [WinAPI]::IsWindow($hwnd)) {
540-
Dbg "FOCUS: hwnd $hwnd dead, re-resolving"
542+
Dbg "RESOLVE: hwnd $hwnd dead, re-resolving"
541543
$hwnd = [IntPtr]::Zero
542544
}
543545

544-
# 2) 进程死了 → 找它的父进程(terminal)兜底
546+
# 2) 进程死了 → fallback 到父进程(terminal)兜底
545547
$proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
546548
if (-not $proc) {
547-
Dbg "FOCUS: target PID $targetPid gone, finding parent (terminal)"
549+
Dbg "RESOLVE: target PID $targetPid gone, finding parent (terminal)"
548550
$parent = Get-CimInstance Win32_Process -Filter "ProcessId=$targetPid" -ErrorAction SilentlyContinue
549551
if ($parent -and $parent.ParentProcessId -and $parent.ParentProcessId -gt 0) {
550552
$parentProc = Get-Process -Id ([int]$parent.ParentProcessId) -ErrorAction SilentlyContinue
551553
if ($parentProc) {
552554
$targetPid = $parentProc.Id
553555
$targetExe = $parentProc.ProcessName
554-
# 优先用 MainWindowHandle,失败就用第一个可见窗口
555556
if ($parentProc.MainWindowHandle -ne [IntPtr]::Zero) {
556557
$hwnd = $parentProc.MainWindowHandle
557558
} else {
558559
$hwnd = [WinAPI]::FindVisibleWindowForPid([uint32]$targetPid)
559560
}
560-
Dbg "FOCUS: fall back to parent $($parentProc.ProcessName) PID=$targetPid hwnd=$hwnd"
561+
Dbg "RESOLVE: fall back to parent $($parentProc.ProcessName) PID=$targetPid hwnd=$hwnd"
561562
}
562563
}
563564
if ($hwnd -eq [IntPtr]::Zero) {
564-
Dbg 'FOCUS: no parent fallback available'
565-
return
565+
Dbg 'RESOLVE: no parent fallback available'
566+
return $null
566567
}
567568
}
568-
# 3) 进程还在但 hwnd 死了(被销毁/重建)→ 找进程的第一个可见窗口
569+
570+
# 3) 进程还在但 hwnd 死了(被销毁/重建)→ 找新可见窗口
569571
if ($hwnd -eq [IntPtr]::Zero -or -not [WinAPI]::IsWindow($hwnd)) {
570-
Dbg "FOCUS: hwnd invalid, finding new visible window for PID $targetPid ($targetExe)"
572+
Dbg "RESOLVE: hwnd invalid, finding new visible window for PID $targetPid ($targetExe)"
571573
$hwnd = [WinAPI]::FindVisibleWindowForPid([uint32]$targetPid)
572574
if ($hwnd -eq [IntPtr]::Zero) {
573-
Dbg 'FOCUS: no visible window found for target process'
574-
return
575+
Dbg 'RESOLVE: no visible window found for target process'
576+
return $null
575577
}
576-
Dbg "FOCUS: re-resolved to hwnd $hwnd"
578+
Dbg "RESOLVE: re-resolved to hwnd $hwnd"
577579
}
578580

581+
return [PSCustomObject]@{ Hwnd = $hwnd; Pid = $targetPid; Exe = $targetExe }
582+
}
583+
584+
# 强制把调用方窗口拉到前台(modern Windows 要求 AllowSetForegroundWindow)。
585+
# 不管当前 visible 与否,都做 show + focus。Focus-CallerWindow 保留,
586+
# 因为它是 Resolve-CallerWindow + 强制 show 的最小封装,可用于自动聚焦
587+
# 流程(needs_input 状态自动弹窗那种)。
588+
function Focus-CallerWindow {
589+
$r = Resolve-CallerWindow
590+
if (-not $r) { return }
591+
579592
try {
580-
# 1) 授权目标进程可以切前台(modern Windows 强制)
581-
[WinAPI]::AllowSetForegroundWindow([uint32]$targetPid) | Out-Null
582-
# 2) 最小化就还原
583-
if ([WinAPI]::IsIconic($hwnd)) {
584-
[WinAPI]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE
593+
[WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null
594+
if ([WinAPI]::IsIconic($r.Hwnd)) {
595+
[WinAPI]::ShowWindow($r.Hwnd, 9) | Out-Null # SW_RESTORE
585596
}
586-
# 3) 设顶
587-
[WinAPI]::SetWindowPos($hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null
588-
[WinAPI]::SetWindowPos($hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST
589-
# 4) 抢焦点
590-
[WinAPI]::BringWindowToTop($hwnd) | Out-Null
591-
[WinAPI]::SetForegroundWindow($hwnd) | Out-Null
592-
$proc2 = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
593-
Dbg ("FOCUS OK: target=" + $proc2.ProcessName + " PID=" + $targetPid + " hwnd=" + $hwnd)
597+
[WinAPI]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null
598+
[WinAPI]::SetWindowPos($r.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST
599+
[WinAPI]::BringWindowToTop($r.Hwnd) | Out-Null
600+
[WinAPI]::SetForegroundWindow($r.Hwnd) | Out-Null
601+
$proc2 = Get-Process -Id $r.Pid -ErrorAction SilentlyContinue
602+
Dbg ("FOCUS OK: target=" + $proc2.ProcessName + " PID=" + $r.Pid + " hwnd=" + $r.Hwnd)
594603
} catch {
595604
Dbg "FOCUS FAIL: $($_.Exception.Message)"
596605
}
597606
}
598607

608+
# 单击 pill toggle:可见(未最小化)→ 隐藏;隐藏 → 还原 + 抢焦点。
609+
# 设计取舍:用 SW_HIDE + SW_SHOW 对,而不是 SW_MINIMIZE + SW_RESTORE:
610+
# 1. SW_MINIMIZE 在某些终端配置下(例如 Windows Terminal 的
611+
# "Always show tabs on top")会保留一个 thin tab-bar strip 浮在桌面顶部,
612+
# 用户体验上不算真"藏",还是有个 visible artifact。
613+
# 2. SW_HIDE 完全抹除窗口,任务栏条目也消失 (recovery 路径只剩 pill
614+
# 自己 + 重新启动 widget)。
615+
# 3. 反向 SW_SHOW 把 SW_HIDE 的窗口恢复 (跟 minimize-then-restore
616+
# 走不同 code path)。
617+
# 状态判定: IsWindowVisible 在 SW_HIDE 后返回 false,在 SW_MINIMIZE 后
618+
# 也返回 false (但 IsIconic 返回 true)。所以 toggle 只看 IsWindowVisible
619+
# 即可,不区分 minimize 和 hide 状态。
620+
function Toggle-CallerWindow {
621+
$r = Resolve-CallerWindow
622+
if (-not $r) { return }
623+
624+
try {
625+
$isShown = [WinAPI]::IsWindowVisible($r.Hwnd)
626+
if ($isShown) {
627+
[WinAPI]::ShowWindow($r.Hwnd, 0) | Out-Null # SW_HIDE
628+
Dbg "TOGGLE: hid target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)"
629+
} else {
630+
[WinAPI]::ShowWindow($r.Hwnd, 5) | Out-Null # SW_SHOW (恢复 SW_HIDE 的窗口)
631+
[WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null
632+
# 如果窗口被其他途径最小化了(IsIconic=true),走 restore
633+
if ([WinAPI]::IsIconic($r.Hwnd)) {
634+
[WinAPI]::ShowWindow($r.Hwnd, 9) | Out-Null # SW_RESTORE
635+
}
636+
[WinAPI]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null
637+
[WinAPI]::SetWindowPos($r.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST
638+
[WinAPI]::BringWindowToTop($r.Hwnd) | Out-Null
639+
[WinAPI]::SetForegroundWindow($r.Hwnd) | Out-Null
640+
Dbg "TOGGLE: shown target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)"
641+
}
642+
} catch {
643+
Dbg "TOGGLE FAIL: $($_.Exception.Message)"
644+
}
645+
}
646+
599647
# 手动设置焦点目标(右键菜单调用):把当前前台窗口记为 focus target
600648
function Set-FocusTarget-Current {
601649
Add-Type @"
@@ -692,7 +740,7 @@ $window.Add_MouseLeftButtonUp({
692740
if ($script:dragStart -and -not $script:didDrag) {
693741
Dbg 'CLICK detected'
694742
Flash-Click
695-
Focus-CallerWindow
743+
Toggle-CallerWindow
696744
}
697745
} catch {
698746
Dbg "CLICK FAIL: $($_.Exception.Message)"

‎plugins/antianqi/mcode-island/scripts/smoke.mjs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,51 @@ const main = async () => {
390390
out('PASS', 'scripts/test-substep-progress.mjs exists (run separately for full suite)');
391391
}
392392

393+
// 5c2. Click toggle extension (round-14 refactor).
394+
// The pill's MouseLeftButtonUp must call Toggle-CallerWindow, NOT
395+
// Focus-CallerWindow. Single-click show is one-way and forces the
396+
// CLI to the front every time the user clicks, which is wrong for
397+
// "I clicked the pill to hide the CLI" — the second click would
398+
// re-show it and surprise the user. Toggle semantics match the
399+
// user's "单击收起单击调出" mental model.
400+
// Drift lock: Resolve-CallerWindow + Toggle-CallerWindow must
401+
// exist as named functions (refactor target), and the click
402+
// handler must invoke Toggle-CallerWindow, not Focus-CallerWindow.
403+
if (await exists(substepWidgetPath)) {
404+
const widget = await readFile(substepWidgetPath, 'utf8');
405+
if (!/function Resolve-CallerWindow\b/.test(widget)) {
406+
out('FAIL', 'mcode-island.ps1: Resolve-CallerWindow function missing (toggle refactor target)');
407+
} else {
408+
out('PASS', 'mcode-island.ps1: Resolve-CallerWindow function present');
409+
}
410+
if (!/function Toggle-CallerWindow\b/.test(widget)) {
411+
out('FAIL', 'mcode-island.ps1: Toggle-CallerWindow function missing');
412+
} else {
413+
out('PASS', 'mcode-island.ps1: Toggle-CallerWindow function present');
414+
}
415+
// Toggle-CallerWindow must dispatch on IsWindowVisible (the
416+
// core visibility check). A regression that always calls
417+
// ShowWindow(SW_HIDE) without checking state would silently
418+
// break the toggle (every click = hide, never show).
419+
if (!/IsWindowVisible\s*\(\s*\$r\.Hwnd\s*\)/.test(widget)) {
420+
out('FAIL', 'mcode-island.ps1: Toggle-CallerWindow does not check IsWindowVisible');
421+
} else {
422+
out('PASS', 'mcode-island.ps1: Toggle-CallerWindow gates on IsWindowVisible');
423+
}
424+
// The click handler must call Toggle-CallerWindow, not Focus.
425+
// We anchor on the MouseLeftButtonUp event to scope the check.
426+
const clickMatch = widget.match(/Add_MouseLeftButtonUp\([\s\S]*?\}\s*\)\s*$/m);
427+
if (!clickMatch) {
428+
out('WARN', 'mcode-island.ps1: Add_MouseLeftButtonUp handler not found (drift lock skipped)');
429+
} else if (!/Toggle-CallerWindow\b/.test(clickMatch[0])) {
430+
out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp does not invoke Toggle-CallerWindow (still using Focus-only)');
431+
} else if (/Focus-CallerWindow\b/.test(clickMatch[0])) {
432+
out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp invokes both Toggle and Focus — pick one');
433+
} else {
434+
out('PASS', 'mcode-island.ps1: MouseLeftButtonUp invokes Toggle-CallerWindow (single click toggles show/hide)');
435+
}
436+
}
437+
393438
// 5b. Drift lock: permission-request.ps1 must emit `{"decision":"ask"}`,
394439
// not `allow` or `deny`. The 0.2.4 Runtime default for PermissionRequest
395440
// is fail-closed; an observer Hook that returns `allow` or `deny`

0 commit comments

Comments
 (0)