Skip to content

Commit 41bf358

Browse files
committed
feat(web): show model capabilities as badges
The picker rendered capabilities as `capabilities.join(', ')`, so a model with three of them read as one run-on line of text. Give each known capability a muted glyph with a translated tooltip, and keep an unrecognised string visible as text rather than dropping it. A model that reasons adaptively gets its own mark, so it no longer reads as the same thing as one that exposes an explicit thinking capability. Rows and the search field move onto the app's metrics, derived from `--ui-font-size` rather than pinned, so the font-size setting keeps working. Off-screen rows use `content-visibility` instead of a virtual list. The picker stays a modal: it is reached from the composer quick-switch through "More models…", and the dialog is the better form on a narrow window.
1 parent 3be049b commit 41bf358

4 files changed

Lines changed: 325 additions & 12 deletions

File tree

.changeset/web-model-badges.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Show model capabilities as badges in the model picker instead of a comma-separated string, and bring its rows and search field onto the app's row metrics, sized from `--ui-font-size` so the font-size setting still scales them. A model that reasons adaptively is now distinguishable from one that exposes an explicit thinking capability, and an unrecognised capability still renders rather than being dropped.

apps/pythinker-web/src/components/ModelPicker.vue

Lines changed: 167 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,70 @@ import { formatTokens } from '../lib/formatTokens';
1010
1111
const { t } = useI18n();
1212
13+
interface CapabilityBadgeMeta {
14+
readonly labelKey: string;
15+
readonly path: string;
16+
}
17+
18+
const capabilityBadgeMeta: Record<string, CapabilityBadgeMeta> = {
19+
image_in: {
20+
labelKey: 'model.capabilities.imageIn',
21+
path: 'M3 3.5h10v9H3z M4.5 10l2.3-2.3 2 2 1.4-1.4 2.8 2.8',
22+
},
23+
image_out: {
24+
labelKey: 'model.capabilities.imageOut',
25+
path: 'M2.5 4h8v8h-8z M8 2.5h5v5 M10 2.5l3 3',
26+
},
27+
vision: {
28+
labelKey: 'model.capabilities.vision',
29+
path: 'M2.5 8s2-3 5.5-3 5.5 3 5.5 3-2 3-5.5 3-5.5-3-5.5-3z M8 6.7a1.3 1.3 0 1 0 0 2.6 1.3 1.3 0 0 0 0-2.6z',
30+
},
31+
video_in: {
32+
labelKey: 'model.capabilities.videoIn',
33+
path: 'M2.5 4.5h8v7h-8z M10.5 7l3-2v6l-3-2z',
34+
},
35+
audio_in: {
36+
labelKey: 'model.capabilities.audioIn',
37+
path: 'M6 3v6a2 2 0 1 1-4 0V7 M2 9a6 6 0 0 0 12 0 M8 15v-3 M5.5 15h5',
38+
},
39+
audio_out: {
40+
labelKey: 'model.capabilities.audioOut',
41+
path: 'M3 6h2.5L9 3v10l-3.5-3H3z M11 6a3 3 0 0 1 0 4 M12.5 4.5a5 5 0 0 1 0 7',
42+
},
43+
thinking: {
44+
labelKey: 'model.capabilities.thinking',
45+
path: 'M5.2 10.7a4 4 0 1 1 5.6 0c-.5.4-.8.8-.8 1.3H6c0-.5-.3-.9-.8-1.3z M6 14h4',
46+
},
47+
always_thinking: {
48+
labelKey: 'model.capabilities.alwaysThinking',
49+
path: 'M8 2.5a5.5 5.5 0 1 0 5.5 5.5 M8 5.5v3l2 1',
50+
},
51+
tool_use: {
52+
labelKey: 'model.capabilities.toolUse',
53+
path: 'M3 3.5h10v9H3z M5 6.5l2 1.5-2 1.5 M8.5 9.5h2',
54+
},
55+
fast_mode: {
56+
labelKey: 'model.capabilities.fastMode',
57+
path: 'M9.5 1.8 3.5 9h4l-1 5.2L12.5 7h-4z',
58+
},
59+
};
60+
61+
const adaptiveThinkingBadge: CapabilityBadgeMeta = {
62+
labelKey: 'model.capabilities.adaptiveThinking',
63+
path: 'M3 8a5 5 0 0 1 8.7-3.4 M11.7 4.6V2.5 M11.7 4.6H9.6 M13 8a5 5 0 0 1-8.7 3.4 M4.3 11.4v2.1 M4.3 11.4h2.1',
64+
};
65+
66+
function capabilityGlyph(capability: string): string | undefined {
67+
return capabilityBadgeMeta[capability]?.path;
68+
}
69+
70+
function capabilityTitle(capability: string): string {
71+
const meta = capabilityBadgeMeta[capability];
72+
return meta === undefined
73+
? t('model.capabilities.unknown', { capability })
74+
: t(meta.labelKey);
75+
}
76+
1377
const props = defineProps<{
1478
models: AppModel[];
1579
current: string;
@@ -223,8 +287,57 @@ function selectTab(tabId: string): void {
223287
</span>
224288
<span class="model-provider">{{ m.provider }}</span>
225289
<span class="model-ctx">{{ t('model.contextSuffix', { size: formatTokens(m.maxContextSize) }) }}</span>
226-
<span v-if="m.capabilities && m.capabilities.length > 0" class="caps">
227-
{{ m.capabilities.join(', ') }}
290+
<span
291+
v-if="(m.capabilities && m.capabilities.length > 0) || m.adaptiveThinking"
292+
class="caps"
293+
:aria-label="t('model.capabilities.label')"
294+
>
295+
<span
296+
v-for="(capability, capabilityIndex) in m.capabilities ?? []"
297+
:key="`${capability}-${capabilityIndex}`"
298+
class="cap-badge"
299+
:class="{ 'is-unknown': capabilityGlyph(capability) === undefined }"
300+
:data-capability="capability"
301+
:title="capabilityTitle(capability)"
302+
:aria-label="capabilityTitle(capability)"
303+
>
304+
<svg
305+
v-if="capabilityGlyph(capability)"
306+
viewBox="0 0 16 16"
307+
width="12"
308+
height="12"
309+
fill="none"
310+
stroke="currentColor"
311+
stroke-width="1.25"
312+
stroke-linecap="round"
313+
stroke-linejoin="round"
314+
aria-hidden="true"
315+
>
316+
<path :d="capabilityGlyph(capability)" />
317+
</svg>
318+
<span v-else>{{ capability }}</span>
319+
</span>
320+
<span
321+
v-if="m.adaptiveThinking"
322+
class="cap-badge is-adaptive"
323+
data-capability="adaptive-thinking"
324+
:title="t(adaptiveThinkingBadge.labelKey)"
325+
:aria-label="t(adaptiveThinkingBadge.labelKey)"
326+
>
327+
<svg
328+
viewBox="0 0 16 16"
329+
width="12"
330+
height="12"
331+
fill="none"
332+
stroke="currentColor"
333+
stroke-width="1.25"
334+
stroke-linecap="round"
335+
stroke-linejoin="round"
336+
aria-hidden="true"
337+
>
338+
<path :d="adaptiveThinkingBadge.path" />
339+
</svg>
340+
</span>
228341
</span>
229342
<button
230343
type="button"
@@ -316,16 +429,19 @@ function selectTab(tabId: string): void {
316429
flex: none;
317430
}
318431
.search-input {
432+
/* Default 14px: 14 + 13 = 27px. */
319433
width: 100%;
434+
height: calc(var(--ui-font-size) + 13px);
320435
box-sizing: border-box;
321436
font-family: var(--mono);
322-
font-size: calc(var(--ui-font-size) - 1.5px);
323-
padding: 5px 8px;
437+
font-size: calc(var(--ui-font-size) - 1px);
438+
padding: 0 8px;
324439
border: 1px solid var(--line);
325-
border-radius: 3px;
440+
border-radius: var(--r-sm);
326441
background: var(--panel);
327442
color: var(--ink);
328443
outline: none;
444+
line-height: 1;
329445
}
330446
331447
.tab-strip {
@@ -369,14 +485,21 @@ function selectTab(tabId: string): void {
369485
}
370486
371487
.model-row {
488+
/* Default 14px: 14 + 18 = 32px; 14 - 1 = 13px. */
372489
display: flex;
373490
align-items: center;
491+
box-sizing: border-box;
492+
height: calc(var(--ui-font-size) + 18px);
374493
gap: 8px;
375-
padding: 7px 14px;
494+
padding: 0 8px;
495+
border-radius: var(--r-md);
376496
cursor: pointer;
377-
font-size: calc(var(--ui-font-size) - 1.5px);
497+
font-size: calc(var(--ui-font-size) - 1px);
498+
line-height: 1;
378499
color: var(--text);
379500
min-width: 0;
501+
content-visibility: auto;
502+
contain-intrinsic-size: calc(var(--ui-font-size) + 18px);
380503
}
381504
.model-row:hover, .model-row.is-selected {
382505
background: var(--soft);
@@ -430,13 +553,45 @@ function selectTab(tabId: string): void {
430553
flex: none;
431554
}
432555
.caps {
433-
color: var(--blue);
556+
display: inline-flex;
557+
align-items: center;
558+
gap: 4px;
559+
flex: none;
560+
min-width: 0;
561+
max-width: 180px;
562+
overflow: hidden;
563+
color: var(--muted);
564+
}
565+
.cap-badge {
566+
display: inline-flex;
567+
align-items: center;
568+
justify-content: center;
569+
flex: none;
570+
box-sizing: border-box;
571+
min-width: 16px;
572+
height: 18px;
573+
padding: 0 4px;
574+
border: 1px solid color-mix(in srgb, var(--line) 65%, var(--panel));
575+
border-radius: var(--r-xs);
576+
background: color-mix(in srgb, var(--panel2) 45%, var(--panel));
577+
color: var(--muted);
434578
font-size: max(9px, calc(var(--ui-font-size) - 4px));
435-
border: 1px solid var(--bd);
436-
border-radius: 3px;
437-
padding: 1px 5px;
579+
line-height: 1;
580+
white-space: nowrap;
581+
}
582+
.cap-badge svg {
583+
display: block;
438584
flex: none;
439585
}
586+
.cap-badge.is-adaptive {
587+
border-style: dashed;
588+
color: var(--dim);
589+
}
590+
.cap-badge.is-unknown {
591+
max-width: 120px;
592+
overflow: hidden;
593+
text-overflow: ellipsis;
594+
}
440595
.star-btn {
441596
flex: none;
442597
display: flex;
@@ -505,7 +660,7 @@ function selectTab(tabId: string): void {
505660
max-height: calc(100dvh - 24px);
506661
}
507662
.model-provider,
508-
.caps {
663+
.model-provider {
509664
display: none;
510665
}
511666
}

apps/pythinker-web/src/i18n/locales/en/model.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,19 @@ export default {
1313
starTitle: 'Add to favorites',
1414
unstarTitle: 'Remove from favorites',
1515
footerHint: '↑↓ Navigate · Enter Select · Esc Close',
16+
capabilities: {
17+
label: 'Model capabilities',
18+
imageIn: 'Image input',
19+
imageOut: 'Image output',
20+
vision: 'Vision',
21+
videoIn: 'Video input',
22+
audioIn: 'Audio input',
23+
audioOut: 'Audio output',
24+
thinking: 'Thinking',
25+
alwaysThinking: 'Always thinking',
26+
adaptiveThinking: 'Adaptive reasoning',
27+
toolUse: 'Tool use',
28+
fastMode: 'Fast mode',
29+
unknown: 'Capability: {capability}',
30+
},
1631
} as const;

0 commit comments

Comments
 (0)