claude-code-best/src/utils/computerUse/win32/inputIndicator.ts
claude-code-best 0955a02348 chore: 清理 18 处未使用导入、变量和函数
移除未使用的导入(getSubscriptionType、isEnvDefinedFalsy、
getClaudeConfigHomeDir 等)、未使用的常量(ACCENT_COLOR、
NAME_MATCH_BONUS、CLIPBOARD_THRESHOLD)和死函数
(getOpus41Option、pasteViaClipboard),
为未使用参数添加 _ 前缀。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:15:51 +08:00

254 lines
8.0 KiB
TypeScript

/**
* Input Indicator — floating label showing what Computer Use is doing
* on the bound window.
*
* Displays a small overlay near the bottom of the bound window:
* ⌨ Typing "hello world..."
* 🖱 Click (120, 50)
* ⌨ Ctrl+S
* 📜 Scroll ↓ 3
* ✅ Done
*
* Auto-fades after 2 seconds of inactivity.
* Click-through, TOPMOST, no taskbar icon.
*/
import * as fs from 'fs'
import * as path from 'path'
import { validateHwnd, getTmpDir } from './shared.js'
const INDICATOR_WIDTH = 350
const INDICATOR_HEIGHT = 28
const FADE_AFTER_MS = 2000
const BG_COLOR = '30, 30, 30' // dark background
const TEXT_COLOR = '220, 220, 220' // light text
let indicatorProc: ReturnType<typeof Bun.spawn> | null = null
let stopFile: string | null = null
let scriptFile: string | null = null
let msgFile: string | null = null
function buildIndicatorScript(hwnd: string, sf: string): string {
const sfEsc = sf.replace(/\\/g, '\\\\')
return `
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
Add-Type @'
using System;
using System.Runtime.InteropServices;
public class Indicator {
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
[DllImport("user32.dll",SetLastError=true)] public static extern int SetWindowLong(IntPtr h, int i, int v);
[DllImport("user32.dll",SetLastError=true)] public static extern int GetWindowLong(IntPtr h, int i);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int h2, uint f);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L,T,R,B; }
public const int GWL_EXSTYLE = -20;
public const int WS_EX_LAYERED = 0x80000;
public const int WS_EX_TRANSPARENT = 0x20;
public const int WS_EX_TOOLWINDOW = 0x80;
public const int WS_EX_NOACTIVATE = 0x08000000;
public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
public const uint SWP_NOACTIVATE = 0x0010;
public const uint SWP_SHOWWINDOW = 0x0040;
public static void MakeOverlay(IntPtr h) {
int ex = GetWindowLong(h, GWL_EXSTYLE);
ex |= WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
SetWindowLong(h, GWL_EXSTYLE, ex);
}
}
'@
$targetHwnd = [IntPtr]::new([long]${hwnd})
$stopFile = '${sfEsc}'
$msgFile = $stopFile + '.msg'
$form = New-Object System.Windows.Forms.Form
$form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
$form.ShowInTaskbar = $false
$form.TopMost = $true
$form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
$form.Size = New-Object System.Drawing.Size(${INDICATOR_WIDTH}, ${INDICATOR_HEIGHT})
$form.Location = New-Object System.Drawing.Point(-32000, -32000)
$form.BackColor = [System.Drawing.Color]::FromArgb(240, ${BG_COLOR})
$form.Opacity = 0.92
$label = New-Object System.Windows.Forms.Label
$label.Dock = [System.Windows.Forms.DockStyle]::Fill
$label.ForeColor = [System.Drawing.Color]::FromArgb(${TEXT_COLOR})
$label.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Regular)
$label.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
$label.Padding = New-Object System.Windows.Forms.Padding(8, 0, 8, 0)
$label.Text = ""
$form.Controls.Add($label)
$form.Show()
[Indicator]::MakeOverlay($form.Handle)
$script:lastMsg = ""
$script:lastMsgTime = [DateTime]::MinValue
$script:visible = $false
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 50 # 20fps
$timer.Add_Tick({
if (-not [Indicator]::IsWindow($targetHwnd)) {
$timer.Stop(); $form.Close()
[System.Windows.Forms.Application]::ExitThread()
return
}
if (Test-Path $stopFile) {
$timer.Stop(); $form.Close()
try { Remove-Item $stopFile -ErrorAction SilentlyContinue } catch {}
try { Remove-Item $msgFile -ErrorAction SilentlyContinue } catch {}
[System.Windows.Forms.Application]::ExitThread()
return
}
# Read new message
if (Test-Path $msgFile) {
try {
$msg = Get-Content $msgFile -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
if ($msg) {
$script:lastMsg = $msg.Trim()
$script:lastMsgTime = [DateTime]::Now
Remove-Item $msgFile -ErrorAction SilentlyContinue
}
} catch {}
}
# Fade logic: hide after ${FADE_AFTER_MS}ms of no updates
$elapsed = ([DateTime]::Now - $script:lastMsgTime).TotalMilliseconds
if ($elapsed -gt ${FADE_AFTER_MS} -and $script:visible) {
$form.Visible = $false
$script:visible = $false
return
}
if ($elapsed -le ${FADE_AFTER_MS} -and $script:lastMsg -ne "") {
# Position at bottom-center of the bound window
$wr = New-Object Indicator+RECT
[Indicator]::GetWindowRect($targetHwnd, [ref]$wr) | Out-Null
$ww = $wr.R - $wr.L
$fx = $wr.L + [int](($ww - ${INDICATOR_WIDTH}) / 2)
$fy = $wr.B - ${INDICATOR_HEIGHT} - 8
$label.Text = $script:lastMsg
[Indicator]::SetWindowPos($form.Handle, [Indicator]::HWND_TOPMOST,
$fx, $fy, 0, 0,
0x0001 -bor [Indicator]::SWP_NOACTIVATE -bor [Indicator]::SWP_SHOWWINDOW) | Out-Null
$form.Visible = $true
$script:visible = $true
# Fade opacity near end
if ($elapsed -gt ${FADE_AFTER_MS * 0.7}) {
$form.Opacity = [Math]::Max(0.3, 0.92 * (1.0 - ($elapsed - ${FADE_AFTER_MS * 0.7}) / ${FADE_AFTER_MS * 0.3}))
} else {
$form.Opacity = 0.92
}
}
})
$timer.Start()
[System.Windows.Forms.Application]::Run()
`
}
/** Start the input indicator for a bound window */
export function showIndicator(hwnd: string): boolean {
hwnd = validateHwnd(hwnd)
hideIndicator()
try {
const tmpDir = getTmpDir()
const ts = Date.now()
stopFile = path.join(tmpDir, `cu_indicator_stop_${ts}`)
scriptFile = path.join(tmpDir, `cu_indicator_${ts}.ps1`)
msgFile = stopFile + '.msg'
fs.writeFileSync(scriptFile, buildIndicatorScript(hwnd, stopFile), 'utf-8')
indicatorProc = Bun.spawn(
[
'powershell',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
scriptFile,
],
{ stdout: 'ignore', stderr: 'ignore' },
)
return true
} catch {
return false
}
}
/** Update the indicator message */
export function updateIndicator(message: string): void {
if (!msgFile) return
try {
fs.writeFileSync(msgFile, message, 'utf-8')
} catch {}
}
/** Hide and destroy the indicator */
export function hideIndicator(): void {
if (stopFile) {
try {
fs.writeFileSync(stopFile, 'STOP', 'utf-8')
} catch {}
setTimeout(() => {
try {
indicatorProc?.kill()
} catch {}
try {
if (scriptFile) fs.unlinkSync(scriptFile)
} catch {}
try {
if (stopFile) fs.unlinkSync(stopFile)
} catch {}
try {
if (msgFile) fs.unlinkSync(msgFile)
} catch {}
}, 2000)
}
indicatorProc = null
stopFile = null
scriptFile = null
msgFile = null
}
// ── Convenience methods for common actions ──
export function indicateTyping(text: string): void {
const preview = text.length > 30 ? text.slice(0, 30) + '...' : text
updateIndicator(`\u2328 Typing "${preview}"`)
}
export function indicateKey(combo: string): void {
updateIndicator(`\u2328 ${combo}`)
}
export function indicateClick(
x: number,
y: number,
button: string = 'left',
): void {
updateIndicator(
`\uD83D\uDDB1 ${button === 'right' ? 'Right-click' : 'Click'} (${x}, ${y})`,
)
}
export function indicateScroll(direction: string, amount: number): void {
const arrow =
direction === 'up'
? '\u2191'
: direction === 'down'
? '\u2193'
: direction === 'left'
? '\u2190'
: '\u2192'
updateIndicator(`\uD83D\uDCDC Scroll ${arrow} ${amount}`)
}
export function indicateDone(): void {
updateIndicator('\u2705 Done')
}