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:
+669
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user