From 4aa52590d8f5551c89a9eea3aab06eca497086db Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 17:22:23 -0500 Subject: [PATCH] fix(tui): disambiguate /model picker rows when provider display names collide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the gateway returns two providers that resolve to the same display name (e.g. `kimi-coding` and `kimi-coding-cn` both → "Kimi For Coding"), the picker now appends the slug so users can tell them apart, in both the provider list and the selected-provider header. No-op when names are already unique. Refs #10526 — the Python backend dedupe from #10599 skips one alias, but user-defined providers, canonical overlays, and future regressions can still surface as indistinguishable rows in the picker. This is a client-side safety net on top of that. --- ui-tui/src/__tests__/providers.test.ts | 62 ++++++++++++++++++++++++++ ui-tui/src/components/modelPicker.tsx | 8 ++-- ui-tui/src/domain/providers.ts | 17 +++++++ 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 ui-tui/src/__tests__/providers.test.ts create mode 100644 ui-tui/src/domain/providers.ts diff --git a/ui-tui/src/__tests__/providers.test.ts b/ui-tui/src/__tests__/providers.test.ts new file mode 100644 index 0000000000..a46102e893 --- /dev/null +++ b/ui-tui/src/__tests__/providers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { providerDisplayNames } from '../domain/providers.js' + +describe('providerDisplayNames', () => { + it('returns bare names when all are unique', () => { + expect(providerDisplayNames([{ name: 'Anthropic', slug: 'anthropic' }, { name: 'OpenAI', slug: 'openai' }])).toEqual( + ['Anthropic', 'OpenAI'] + ) + }) + + it('appends slug to every collision so the disambiguation is symmetric', () => { + expect( + providerDisplayNames([ + { name: 'Kimi For Coding', slug: 'kimi-coding' }, + { name: 'Kimi For Coding', slug: 'kimi-coding-cn' } + ]) + ).toEqual(['Kimi For Coding (kimi-coding)', 'Kimi For Coding (kimi-coding-cn)']) + }) + + it('only disambiguates the colliding group', () => { + expect( + providerDisplayNames([ + { name: 'Anthropic', slug: 'anthropic' }, + { name: 'Foo', slug: 'foo-a' }, + { name: 'Foo', slug: 'foo-b' } + ]) + ).toEqual(['Anthropic', 'Foo (foo-a)', 'Foo (foo-b)']) + }) + + it('falls back to plain name if slug is empty', () => { + expect( + providerDisplayNames([ + { name: 'Foo', slug: '' }, + { name: 'Foo', slug: '' } + ]) + ).toEqual(['Foo', 'Foo']) + }) + + it('skips disambiguation when slug equals name', () => { + expect( + providerDisplayNames([ + { name: 'foo', slug: 'foo' }, + { name: 'foo', slug: 'foo' } + ]) + ).toEqual(['foo', 'foo']) + }) + + it('handles empty input', () => { + expect(providerDisplayNames([])).toEqual([]) + }) + + it('preserves order', () => { + const input = [ + { name: 'Z', slug: 'z' }, + { name: 'A', slug: 'a1' }, + { name: 'A', slug: 'a2' } + ] + + expect(providerDisplayNames(input)).toEqual(['Z', 'A (a1)', 'A (a2)']) + }) +}) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 10a00cdf19..1bc95481da 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -1,6 +1,7 @@ import { Box, Text, useInput } from '@hermes/ink' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { providerDisplayNames } from '../domain/providers.js' import type { GatewayClient } from '../gatewayClient.js' import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' @@ -59,6 +60,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const provider = providers[providerIdx] const models = provider?.models ?? [] + const names = useMemo(() => providerDisplayNames(providers), [providers]) useInput((ch, key) => { if (key.escape) { @@ -160,7 +162,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke if (stage === 'provider') { const rows = providers.map( - p => `${p.is_current ? '*' : ' '} ${p.name} · ${p.total_models ?? p.models?.length ?? 0} models` + (p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models` ) const { items, off } = visibleItems(rows, providerIdx) @@ -201,7 +203,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke Select Model - {provider?.name || '(unknown provider)'} + {names[providerIdx] || '(unknown provider)'} {!models.length ? no models listed for this provider : null} {provider?.warning ? warning: {provider.warning} : null} {off > 0 && ↑ {off} more} diff --git a/ui-tui/src/domain/providers.ts b/ui-tui/src/domain/providers.ts new file mode 100644 index 0000000000..02cc99b922 --- /dev/null +++ b/ui-tui/src/domain/providers.ts @@ -0,0 +1,17 @@ +export const providerDisplayNames = (providers: readonly { name: string; slug: string }[]): string[] => { + const counts = new Map() + + for (const p of providers) { + counts.set(p.name, (counts.get(p.name) ?? 0) + 1) + } + + return providers.map(p => { + const dup = (counts.get(p.name) ?? 0) > 1 + + if (!dup || !p.slug || p.slug === p.name) { + return p.name + } + + return `${p.name} (${p.slug})` + }) +}