Split the Windows script into modules; drop the Arkylx Index pieces

claude-mode.ps1 was 2,407 lines. It is now 153: the help block, parameters,
paths, the managed-key list, a loader, and the dispatch. The rest moved,
verbatim, into eleven files under lib/ - providers, output, files, core,
vault, switch, guards, health, catalogue, commands, menu - dot-sourced into
the script's scope in their original order, with the same check as the bash
split that every original line landed in exactly one file. Inside a module
$PSScriptRoot is lib\, so the one path beside the main script
(providers.json) now goes through $script:Here.

install.ps1 ships lib\, clearing old modules first. The Windows suite
parses every module and install.ps1 (36 checks, all green on Windows
PowerShell 5.1); install.ps1 is parsed but never run, since it edits the
real profile and User PATH.

linux/bootstrap.sh and scripts/build-package.ps1 existed only to build and
serve packages for the Arkylx Index. Both installers fetch the repository's
own archive, so a push to master is the release; the two scripts, the dist/
ignore and their mentions in the docs are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-15 02:06:45 +03:00
co-authored by Claude Opus 5
parent 441166d003
commit 4091746d4e
21 changed files with 2421 additions and 2527 deletions
+669
View File
@@ -0,0 +1,669 @@
# lib/menu.ps1 - the interactive menu, its pickers, and interactive preset editing.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# Interactive menu (claude-mode with no arguments)
# ---------------------------------------------------------------------------
function Test-Interactive {
try {
if ([Console]::IsInputRedirected) { return $false }
if ([Console]::IsOutputRedirected) { return $false }
[void][Console]::WindowWidth
return $true
} catch { return $false }
}
# ---------------------------------------------------------------------------
# UI frame - the screen region the interactive menus own.
#
# Successive selectors (mode -> preset, preset -> tier) must REPLACE each other
# rather than stack, otherwise the earlier list stays on screen looking frozen
# and interactive. The frame remembers where the current run of menus began;
# each new selector wipes back to that line and redraws from it.
#
# Output that must persist (a switch summary, doctor results) calls Stop-UiFrame
# first: that erases the menu, then lets the output print in the freed space and
# stay there, with the next menu opening a fresh frame below it.
# ---------------------------------------------------------------------------
$script:UiActive = $false
$script:UiTop = 0
$script:UiQuit = $false # set by the submenu to unwind the whole menu stack
function Start-UiFrame {
if ($script:UiActive) { return }
try { $script:UiTop = [Console]::CursorTop } catch { $script:UiTop = 0 }
$script:UiActive = $true
}
function Clear-UiFrame {
if (-not $script:UiActive) { return }
try {
$w = [Math]::Max(1, [Console]::WindowWidth - 1)
$blank = ' ' * $w
$bottom = [Console]::CursorTop
for ($y = $script:UiTop; $y -le $bottom; $y++) {
[Console]::SetCursorPosition(0, $y)
[Console]::Write($blank)
}
[Console]::SetCursorPosition(0, $script:UiTop)
} catch { }
}
function Stop-UiFrame {
Clear-UiFrame
$script:UiActive = $false
}
# ---------------------------------------------------------------------------
# Show-Select - arrow-key list picker.
#
# Draws the list once, then repaints it in place on every keypress by parking
# the cursor back at the top of the region. Each row is padded to the console
# width so a repaint erases whatever the previous, longer row left behind.
# A fixed-height detail pane under the list shows the highlighted row's info,
# which keeps the geometry constant - variable-height rows would make the
# in-place repaint arithmetic fragile.
#
# Returns the selected index, or -1 if the user cancelled / the console cannot
# support this (callers fall back to Read-Choice).
# ---------------------------------------------------------------------------
function Show-Select {
param(
[string] $Title,
[string] $Status,
[object[]] $Items, # each: Label (string), Detail (string[])
[int] $Default = 0,
[int] $DetailLines = 3
)
if (-not (Test-Interactive)) { return -1 }
$n = @($Items).Count
if ($n -eq 0) { return -1 }
$idx = [Math]::Max(0, [Math]::Min($Default, $n - 1))
$w = [Math]::Max(40, [Console]::WindowWidth - 1)
# Take over the frame: a menu already on screen is wiped so this one lands
# in its place instead of below it.
if ($script:UiActive) { Clear-UiFrame } else { Start-UiFrame }
$headerH = 3
Write-Host ''
if ($Title) {
$headerH = 4
$t = " $Title"
if ($Status) { $t = $t.PadRight(34) + $Status }
Write-Host $t -ForegroundColor Cyan
}
Write-Host ' up/down move enter select esc cancel' -ForegroundColor DarkGray
Write-Host ''
# Reserve the region first so the cursor never has to scroll mid-repaint,
# then rewind to its top.
$regionH = $n + 1 + $DetailLines
for ($i = 0; $i -lt $regionH; $i++) { Write-Host '' }
$top = [Console]::CursorTop - $regionH
# Reserving may have scrolled the buffer, which moves everything up and
# invalidates the remembered frame top. Re-anchor it from where the list
# actually landed.
$script:UiTop = [Math]::Max(0, $top - $headerH)
# Truncate-or-pad a row to exactly the console width, so repainting a short
# row fully erases a longer one underneath it.
$pad = {
param([string] $s)
if ($s.Length -gt $w) { $s = $s.Substring(0, $w) }
return $s.PadRight($w)
}
$cursorWasVisible = $true
try { $cursorWasVisible = [Console]::CursorVisible; [Console]::CursorVisible = $false } catch { }
try {
while ($true) {
[Console]::SetCursorPosition(0, $top)
for ($i = 0; $i -lt $n; $i++) {
$sel = ($i -eq $idx)
$mark = if ($sel) { ' > ' } else { ' ' }
$row = & $pad ($mark + $Items[$i].Label)
if ($sel) {
# An item may carry its own accent (mode rows do), so the
# highlight itself tells you which provider you are on.
$bg = 'Cyan'
if ($Items[$i].PSObject.Properties.Name -contains 'Accent' -and $Items[$i].Accent) {
$bg = [string]$Items[$i].Accent
}
Write-Host $row -ForegroundColor Black -BackgroundColor $bg
}
else { Write-Host $row -ForegroundColor Gray }
}
Write-Host (& $pad '')
$detail = @($Items[$idx].Detail)
for ($k = 0; $k -lt $DetailLines; $k++) {
$line = if ($k -lt $detail.Count) { ' ' + $detail[$k] } else { '' }
Write-Host (& $pad $line) -ForegroundColor DarkGray
}
# if/elseif rather than switch: `break`/`continue` inside a switch
# nested in a loop is ambiguous in PowerShell, and this is the input
# loop - it has to be unambiguous.
$key = [Console]::ReadKey($true)
$k = [string]$key.Key
$ch = $key.KeyChar
if ($k -eq 'UpArrow' -or $k -eq 'K') { $idx = ($idx - 1 + $n) % $n }
elseif ($k -eq 'DownArrow' -or $k -eq 'J') { $idx = ($idx + 1) % $n }
elseif ($k -eq 'Home') { $idx = 0 }
elseif ($k -eq 'End') { $idx = $n - 1 }
elseif ($k -eq 'Enter' -or $k -eq 'Spacebar') { return $idx }
elseif ($k -eq 'Escape') { return -1 }
elseif ($ch -eq 'q' -or $ch -eq 'Q') { return -1 }
elseif ($ch -match '^[1-9]$' -and [int][string]$ch -le $n) {
# Number keys still work as direct shortcuts.
return ([int][string]$ch - 1)
}
}
} finally {
try {
[Console]::SetCursorPosition(0, $top + $regionH)
[Console]::CursorVisible = $cursorWasVisible
} catch { }
}
}
# Numeric fallback, used when the console cannot host Show-Select.
function Read-Choice {
param([string] $Prompt, [int] $Max, [string] $Default = '')
while ($true) {
$raw = Read-Host $Prompt
if ([string]::IsNullOrWhiteSpace($raw)) {
if ($Default) { return $Default }
continue
}
$raw = $raw.Trim()
if ($raw -eq 'q' -or $raw -eq '0') { return 'q' }
$n = 0
if ([int]::TryParse($raw, [ref]$n) -and $n -ge 1 -and $n -le $Max) { return [string]$n }
Write-Host ' (invalid choice)' -ForegroundColor DarkGray
}
}
function Get-PresetSummaryLines {
param($Preset)
$out = @()
if ($Preset.Contains('description')) { $out += [string]$Preset['description'] }
$bits = @()
foreach ($t in $script:Tiers) {
if ($Preset['models'].Contains($t)) { $bits += "$t=$($Preset['models'][$t])" }
}
if ($bits.Count) { $out += ($bits -join ' ') }
if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) {
$out += ("context {0:N0} tokens base {1}" -f [int]$Preset['contextTokens'], $Preset['baseUrl'])
} else {
$out += "base $($Preset['baseUrl'])"
}
return $out
}
function Invoke-PresetPicker {
param([string] $Provider)
$names = @(Get-PresetNamesForProvider $Provider)
if ($names.Count -eq 0) { throw "no presets defined for provider '$Provider'" }
$default = $script:ProviderDefaultPreset[$Provider]
if (-not ($names -contains $default)) { $default = $names[0] }
$defaultIdx = [Math]::Max(0, [array]::IndexOf($names, $default))
$items = @()
foreach ($n in $names) {
$p = Get-Preset $n
$label = $n
if ($n -eq $default) { $label = "$n (default)" }
$items += [pscustomobject]@{ Label = $label; Detail = (Get-PresetSummaryLines $p) }
}
$sel = Show-Select -Title "preset for $Provider" -Items $items -Default $defaultIdx
if ($sel -ge 0) { return $names[$sel] }
if ($sel -eq -1 -and (Test-Interactive)) { return $null } # cancelled
# console too limited for the picker - fall back to numbers
for ($i = 0; $i -lt $names.Count; $i++) { Write-Host (" {0}) {1}" -f ($i + 1), $names[$i]) }
$c = Read-Choice -Prompt " preset [Enter = $default, q = cancel]" -Max $names.Count -Default 'D'
if ($c -eq 'q') { return $null }
if ($c -eq 'D') { return $default }
return $names[[int]$c - 1]
}
# ---------------------------------------------------------------------------
# Show-SearchSelect - arrow-key picker with a live type-to-filter box.
#
# Show-Select is fine for a handful of rows, but OpenRouter lists 300+ models,
# which is unusable as a flat list. This keeps the same in-place repaint but adds
# a filter line and a scrolling window over the matches.
#
# Returns the index into $Items, or -1 to cancel.
# ---------------------------------------------------------------------------
function Show-SearchSelect {
param(
[string] $Title,
[string] $Status,
[object[]] $Items, # each: Label (string), Detail (string[]), Key (string, searched)
[string] $Query = '',
[int] $MaxRows = 12,
[int] $DetailLines = 2
)
if (-not (Test-Interactive)) { return -1 }
$all = @($Items)
if ($all.Count -eq 0) { return -1 }
$w = [Math]::Max(40, [Console]::WindowWidth - 1)
$MaxRows = [Math]::Max(3, [Math]::Min($MaxRows, [Console]::WindowHeight - 12))
if ($script:UiActive) { Clear-UiFrame } else { Start-UiFrame }
$headerH = 3
Write-Host ''
if ($Title) {
$headerH = 4
$t = " $Title"
if ($Status) { $t = $t.PadRight(34) + $Status }
Write-Host $t -ForegroundColor Cyan
}
Write-Host ' type to filter up/down move enter select esc cancel' -ForegroundColor DarkGray
Write-Host ''
# filter line + blank + rows + count + blank + detail
$regionH = 1 + 1 + $MaxRows + 1 + 1 + $DetailLines
for ($i = 0; $i -lt $regionH; $i++) { Write-Host '' }
$top = [Console]::CursorTop - $regionH
$script:UiTop = [Math]::Max(0, $top - $headerH)
$pad = {
param([string] $s)
if ($s.Length -gt $w) { $s = $s.Substring(0, $w) }
return $s.PadRight($w)
}
$idx = 0
$off = 0
$cursorWasVisible = $true
try { $cursorWasVisible = [Console]::CursorVisible; [Console]::CursorVisible = $false } catch { }
try {
while ($true) {
# Filter on every keystroke. Match against Key when present so a row
# can display extra decoration without breaking search.
$q = $Query.Trim().ToLower()
if ($q) {
$matches = @($all | Where-Object {
$hay = if ($_.PSObject.Properties.Name -contains 'Key' -and $_.Key) { [string]$_.Key } else { [string]$_.Label }
$hay.ToLower().Contains($q)
})
} else {
$matches = $all
}
$m = $matches.Count
if ($idx -ge $m) { $idx = [Math]::Max(0, $m - 1) }
if ($idx -lt $off) { $off = $idx }
if ($idx -ge $off + $MaxRows) { $off = $idx - $MaxRows + 1 }
if ($off -gt [Math]::Max(0, $m - $MaxRows)) { $off = [Math]::Max(0, $m - $MaxRows) }
[Console]::SetCursorPosition(0, $top)
Write-Host (& $pad (" filter: " + $Query + "_")) -ForegroundColor White
Write-Host (& $pad '')
for ($r = 0; $r -lt $MaxRows; $r++) {
$i = $off + $r
if ($i -ge $m) { Write-Host (& $pad ''); continue }
$sel = ($i -eq $idx)
$mark = if ($sel) { ' > ' } else { ' ' }
$row = & $pad ($mark + $matches[$i].Label)
if ($sel) { Write-Host $row -ForegroundColor Black -BackgroundColor Cyan }
else { Write-Host $row -ForegroundColor Gray }
}
$count = if ($m -eq 0) { ' (no match)' } else { " $($idx + 1) of $m" + $(if ($all.Count -ne $m) { " (filtered from $($all.Count))" } else { '' }) }
Write-Host (& $pad $count) -ForegroundColor DarkGray
Write-Host (& $pad '')
$detail = if ($m -gt 0) { @($matches[$idx].Detail) } else { @() }
for ($k = 0; $k -lt $DetailLines; $k++) {
$line = if ($k -lt $detail.Count) { ' ' + $detail[$k] } else { '' }
Write-Host (& $pad $line) -ForegroundColor DarkGray
}
$key = [Console]::ReadKey($true)
$k = [string]$key.Key
$ch = $key.KeyChar
if ($k -eq 'UpArrow') { if ($m) { $idx = ($idx - 1 + $m) % $m } }
elseif ($k -eq 'DownArrow') { if ($m) { $idx = ($idx + 1) % $m } }
elseif ($k -eq 'PageUp') { $idx = [Math]::Max(0, $idx - $MaxRows) }
elseif ($k -eq 'PageDown') { $idx = [Math]::Min([Math]::Max(0, $m - 1), $idx + $MaxRows) }
elseif ($k -eq 'Home') { $idx = 0 }
elseif ($k -eq 'End') { $idx = [Math]::Max(0, $m - 1) }
elseif ($k -eq 'Enter') { if ($m -gt 0) { return [array]::IndexOf($all, $matches[$idx]) } }
elseif ($k -eq 'Escape') { return -1 }
elseif ($k -eq 'Backspace') {
if ($Query.Length -gt 0) { $Query = $Query.Substring(0, $Query.Length - 1); $idx = 0; $off = 0 }
}
elseif ($ch -and [int][char]$ch -ge 32 -and [int][char]$ch -lt 127) {
$Query += $ch; $idx = 0; $off = 0
}
}
} finally {
try {
[Console]::SetCursorPosition(0, $top + $regionH)
[Console]::CursorVisible = $cursorWasVisible
} catch { }
}
}
# Catalogue per provider, shaped for the picker. Cached per process so opening
# the picker repeatedly in one session does not refetch OpenRouter's 300+ models.
$script:ModelCatalogueCache = @{}
function Get-ModelChoices {
param($Preset)
$provider = [string]$Preset['provider']
$cacheKey = $provider + '|' + [string]$Preset['baseUrl']
if ($script:ModelCatalogueCache.ContainsKey($cacheKey)) { return $script:ModelCatalogueCache[$cacheKey] }
$out = @()
$kind = [string](Get-Provider $provider).catalogue.kind
$tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() })
foreach ($m in (Get-ProviderCatalogue $Preset | Sort-Object Id)) {
$flag = ''
if ($kind -eq 'openrouter') {
$det = @(("context {0:N0} `$$($m.InM) in / `$$($m.OutM) out per 1M tokens" -f [int]$m.Ctx))
} elseif ($kind -eq 'lmstudio') {
$det = @("state: $($m.State) max context: $($m.Ctx)")
$short = ($m.Id -split '/')[-1].ToLower()
$risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
if ($risk -and $risk.Assertions.Count -gt 0) {
$flag = ' [TEMPLATE RISK]'
$det += 'chat template asserts message order - can break tool calls'
}
} else {
$det = @([string]$m.Note)
}
$out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det }
}
$script:ModelCatalogueCache[$cacheKey] = $out
return $out
}
function Read-ModelId {
param($Preset, [string] $Tier, [string] $Current)
$choices = @()
$err = $null
try { $choices = @(Get-ModelChoices $Preset) } catch { $err = $_.Exception.Message }
if ($choices.Count -gt 0) {
$items = @()
# First row is always the manual escape hatch - a catalogue can lag
# behind what the provider actually accepts.
$items += [pscustomobject]@{ Id = $null; Key = 'type manually custom'; Label = '<type an id manually>'; Detail = @('enter any model id by hand') }
$items += $choices
$sel = Show-SearchSelect -Title "model for '$Tier'" -Status "current: $Current" -Items $items
if ($sel -lt 0) { return $null }
if ($sel -gt 0) { return $items[$sel].Id }
# fall through to manual entry
}
# A typed prompt cannot live inside the repainting frame - the redraw would
# erase what is being typed. Close the frame first.
Stop-UiFrame
Write-Host ''
if ($err) { Write-Warn2 "could not load the model list: $err" }
Write-Host " current $Tier : $Current" -ForegroundColor DarkGray
$val = Read-Host " new model id for '$Tier' (blank = cancel)"
if ([string]::IsNullOrWhiteSpace($val)) { return $null }
return $val.Trim()
}
# A blank preset per provider, with the endpoint/auth/context bits already right
# so only the model choices are left to make.
function New-PresetScaffold {
param([string] $Provider)
# Built from the provider's own template in providers.json; the POSIX
# build's `cm-json.py scaffold` produces the same thing from the same file.
$p = Get-Provider $Provider
if (-not $p) { throw "unknown provider '$Provider'" }
$tpl = $p.preset
$base = [ordered]@{
provider = $Provider
description = 'new preset'
}
$base['baseUrl'] = [string]$tpl.baseUrl
$auth = [ordered]@{}
if ($tpl.auth) { foreach ($prop in $tpl.auth.PSObject.Properties) { $auth[$prop.Name] = $prop.Value } }
else { $auth['mode'] = 'vault'; $auth['keyRef'] = $Provider }
$base['auth'] = $auth
$base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' }
$base['subagentModel'] = 'inherit'
$base['gatewayModelDiscovery'] = [bool]$tpl.gatewayModelDiscovery
$base['contextTokens'] = $(if ($tpl.contextTokens) { [int]$tpl.contextTokens } else { 200000 })
if ($tpl.extraEnv) {
$extra = [ordered]@{}
foreach ($prop in $tpl.extraEnv.PSObject.Properties) { $extra[$prop.Name] = [string]$prop.Value }
$base['extraEnv'] = $extra
}
return $base
}
function New-PresetInteractive {
# 1. provider
$provs = @($script:Modes | Where-Object { $_ -ne 'anthropic' })
$items = @()
foreach ($p in $provs) { $items += [pscustomobject]@{ Label = $p; Detail = @($script:ModeLabel[$p]) } }
$sel = Show-Select -Title 'new preset - which provider' -Items $items
if ($sel -lt 0) { return }
$provider = $provs[$sel]
# 2. start from an existing preset of that provider, or blank
$siblings = @(Get-PresetNamesForProvider $provider)
$items = @([pscustomobject]@{ Label = '<blank>'; Detail = @("empty $provider preset - pick every model yourself") })
foreach ($n in $siblings) {
$items += [pscustomobject]@{ Label = "copy of $n"; Detail = (Get-PresetSummaryLines (Get-Preset $n)) }
}
$sel = Show-Select -Title 'start from' -Items $items
if ($sel -lt 0) { return }
$preset = if ($sel -eq 0) { New-PresetScaffold $provider } else { Get-Preset $siblings[$sel - 1] }
# 3. name it
Stop-UiFrame
Write-Host ''
Write-Host " new $provider preset" -ForegroundColor Cyan
$name = Read-Host ' name (letters, digits, dash; blank = cancel)'
if ([string]::IsNullOrWhiteSpace($name)) { return }
$name = $name.Trim()
if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') {
Write-Err2 "invalid name '$name' - use letters, digits, dash, dot, underscore"
return
}
if (Test-Path -LiteralPath (Get-PresetPath $name)) {
Write-Err2 "preset '$name' already exists"
return
}
$preset['description'] = if ($sel -eq 0) { "custom $provider preset" } else { "copy of $($siblings[$sel - 1])" }
Write-JsonFile (Get-PresetPath $name) $preset
Write-Ok "created preset '$name' ($provider)"
# 4. straight into editing it
Invoke-PresetEditor -Name $name
}
function Invoke-PresetEditor {
param([string] $Name)
if (-not $Name) {
$names = @(Get-PresetNames)
if ($names.Count -eq 0) { Write-Warn2 'no presets'; return }
$items = @()
foreach ($n in $names) {
$p = Get-Preset $n
$items += [pscustomobject]@{
Label = ("{0,-18} [{1}]" -f $n, $p['provider'])
Detail = (Get-PresetSummaryLines $p)
}
}
$sel = Show-Select -Title 'edit which preset' -Items $items
if ($sel -lt 0) { return }
$Name = $names[$sel]
}
$name = $Name
$preset = Get-Preset $name
while ($true) {
$rows = @()
foreach ($t in $script:Tiers) {
$cur = ''
if ($preset['models'].Contains($t)) { $cur = [string]$preset['models'][$t] }
$rows += [pscustomobject]@{ Tier = $t; Current = $cur }
}
$sub = ''
if ($preset.Contains('subagentModel')) { $sub = [string]$preset['subagentModel'] }
$rows += [pscustomobject]@{ Tier = 'subagent'; Current = $sub }
$items = @()
foreach ($r in $rows) {
$items += [pscustomobject]@{
Label = ("{0,-9} {1}" -f $r.Tier, $r.Current)
Detail = @("change which model backs the '$($r.Tier)' tier", "current: $($r.Current)")
}
}
$c = Show-Select -Title "$name [$($preset['provider'])]" -Status 'esc = done' -Items $items
if ($c -lt 0) { return }
$tier = $rows[$c].Tier
$val = Read-ModelId -Preset $preset -Tier $tier -Current $rows[$c].Current
if (-not $val) { continue }
# Set-PresetTier may re-apply the live preset, which prints a full switch
# summary. Drop the frame so that output survives instead of being wiped
# by the next redraw of the tier list.
Stop-UiFrame
Set-PresetTier -Name $name -Tier $tier -Model $val
$preset = Get-Preset $name
}
}
# The second level: everything that is not switching mode. Loops until the user
# backs out, so running doctor then editing a preset costs one trip in.
function Invoke-MoreMenu {
while ($true) {
$state = Get-State
$current = [string]$state['mode']
$curDesc = $current
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
$items = @(
[pscustomobject]@{ Label = 'status'; Detail = @('show the full active configuration') }
[pscustomobject]@{ Label = 'edit presets'; Detail = @('pick models per tier from the provider catalogue') }
[pscustomobject]@{ Label = 'new preset'; Detail = @('create a preset - blank or copied from an existing one') }
[pscustomobject]@{ Label = 'doctor'; Detail = @('verify auth, endpoint, model ids, context window') }
[pscustomobject]@{ Label = 'back'; Detail = @('return to the mode menu') }
[pscustomobject]@{ Label = 'quit'; Detail = @() }
)
$sel = Show-Select -Title 'claude-mode - more' -Status "currently: $curDesc" -Items $items
if ($sel -lt 0) { return } # esc = back
switch ($sel) {
0 { Stop-UiFrame; Invoke-Status } # output worth keeping
1 { Invoke-PresetEditor } # more menus - keep the frame
2 { New-PresetInteractive }
3 { Stop-UiFrame; Invoke-Doctor }
4 { return }
5 { Stop-UiFrame; $script:UiQuit = $true; return }
}
}
}
function Invoke-Menu {
if (-not (Test-Interactive)) { Invoke-Status; return }
$script:UiQuit = $false
# Drawn once, above the frame anchor, so the menus repaint underneath it and
# the banner stays put instead of flickering on every keypress.
$st0 = Get-State
Show-Banner -Mode ([string]$st0['mode']) -Preset $(if ([string]$st0['mode'] -ne 'anthropic') { [string]$st0['preset'] } else { '' })
while ($true) {
$state = Get-State
$current = [string]$state['mode']
$curDesc = $current
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
# The mode you are already in is not offered - nothing to switch to.
$choices = @($script:Modes | Where-Object { $_ -ne $current })
$items = @()
foreach ($m in $choices) {
$detail = @($script:ModeLabel[$m])
try {
$pn = $script:ProviderDefaultPreset[$m]
if ($pn -and (Test-Path -LiteralPath (Get-PresetPath $pn))) {
$detail += (Get-PresetSummaryLines (Get-Preset $pn))[1]
}
} catch { }
$items += [pscustomobject]@{
Label = ("switch to " + $m)
Detail = $detail
Accent = $script:ModeColor[$m]
}
}
# Everything that is not "switch mode" lives one level down, so the three
# things this tool exists to do are the whole first screen.
$items += [pscustomobject]@{ Label = 'more ...'; Detail = @('status, presets, doctor') }
$sel = Show-Select -Title 'claude-mode' -Status "currently: $curDesc" -Items $items
if ($sel -lt 0) { Stop-UiFrame; return }
$nChoices = $choices.Count
if ($sel -eq $nChoices) {
Invoke-MoreMenu
if ($script:UiQuit) { return }
continue
}
$mode = $choices[$sel]
if ($mode -eq 'anthropic') { Stop-UiFrame; Set-ClaudeMode -Mode 'anthropic' -PresetName ''; return }
$preset = Invoke-PresetPicker -Provider $mode
if (-not $preset) { continue }
Stop-UiFrame
Set-ClaudeMode -Mode $mode -PresetName $preset
return
}
}