Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions ui-tui/src/__tests__/modelPicker.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { providerIndexAfterClearingFilter } from '../components/modelPicker.js'
import { modelOptionsRequestParams, providerIndexAfterClearingFilter } from '../components/modelPicker.js'
import type { ModelOptionProvider } from '../gatewayTypes.js'

const provider = (slug: string, name = slug): ModelOptionProvider => ({ name, slug })
Expand All @@ -21,9 +21,7 @@ describe('ModelPicker provider filtering', () => {
})

it('returns -1 when provider is undefined', () => {
const rows = [
{ name: 'A', provider: provider('a') }
]
const rows = [{ name: 'A', provider: provider('a') }]

expect(providerIndexAfterClearingFilter(rows, undefined)).toBe(-1)
})
Expand Down Expand Up @@ -52,3 +50,33 @@ describe('ModelPicker provider filtering', () => {
expect(providerIndexAfterClearingFilter(rows, p)).toBe(0)
})
})

describe('ModelPicker model.options params', () => {
it('requests the full provider universe by default', () => {
expect(modelOptionsRequestParams('sess-1', false, false)).toEqual({
session_id: 'sess-1',
include_unconfigured: true
})
})

it('requests explicit configured providers when hiding unconfigured providers', () => {
expect(modelOptionsRequestParams('sess-1', false, true)).toEqual({
session_id: 'sess-1',
explicit_only: true
})
})

it('preserves refresh while requesting all providers', () => {
expect(modelOptionsRequestParams(null, true, false)).toEqual({
refresh: true,
include_unconfigured: true
})
})

it('preserves refresh while requesting configured providers', () => {
expect(modelOptionsRequestParams(null, true, true)).toEqual({
refresh: true,
explicit_only: true
})
})
})
71 changes: 59 additions & 12 deletions ui-tui/src/components/modelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,42 @@ const MAX_WIDTH = 90

type Stage = 'provider' | 'key' | 'model' | 'disconnect'

type ModelOptionsRequestParams = {
explicit_only?: true
include_unconfigured?: true
refresh?: true
session_id?: string
}

type ProviderRow = { name: string; provider: ModelOptionProvider }

export function providerIndexAfterClearingFilter(providerRows: ProviderRow[], provider: ModelOptionProvider | undefined) {
export function providerIndexAfterClearingFilter(
providerRows: ProviderRow[],
provider: ModelOptionProvider | undefined
) {
if (!provider) {
return -1
}

return providerRows.findIndex(row => row.provider.slug === provider.slug)
}

// The TUI picker defaults to the full provider universe with setup
// affordances ("paste KEY to activate"), so it opts into unconfigured
// rows — the backend defaults to the configured subset for desktop chat
// pickers (#56974). ^a flips to that same explicit-providers subset.
export function modelOptionsRequestParams(
sessionId: string | null,
initialRefresh: boolean,
hideUnconfigured: boolean
): ModelOptionsRequestParams {
return {
...(sessionId ? { session_id: sessionId } : {}),
...(initialRefresh ? { refresh: true } : {}),
...(hideUnconfigured ? { explicit_only: true } : { include_unconfigured: true })
}
}

export function ModelPicker({
allowPersistGlobal = true,
gw,
Expand All @@ -49,6 +75,7 @@ export function ModelPicker({
const [keyError, setKeyError] = useState('')
// Type-to-filter query, scoped per stage (cleared on stage change).
const [filter, setFilter] = useState('')
const [hideUnconfigured, setHideUnconfigured] = useState(false)

const { stdout } = useStdout()
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
Expand All @@ -58,16 +85,18 @@ export function ModelPicker({
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))

useEffect(() => {
gw.request<ModelOptionsResponse>('model.options', {
...(sessionId ? { session_id: sessionId } : {}),
...(initialRefresh ? { refresh: true } : {}),
// The TUI picker shows the full provider universe with setup
// affordances ("paste KEY to activate"), so opt into unconfigured
// rows — the backend now defaults to the configured subset for
// desktop chat pickers (#56974).
include_unconfigured: true
})
let cancelled = false

setLoading(true)
gw.request<ModelOptionsResponse>(
'model.options',
modelOptionsRequestParams(sessionId, initialRefresh, hideUnconfigured)
)
.then(raw => {
if (cancelled) {
return
}

const r = asRpcResult<ModelOptionsResponse>(raw)

if (!r) {
Expand All @@ -92,10 +121,18 @@ export function ModelPicker({
setLoading(false)
})
.catch((e: unknown) => {
if (cancelled) {
return
}

setErr(rpcErrorMessage(e))
setLoading(false)
})
}, [gw, initialRefresh, sessionId])

return () => {
cancelled = true
}
}, [gw, hideUnconfigured, initialRefresh, sessionId])

const names = useMemo(() => providerDisplayNames(providers), [providers])

Expand Down Expand Up @@ -424,6 +461,14 @@ export function ModelPicker({
return
}

if (key.ctrl && ch === 'a' && stage === 'provider') {
setHideUnconfigured(v => !v)
setFilter('')
setProviderIdx(0)

return
}

// Any other printable single character extends the filter.
if (ch && !key.ctrl && !key.meta && ch.length === 1 && ch >= ' ') {
setFilter(v => v + ch)
Expand Down Expand Up @@ -553,6 +598,8 @@ export function ModelPicker({

const { items, offset } = windowItems(rows, providerIdx, VISIBLE)
const noMatches = !!filter.trim() && rows.length === 0
const providerToggleHint = hideUnconfigured ? '^a show all' : '^a hide unconfigured'
const providerOverlayHint = `↑/↓ select · Enter choose · ^d disconnect · ${providerToggleHint} · Esc clear/back · q close`

return (
<Box flexDirection="column" width={width}>
Expand Down Expand Up @@ -615,7 +662,7 @@ export function ModelPicker({
persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'}
{allowPersistGlobal ? ' · ^g toggle' : ' only'}
</Text>
<OverlayHint t={t}>↑/↓ select · Enter choose · ^d disconnect · Esc clear/back · q close</OverlayHint>
<OverlayHint t={t}>{providerOverlayHint}</OverlayHint>
</Box>
)
}
Expand Down