Selector.ps1

# 界面层:全屏选择器(spec 4,决策 43 重构为卡片式网格,第八轮重构为固定栏位布局)
# 帧渲染是纯函数(可测);主循环 + ReadKey 见 Show-CctSelector
# 第十轮布局:搜索行(固定) / 空行 / 卡片网格(滚动区,带边框) / [填充行] / 帮助行(固定)
# 帧高恒 = WindowHeight(帮助行贴底,决策 76);搜索行恒在行 0、帮助行恒在最后一行,
# 滚动时二者行号恒定(行 diff 不重绘 = 固定栏位效果),终端永不滚屏
# (第八轮 bug2 根因:旧版 MaxRows 按 3 行/块预算,实际每块 4 行(3 内容+1 空行),帧高超出
# 屏高后绝对定位行号把超出行推入滚动区,屏幕内容整体错位——「提示 提示」残影即此)

$script:CctEsc = [char]27

# 版权行左侧显示的版本号:从模块清单 psd1 读取(不写死;发版只需改 build-metadata.json,
# 盖章后 psd1 跟随,这里自然跟着变)。读失败时留空,版权行退化为原来的 by … 文案。
$script:CctVersionLabel = ''
try {
    $cctManifest = Import-PowerShellDataFile -LiteralPath (Join-Path $PSScriptRoot 'ClaudeCodeTask.psd1') -ErrorAction Stop
    if ($cctManifest.ModuleVersion) { $script:CctVersionLabel = "v$($cctManifest.ModuleVersion)" }
} catch { }

# stdin 控制台模式管理(第六轮根因修复,2026-09-01 探针实锤):
# stdin 带 ENABLE_VIRTUAL_TERMINAL_INPUT(0x0200) 时 [Console]::ReadKey 把方向键拆成
# ESC/'['/'C' 三个普通字符(Key=None)——方向键导航失效 + '[C[C[D' 污染 query、
# ESC/ctrl+C 退出全失灵。用户终端可能带着该模式进来(PSReadLine/其他 TUI 工具残留),
# cct 进入时防御性关闭,退出时恢复原样。
if (-not ('CctConsoleMode' -as [type])) {
    Add-Type -Path (Join-Path $PSScriptRoot 'lib\ClaudeCodeTask.Core.dll')
}

# 清掉 mode 的 VT_INPUT 位(纯函数,可测);stdin 非控制台(重定向)返回原值
function Get-CctCleanInputMode {
    param([uint32]$Mode)
    return $Mode -band (-bnot [uint32]0x0200)
}

# 进入选择器前关闭 VT_INPUT;返回「恢复函数」供 finally 调用(模式不可读时返回 null)
function Enter-CctInputMode {
    try {
        $h = [CctConsoleMode]::GetStdHandle([CctConsoleMode]::STD_INPUT)
        $m = [uint32]0
        if (-not [CctConsoleMode]::GetConsoleMode($h, [ref]$m)) { return $null }
        $clean = Get-CctCleanInputMode $m
        if ($clean -ne $m) {
            [void][CctConsoleMode]::SetConsoleMode($h, $clean)
            return {
                try {
                    $h2 = [CctConsoleMode]::GetStdHandle([CctConsoleMode]::STD_INPUT)
                    [void][CctConsoleMode]::SetConsoleMode($h2, $m)
                } catch {}
            }.GetNewClosure()
        }
        return $null   # 本来就没开 VT_INPUT,无需恢复
    } catch { return $null }
}

# ANSI 剥离(计算纯文本宽度用)
$script:CctAnsiPattern = "$([char]27)\[[0-9;?]*[a-zA-Z]"

# 版权文字按可用显示宽分档:完整 → 缩短 → 只剩版本号 → 省略(窗口缩窄时渐进隐藏,
# 不挤压左侧按键提示)。按 Get-DisplayWidth 逐档实测宽度而非写死阈值——版本号长度会变
# (0.3.3 → 0.10.0),写死阈值将来会算错。
function Get-CctCopyright {
    param([int]$AvailWidth)   # 版权文字可用显示宽 = 窗口宽 - 帮助行左侧按键提示宽 - 间隔
    $ver = [string]$script:CctVersionLabel
    foreach ($cand in @(
        "$ver by github - hjkl950217",
        "$ver by github",
        "$ver"
    )) {
        $text = $cand.Trim()
        if ($text -and (Get-DisplayWidth $text) -le $AvailWidth) { return $text }
    }
    return ''
}

# 删除确认屏(纯函数,供 New-CctFrame 分派;也可单独断言)。
# 布局沿用固定栏位约定:帧高恒 = WindowHeight、末行恒为按键提示行、每行显示宽 ≤ WindowWidth。
# 清单列宽全部按显示宽(Get-DisplayWidth)算——不用 \t:终端制表位按字符数推进,
# 中文占 2 列必然错位,且不同终端 tab stop 不一致。
function New-CctConfirmFrame {
    param(
        [pscustomobject]$Confirm,   # { TaskPath, ProjectDir, Plan, Scroll }
        [int]$WindowWidth,
        [int]$WindowHeight,
        [datetime]$Now
    )
    $esc = $script:CctEsc
    $cReset = "$esc[0m"; $cDim = "$esc[90m"; $cWarn = "$esc[91m"

    $plan = $Confirm.Plan
    $rows = [System.Collections.Generic.List[string]]::new()

    # 固定段(标题 / 空 / 目录 / 位置 / 汇总 / 空);窗口极矮时从后往前裁,保证不超帧高
    $fixed = [System.Collections.Generic.List[string]]::new()
    $fixed.Add($cWarn + (Truncate-Display ' 删除会话历史(删除后进入回收站,可在回收站还原)' $WindowWidth) + $cReset)
    $fixed.Add('')
    $fixed.Add($cDim + (Truncate-Display (" 目录:" + [string]$Confirm.TaskPath) $WindowWidth) + $cReset)
    $fixed.Add($cDim + (Truncate-Display (" 位置:" + [string]$Confirm.ProjectDir) $WindowWidth) + $cReset)
    $extra = if ($plan.OtherCount -gt 0) { ",另含 $($plan.OtherCount) 个附属会话文件" } else { '' }
    $summary = " 将删除该目录下全部 $($plan.SessionCount) 个会话(约 $(Format-CctBytes -Bytes $plan.TotalSize)),含记忆文件夹$extra"
    $fixed.Add((Truncate-Display $summary $WindowWidth))
    $fixed.Add('')
    $maxFixed = [Math]::Max(1, $WindowHeight - 1)
    while ($fixed.Count -gt $maxFixed) { $fixed.RemoveAt($fixed.Count - 1) }
    foreach ($f in $fixed) { $rows.Add($f) }

    # 清单区:固定段之后到末行之前
    $capacity = [Math]::Max(0, $WindowHeight - 1 - $rows.Count)

    # 列宽预算:缩进 2 + sid 8 + 三个 2 空格间隔 + 时间 11 + 大小 10;标题列吃剩余
    $indent = 2; $sidW = 8; $timeW = 11; $sizeW = 10; $sep = 2
    $minTitle = 8
    $showTime = $true; $showSize = $true
    $titleBudget = $WindowWidth - ($indent + $sidW + $sep + $sep + $timeW + $sep + $sizeW)
    if ($titleBudget -lt $minTitle) {
        $showSize = $false                                   # 窄窗降级 1:去掉大小列
        $titleBudget = $WindowWidth - ($indent + $sidW + $sep + $sep + $timeW)
        if ($titleBudget -lt $minTitle) {
            $showTime = $false                               # 窄窗降级 2:再去掉时间列
            $titleBudget = $WindowWidth - ($indent + $sidW + $sep)
        }
    }
    # 标题列取「本批最长标题」与预算的较小值(短标题时更紧凑),下限 $minTitle;
    # 预算本身更小时以预算为准(宁可截断也不越界)
    $longest = 0
    foreach ($s in $plan.Sessions) {
        $tw = Get-DisplayWidth $s.Title
        if ($tw -gt $longest) { $longest = $tw }
    }
    $titleW = $titleBudget
    if ($titleW -gt $minTitle) { $titleW = [Math]::Min($titleW, [Math]::Max($minTitle, $longest)) }

    $total = $plan.Sessions.Count
    $scroll = [Math]::Max(0, [int]$Confirm.Scroll)
    if ($scroll -ge $total -and $total -gt 0) { $scroll = $total - 1 }
    $shown = [Math]::Min($capacity, [Math]::Max(0, $total - $scroll))
    $hasMore = ($scroll + $shown) -lt $total
    if ($hasMore -and $shown -gt 0) { $shown-- }             # 末尾留一行给「还有 N 个」提示

    for ($i = 0; $i -lt $capacity; $i++) {
        if ($i -lt $shown) {
            $s = $plan.Sessions[$scroll + $i]
            $line = ' ' * $indent + (Pad-DisplayRight $s.Sid8 $sidW) + (' ' * $sep) +
                    (Pad-DisplayRight (Truncate-Display $s.Title $titleW) $titleW)
            if ($showTime) { $line += (' ' * $sep) + (Pad-DisplayLeft $s.LastWrite.ToString('MM-dd HH:mm') $timeW) }
            if ($showSize) { $line += (' ' * $sep) + (Pad-DisplayLeft (Format-CctBytes -Bytes $s.Size) $sizeW) }
            $rows.Add($line)
        } elseif ($i -eq $shown -and $hasMore) {
            $rows.Add($cDim + (' ' * $indent + "…还有 $($total - $scroll - $shown) 个(↑↓ 滚动)") + $cReset)
        } elseif ($i -eq 0 -and $total -eq 0) {
            $rows.Add($cDim + (' ' * $indent + '(该目录下没有会话文件)') + $cReset)
        } else {
            $rows.Add('')
        }
    }

    # 填满到末行前 + 末行按键提示(帧高恒 = WindowHeight)
    while ($rows.Count -lt $WindowHeight - 1) { $rows.Add('') }
    # 末行按键提示:确认段红色(危险动作)、取消段与主界面按键提示同为暗灰;
    # 两段各自截断——确认段先占宽,取消段吃剩余,剩余不足则整段丢弃,保证不越界
    $hintDel = Truncate-Display ' y/d/回车 确认删除' $WindowWidth
    $hintCancelW = $WindowWidth - (Get-DisplayWidth $hintDel)
    $hintCancel = if ($hintCancelW -gt 0) { Truncate-Display ' n/Esc 取消' $hintCancelW } else { '' }
    $rows.Add($cWarn + $hintDel + $cDim + $hintCancel + $cReset)
    return $rows.ToArray()
}

# 生成整帧行数组(不写控制台)
# 第十轮布局:搜索行 / 空行 / [卡片块(边框5行)]*MaxRows / [填充行 h-3-MaxRows*5] / 帮助行
# 帧高恒 = WindowHeight(帮助行贴底);无块尾空行、无隐藏提示行——行号稳定 = 固定栏位效果
function New-CctFrame {
    param(
        [pscustomobject[]]$Tasks, [int]$SelectedIndex, [string]$Query,
        [int]$WindowWidth, [int]$WindowHeight, [datetime]$Now,
        [int]$TotalCount = $Tasks.Count,     # 过滤后的 M/N 里 N = 过滤前总数
        [pscustomobject]$Confirm = $null,    # 删除确认屏数据(非空 → 整帧走确认屏渲染)
        [string]$Notice = $null,             # 一次性结果提示(放搜索框右侧计数位,下次按键时清除)
        [bool]$NoticeIsError = $false        # 提示是否用错误样式(红)显示
    )
    $esc = $script:CctEsc
    $cReset = "$esc[0m"; $cDim = "$esc[90m"; $cSel = "$esc[96m"   # 选中:亮青文字(反馈 4,替代整卡反色 ESC[7m)
    $cWarn = "$esc[91m"; $cOk = "$esc[92m"

    if ($Confirm) { return (New-CctConfirmFrame -Confirm $Confirm -WindowWidth $WindowWidth -WindowHeight $WindowHeight -Now $Now) }

    $rows = [System.Collections.Generic.List[string]]::new()

    # ---- 行 0:搜索行(计数紧跟搜索框右侧,决策 47;第八轮 3:占位文案)----
    # 搜索框恒定显示:一次性结果提示(删除成功/失败)占「计数」的位置放在右侧,
    # 不覆盖搜索框——删完立刻能继续输入与翻页,不必先按键清掉一屏提示
    $leftPlain = '搜索: ['
    $left = '搜索: ['
    if ([string]::IsNullOrEmpty($Query)) {
        $leftPlain += '直接输入即可按关键词实时过滤,支持中文'
        $left += $cDim + '直接输入即可按关键词实时过滤,支持中文' + $cReset
    } else {
        $leftPlain += $Query
        $left += $Query
    }
    $leftPlain += ']'; $left += ']'
    $right = ''
    $rightColor = $cDim
    if ($Notice) {
        # 按剩余宽度截断,保证整行不越界
        $budget = [Math]::Max(1, $WindowWidth - (Get-DisplayWidth $leftPlain) - 2)
        $right = ' ' + (Truncate-Display $Notice $budget)
        $rightColor = if ($NoticeIsError) { $cWarn } else { $cOk }
    } elseif ([string]::IsNullOrEmpty($Query)) {
        $right = " 共 $TotalCount 项"                # 计数:无过滤「共 N 项」
    } else {
        $right = " $($Tasks.Count)/$TotalCount"      # 有过滤「M/N」
    }
    $rows.Add($left + $rightColor + $right + $cReset)

    $rows.Add('')   # 空行

    # ---- 卡片网格(第八轮 4:边框卡片)----
    # 列数:≤3,带边框后单卡最小 36 显示宽(内容区最小 34)
    $cols = [Math]::Min(3, [Math]::Max(1, [int][Math]::Floor($WindowWidth / 36)))
    # 卡外框宽:总宽去掉卡间 1 空格后均分(反馈 4:卡间距 2→1);innerW = 两根 │ 之间的列数;padW = 去掉两侧各 1 空格的文本预算
    $gap = 1
    $cardW = [int][Math]::Floor(($WindowWidth - ($cols - 1) * $gap) / $cols)
    $innerW = $cardW - 2
    $padW = $innerW - 2
    $timeW = 10                                  # 时间列预留 10 显示宽(最宽如「59分钟前」)
    $nameW = $padW - $timeW - 1                  # 标题段:文本预算 - 时间列 - 1 空格间隔
    $tails = Get-TailPaths @($Tasks | ForEach-Object { $_.Path } | Select-Object -Unique)

    # 可见卡片块数:帧高恒 = WindowHeight(反馈 2 帮助行贴底)。余下 (h-3-MaxRows*5) 行填到帮助行
    # 上方做间距——搜索行行 0 与帮助行末行行号恒定(固定栏位,行 diff 不重绘)
    $MaxRows = [Math]::Max(0, [int][Math]::Floor(($WindowHeight - 3) / 5))

    # 滚动:按卡片行号(floor(index/cols))保持选中可见
    $rSel = [int][Math]::Floor($SelectedIndex / $cols)
    $startRow = [Math]::Max(0, $rSel - $MaxRows + 1)

    # 每个块槽固定 5 行(边框内容),槽不满也填满(第九轮 4+5:去块尾空行)
    for ($r = $startRow; $r -lt ($startRow + $MaxRows); $r++) {
        # 每张卡 5 行:上边框 / 标题+时间 / 路径1 / 路径2 / 下边框
        $tops = [System.Collections.Generic.List[string]]::new()
        $l1s = [System.Collections.Generic.List[string]]::new()
        $l2s = [System.Collections.Generic.List[string]]::new()
        $l3s = [System.Collections.Generic.List[string]]::new()
        $bots = [System.Collections.Generic.List[string]]::new()
        $top = '┌' + ('─' * $innerW) + '┐'
        $bot = '└' + ('─' * $innerW) + '┘'
        for ($c = 0; $c -lt $cols; $c++) {
            $idx = $r * $cols + $c
            if ($idx -ge $Tasks.Count) { continue }
            $t = $Tasks[$idx]
            $isSel = ($idx -eq $SelectedIndex)
            # 标题行:会话项带目录括号(决策 48 + 第八轮去重)
            $displayName = if ($t.Kind -eq 'Session') {
                Get-CctSessionLabel $t.Name $t.Path
            } else { [string]$t.Name }
            # 时间右对齐(第八轮 5):名称左对齐占 nameW,时间列 Pad-DisplayLeft 靠右
            $namePart = Pad-DisplayRight (Truncate-Display $displayName $nameW) $nameW
            $timePart = Pad-DisplayLeft (Truncate-Display (Get-RelativeTime $t.LastActive $Now) $timeW) $timeW
            $l1 = "│ $namePart $timePart │"
            # 路径两行(决策 47:3 级尾部,超宽换行,不足补空行保证网格对齐)——文本预算 padW
            $tailFull = [string]$tails[$t.Path]
            $l2Plain = if ((Get-DisplayWidth $tailFull) -gt $padW) {
                # 超 1 行宽:第 1 行装满 padW 宽的内容,剩余进第 2 行(可能被截断)
                $w = 0; $i = 0
                while ($i -lt $tailFull.Length -and $w + (Get-DisplayWidth ([string]$tailFull[$i])) -le $padW) {
                    $w += Get-DisplayWidth ([string]$tailFull[$i]); $i++
                }
                ,@($tailFull.Substring(0, $i), (Truncate-Display $tailFull.Substring($i) $padW))
            } else { ,@($tailFull, '') }
            $l2 = '│ ' + (Pad-DisplayRight $l2Plain[0] $padW) + ' │'
            $l3 = '│ ' + (Pad-DisplayRight $l2Plain[1] $padW) + ' │'
            if ($isSel) {
                # 选中整卡(含边框 5 行)亮青文字(反馈 4:整卡反色→亮青,匹配边框线条)
                $top = $cSel + $top + $cReset
                $l1 = $cSel + $l1 + $cReset
                $l2 = $cSel + $l2 + $cReset
                $l3 = $cSel + $l3 + $cReset
                $bot = $cSel + $bot + $cReset
            } else {
                $top = $cDim + $top + $cReset
                $l2 = $cDim + $l2 + $cReset
                $l3 = $cDim + $l3 + $cReset
                $bot = $cDim + $bot + $cReset
            }
            $null = $tops.Add($top)
            $null = $l1s.Add($l1)
            $null = $l2s.Add($l2)
            $null = $l3s.Add($l3)
            $null = $bots.Add($bot)
            # 每卡重置边框模板(选中态已写进变量,下一张卡要用干净的)
            $top = '┌' + ('─' * $innerW) + '┐'
            $bot = '└' + ('─' * $innerW) + '┘'
        }
        $rows.Add(($tops -join (' ' * $gap)))
        $rows.Add(($l1s -join (' ' * $gap)))
        $rows.Add(($l2s -join (' ' * $gap)))
        $rows.Add(($l3s -join (' ' * $gap)))
        $rows.Add(($bots -join (' ' * $gap)))
    }

    # 余数行填充到帮助行上方 → 帧高恒 = WindowHeight,帮助行贴底(反馈 2)
    for ($i = 0; $i -lt ($WindowHeight - 3 - $MaxRows * 5); $i++) { $rows.Add('') }

    # 帮助行恒在末行 → 行号稳定 = 帮助行永远在屏幕最后一行
    # 左侧按键提示 + 右侧居右淡色版权(版权按宽度分档,窗口缩窄时渐进省略)
    # 提示先截断到窗宽:加「d 删除」后提示变长,窄窗口下提示本身就可能超宽;
    # 版权只在「提示 + 版权」整体放得下时才拼(宁可少版权也不越界)
    $help = Truncate-Display ' ↑↓←→ 选择 回车 启动 d 删除 Esc 取消' $WindowWidth
    $helpW = Get-DisplayWidth $help
    $copy = Get-CctCopyright ($WindowWidth - $helpW - 2)   # 2 = 与提示的最小间隔空格
    $copyW = if ($copy) { Get-DisplayWidth $copy } else { 0 }
    if ($copyW -gt 0 -and ($helpW + $copyW) -le $WindowWidth) {
        $rows.Add($cDim + $help + (' ' * ($WindowWidth - $helpW - $copyW)) + $copy + $cReset)
    } else {
        $rows.Add($cDim + $help + $cReset)
    }
    return $rows.ToArray()
}

# 行 diff 重绘(发现 11:缓存上一帧,只重绘变化行,防闪烁)
# 第十一轮反馈:覆盖式写入替代「ESC[K 清行+重写」。选中移动时整行只做颜色切换
# (可见宽不变),直接覆盖不闪;仅当新行可见宽 < 旧行(缩短)或该行被删除($l2=null)时
# 才 ESC[K 清行/清尾,避免残留。
$script:CctLastRows = @()
function Write-CctFrame {
    param([string[]]$Rows)
    $esc = $script:CctEsc
    $sb = [System.Text.StringBuilder]::new()
    $max = [Math]::Max(@($script:CctLastRows).Count, $Rows.Count)
    for ($i = 0; $i -lt $max; $i++) {
        $l1 = if ($null -ne $script:CctLastRows -and $i -lt @($script:CctLastRows).Count) { $script:CctLastRows[$i] } else { $null }
        $l2 = if ($i -lt $Rows.Count) { $Rows[$i] } else { $null }
        if ($l1 -cne $l2) {
            [void]$sb.Append($esc).Append('[').Append($i + 1).Append(';1H')
            if ($null -ne $l2) {
                # 覆盖式写入:直接写新内容(自带完整 ANSI 颜色状态),不先清行 → 无空白帧闪烁
                [void]$sb.Append($l2)
                if ($null -ne $l1 -and
                    (Get-DisplayWidth ($l2 -replace $script:CctAnsiPattern, '')) -lt
                    (Get-DisplayWidth ($l1 -replace $script:CctAnsiPattern, ''))) {
                    [void]$sb.Append($esc).Append('[K')   # 新行变短:清尾残留
                }
            } else {
                [void]$sb.Append($esc).Append('[K')   # 旧行比新帧多:清行
            }
        }
    }
    $script:CctLastRows = @($Rows)
    if ($sb.Length -gt 0) { [Console]::Write($sb.ToString()) }
}

# 构造删除确认屏数据(读盘列清单,供主循环按 d/Delete 时调用)。
# 返回 { Confirm } 或 { Error }——错误走一次性提示,不进确认态。
function New-CctDeleteConfirm {
    param([pscustomobject]$Task)

    if (-not $Task.ProjectDir) { return [pscustomobject]@{ Error = '该项缺少会话目录信息,无法删除' } }
    $plan = Get-CctDirDeletePlan -ProjectDir $Task.ProjectDir
    if (-not $plan.Exists) { return [pscustomobject]@{ Error = '会话目录已不存在,无需删除' } }
    return [pscustomobject]@{ Confirm = [pscustomobject]@{
        TaskPath   = $Task.GroupKey
        ProjectDir = $Task.ProjectDir
        Plan       = $plan
        Scroll     = 0
    } }
}

# 主循环(决策 7/24/31/43/45;KeySource 可注入供测试,生产用轮询读键)
function Show-CctSelector {
    param(
        [pscustomobject[]]$Tasks,
        [string]$InitialQuery = '',
        [scriptblock]$KeySource = $null
    )
    $esc = $script:CctEsc
    if (@($Tasks).Count -eq 0) { return $null }
    $total = @($Tasks).Count

    try { $w = [Console]::WindowWidth } catch { $w = 120 }
    try { $h = [Console]::WindowHeight } catch { $h = 40 }
    # 帧高恒 = $h(帮助行贴底,决策 76);MaxRows 由 New-CctFrame 按 $h 内部计算

    $query = $InitialQuery
    $selected = 0
    $confirm = $null        # 非空 = 删除确认态({ TaskPath, ProjectDir, Plan, Scroll })
    $notice = $null         # 一次性结果提示(显示在搜索框右侧,按键时清除)
    $noticeIsError = $false # 提示样式:错误=红,成功=绿
    $script:CctLastRows = @()
    $result = $null

    # 进入删除确认态(d / Delete 两个入口共用)。构造失败(缺目录信息/目录已不在)
    # 降级为一次性提示,不进确认态。用 dot-source 调用以读写外层变量。
    $openConfirm = {
        if ($filtered.Count -gt 0) {
            $r = New-CctDeleteConfirm -Task $filtered[$selected]
            if ($r.Error) { $notice = $r.Error; $noticeIsError = $true }
            else { $confirm = $r.Confirm }
        }
    }

    # 生产模式:进备用屏幕 + 隐藏光标(发现 11)。测试模式(KeySource 注入)跳过控制台操作
    $interactive = $null -eq $KeySource
    $savedCtrl = $false
    $restoreMode = $null
    try {
        if ($interactive) {
            # 第六轮修复:关掉 VT_INPUT(残留时 ReadKey 把方向键拆成 ESC 序列字符)
            $restoreMode = Enter-CctInputMode
            try { $savedCtrl = [Console]::TreatControlCAsInput } catch { $savedCtrl = $false }
            [Console]::TreatControlCAsInput = $true
            [Console]::Write("$esc[?1049h$esc[?25l")
        }
        while ($true) {
            $filtered = @(Filter-CctTasks $Tasks $query)
            if ($selected -ge $filtered.Count) { $selected = [Math]::Max(0, $filtered.Count - 1) }
            # 列数随当前宽度计算(resize 后 $w 已更新,这里驱动 ↑↓ 跳列;边框卡按 36 算)
            $cols = [Math]::Min(3, [Math]::Max(1, [int][Math]::Floor($w / 36)))
            $frame = @(New-CctFrame $filtered $selected $query $w $h ([datetime]::Now) $total $confirm $notice $noticeIsError)
            if ($interactive) { Write-CctFrame $frame }   # 测试模式不写控制台
            # 读键:生产模式用 KeyAvailable 轮询以检测终端 resize;测试模式直接调 KeySource
            if ($KeySource) {
                $key = & $KeySource
            } else {
                $resized = $false
                while (-not [Console]::KeyAvailable) {
                    $nw = $null; $nh = $null
                    try { $nw = [Console]::WindowWidth } catch { $nw = $w }
                    try { $nh = [Console]::WindowHeight } catch { $nh = $h }
                    if ($nw -ne $w -or $nh -ne $h) {
                        $w = $nw; $h = $nh
                        if ($w -gt 0) { $cols = [Math]::Min(3, [Math]::Max(1, [int][Math]::Floor($w / 36))) }
                        # 反馈 1 根因:旧版 resize 只置空 CctLastRows 触发「全量重绘」,但 Write-CctFrame
                        # 行数取 Max(旧帧数,新帧数),置空后旧帧行数=0 → 旧帧比新帧多的行永不重绘 → 残影。
                        # 修复:先真清屏(ESC[2J)再置空,物理屏旧内容彻底清除,下一帧全量重绘。
                        # 反馈 11:清屏后必须立即重绘(continue 跳回外层循环顶部),否则停止后需按键才显示。
                        [Console]::Write("$esc[2J$esc[H")
                        $script:CctLastRows = @()   # 清屏后强制下一帧全量重绘;selected 保持
                        $resized = $true
                        break
                    }
                    Start-Sleep -Milliseconds 30
                }
                if ($resized) { continue }   # 跳过 ReadKey,立即用新尺寸重绘一帧
                $key = [Console]::ReadKey($true)
            }
            if ($null -eq $key) { return $null }           # 序列耗尽 = 取消
            # Ctrl+C 取消
            if ($key.Key -eq [ConsoleKey]::C -and ($key.Modifiers -band [System.ConsoleModifiers]::Control)) { return $null }

            # 一次性结果提示:本次按键先清掉旧提示,但按键照常处理——
            # 删完立刻处于正常操作状态,不必先按一下把提示按掉
            if ($notice) { $notice = $null; $noticeIsError = $false }

            # 删除确认态:y/回车 执行,n/Esc 取消,↑↓ 滚动清单,其余键一律无效
            # (不做「其他键取消」——用户可能想滚动看完清单再决定,误触不该有后果)
            if ($confirm) {
                $doDelete = $false
                switch ($key.Key) {
                    ([ConsoleKey]::UpArrow)   { if ($confirm.Scroll -gt 0) { $confirm.Scroll-- } }
                    ([ConsoleKey]::DownArrow) {
                        $maxScroll = [Math]::Max(0, @($confirm.Plan.Sessions).Count - 1)
                        if ($confirm.Scroll -lt $maxScroll) { $confirm.Scroll++ }
                    }
                    ([ConsoleKey]::Escape)    { $confirm = $null }
                    ([ConsoleKey]::Enter)     { $doDelete = $true }
                    ([ConsoleKey]::Y)         { $doDelete = $true }
                    ([ConsoleKey]::D)         { $doDelete = $true }   # 与「d 进确认」同键,连按两下即删
                    ([ConsoleKey]::N)         { $confirm = $null }
                }
                if ($doDelete -and $confirm) {
                    $target = $confirm
                    # 删前再探一次占用(构造确认屏到此刻之间用户可能开了会话):
                    # 既给明确提示,也避开删除 API 在错误路径上的系统对话框
                    if (Test-CctDirInUse -ProjectDir $target.ProjectDir) {
                        $notice = '该目录有会话正在使用,请先退出那个会话再删'
                        $noticeIsError = $true
                    } else {
                        $res = Remove-CctDirToRecycleBin -ProjectDir $target.ProjectDir
                        if ($res.Ok) {
                            $notice = '删除成功'      # 简短提示,不挡操作
                            $noticeIsError = $false
                            # 该目录的全部卡片一起消失(同一 GroupKey 可能有多个命名会话卡)
                            $Tasks = @($Tasks | Where-Object { $_.GroupKey -ne $target.TaskPath })
                            $total = @($Tasks).Count
                            $selected = 0
                        } else {
                            $notice = "删除失败:$($res.Message)"
                            $noticeIsError = $true
                        }
                    }
                    $confirm = $null
                }
                continue
            }

            switch ($key.Key) {
                ([ConsoleKey]::LeftArrow)  { $selected = [Math]::Max(0, $selected - 1) }
                ([ConsoleKey]::RightArrow) { $selected = [Math]::Min($filtered.Count - 1, $selected + 1) }
                ([ConsoleKey]::UpArrow)    { $selected = [Math]::Max(0, $selected - $cols) }
                ([ConsoleKey]::DownArrow)  { $selected = [Math]::Min($filtered.Count - 1, $selected + $cols) }
                ([ConsoleKey]::Enter) {
                    if ($filtered.Count -gt 0) { $result = $filtered[$selected] }
                    return $result
                }
                ([ConsoleKey]::Escape)     { return $null }
                ([ConsoleKey]::Backspace)  { if ($query.Length -gt 0) { $query = $query.Substring(0, $query.Length - 1) } }
                ([ConsoleKey]::Delete)     { . $openConfirm }
                default {
                    $ch = $key.KeyChar
                    # d 与删除键兼用:搜索框为空时按 d 进删除确认(顺手),已输入搜索词时
                    # d 照常进输入框(否则搜不了含 d 的关键词),要删就按 Delete
                    if ($key.Key -eq [ConsoleKey]::D -and [string]::IsNullOrEmpty($query)) { . $openConfirm }
                    elseif ($ch -ge [char]0x20 -and $ch -ne [char]0x7F) { $query += $ch }
                }
            }
        }
    } finally {
        if ($interactive) {
            try { [Console]::TreatControlCAsInput = $savedCtrl } catch {}
            try { [Console]::Write("$esc[?25h$esc[?1049l$esc[0m") } catch {}
            if ($restoreMode) { & $restoreMode }
        }
    }
}