Skip to content

Commit e6d603a

Browse files
committed
feat(web): select a thinking effort level from the model menu
The composer offered a single thinking on/off row and always sent 'high'. It now lists the effort levels the current model declares (off, minimal, low, medium, high, xhigh, max), filtered and clamped by the same rules the node SDK applies to the terminal and VS Code renderers, and the footer pill shows the effective level instead of the fixed word 'thinking'. The dropdown also had a height floor but no ceiling, so a provider with many models filled the whole window. It is now capped at 360px and scrolls.
1 parent d2800a0 commit e6d603a

7 files changed

Lines changed: 176 additions & 35 deletions

File tree

‎apps/pythinker-web/src/components/Composer.vue‎

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { buildSlashItems, filterCommands, parseSlash } from '../lib/slashCommand
99
import type { FileItem } from './MentionMenu.vue';
1010
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../types';
1111
import type { AppModel, AppSkill, ThinkingLevel } from '../api/types';
12-
import { modelThinkingAvailability } from '../lib/modelThinking';
12+
import {
13+
coerceThinkingForModel,
14+
effortLevelsForModel,
15+
modelThinkingAvailability,
16+
} from '../lib/modelThinking';
1317
import { formatTokens } from '../lib/formatTokens';
1418
1519
// ---------------------------------------------------------------------------
@@ -745,7 +749,7 @@ function toggleDropdown(): void {
745749
if (dropdownOpen.value) {
746750
const rect = modelPillRef.value?.getBoundingClientRect();
747751
modelDropdownStyle.value = rect
748-
? { maxHeight: `${Math.max(160, rect.top - 4 - 12)}px` }
752+
? { maxHeight: `${Math.min(360, Math.max(160, rect.top - 4 - 12))}px` }
749753
: {};
750754
permDropdownOpen.value = false;
751755
document.addEventListener('click', onDocClick, true);
@@ -817,15 +821,16 @@ const currentModel = computed(() => {
817821
);
818822
});
819823
const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value));
820-
const thinkingToggleable = computed(() => thinkingAvailability.value === 'toggle');
821-
const thinkingOn = computed(() => {
822-
if (thinkingAvailability.value === 'always-on') return true;
823-
if (thinkingAvailability.value === 'unsupported') return false;
824-
return (props.thinking ?? 'off') !== 'off';
825-
});
826-
function toggleThinking(): void {
827-
if (!thinkingToggleable.value) return;
828-
emit('setThinking', thinkingOn.value ? 'off' : 'high');
824+
const effortLevels = computed(() => effortLevelsForModel(currentModel.value));
825+
const currentEffort = computed<ThinkingLevel>(() =>
826+
coerceThinkingForModel(currentModel.value, props.thinking ?? 'off'),
827+
);
828+
function effortLabel(level: ThinkingLevel): string {
829+
return t(`status.effortLevels.${level}`);
830+
}
831+
function selectEffort(level: ThinkingLevel): void {
832+
emit('setThinking', level);
833+
closeDropdown();
829834
}
830835
831836
// Plan toggle
@@ -1200,7 +1205,7 @@ function selectModel(modelId: string): void {
12001205
@keydown.space.prevent="toggleDropdown"
12011206
>
12021207
<b>{{ status.model }}</b>
1203-
<span v-if="thinkingOn" class="think-suffix">{{ t('composer.thinkingSuffix') }}</span>
1208+
<span v-if="thinkingAvailability !== 'unsupported'" class="think-suffix">{{ ` · ${effortLabel(currentEffort)}` }}</span>
12041209
<svg class="cv" viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>
12051210
</span>
12061211
</div>
@@ -1242,19 +1247,21 @@ function selectModel(modelId: string): void {
12421247

12431248
<div v-if="providerModels.length > 0" class="md-divider" />
12441249

1245-
<!-- Thinking toggle -->
1246-
<button
1247-
class="md-row md-row-toggle"
1248-
role="menuitem"
1249-
:class="{ 'is-on': thinkingOn, 'is-disabled': !thinkingToggleable }"
1250-
:disabled="!thinkingToggleable"
1251-
@click="toggleThinking()"
1252-
>
1253-
<span class="md-check"><svg v-if="thinkingOn" viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5l3.5 3.5L13 4.5"/></svg></span>
1254-
<span class="md-name">{{ t('status.thinkingLabel') }}</span>
1255-
<span v-if="thinkingAvailability === 'always-on'" class="md-note">{{ t('status.planOn') }}</span>
1256-
<span v-else-if="thinkingAvailability === 'unsupported'" class="md-note">{{ t('status.modeNotSupported') }}</span>
1257-
</button>
1250+
<!-- Thinking effort -->
1251+
<div class="md-section">{{ t('status.effortLabel') }}</div>
1252+
<div v-if="thinkingAvailability === 'unsupported'" class="md-cache-note">{{ t('status.modeNotSupported') }}</div>
1253+
<template v-else>
1254+
<button
1255+
v-for="level in effortLevels"
1256+
:key="level"
1257+
class="md-row"
1258+
role="menuitem"
1259+
@click="selectEffort(level)"
1260+
>
1261+
<span class="md-check"><svg v-if="level === currentEffort" viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5l3.5 3.5L13 4.5"/></svg></span>
1262+
<span class="md-name">{{ effortLabel(level) }}</span>
1263+
</button>
1264+
</template>
12581265

12591266
<div class="md-divider" />
12601267
<div class="md-cache-note">{{ t('status.cacheNote') }}</div>

‎apps/pythinker-web/src/i18n/locales/en/composer.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,5 @@ export default {
1818
emptyConversationTitle: 'Pythinker Code',
1919
emptyConversation: 'No messages yet — type below to start the conversation',
2020
quickStartPlaceholder: 'Type a message to start a new conversation…',
21-
thinkingSuffix: ' · thinking',
2221

2322
} as const;

‎apps/pythinker-web/src/i18n/locales/en/status.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ export default {
3232
// Thinking selector
3333
thinkingLabel: 'thinking',
3434
thinkingTooltip: 'Toggle thinking mode',
35+
effortLabel: 'Thinking effort',
36+
effortLevels: {
37+
off: 'Off',
38+
minimal: 'Minimal',
39+
low: 'Low',
40+
medium: 'Medium',
41+
high: 'High',
42+
xhigh: 'xHigh',
43+
max: 'Max',
44+
},
3545
cacheNote:
3646
'Note: Switching models or thinking effort invalidates the existing prompt cache. Start a new chat to avoid extra token costs.',
3747
starredModels: 'Starred',

‎apps/pythinker-web/src/lib/modelThinking.ts‎

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,27 @@ export type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported';
44

55
export type ModelThinkingInfo = Pick<AppModel, 'capabilities'> & {
66
readonly adaptiveThinking?: boolean;
7+
readonly supportEfforts?: readonly string[];
78
};
89

10+
/**
11+
* Effort-level rules, mirrored from `packages/node-sdk/src/thinking-levels.ts`.
12+
* The web app has no dependency on the SDK or agent-core, so the rules are
13+
* duplicated here on purpose; keep both copies in step.
14+
*/
15+
export const CANONICAL_EFFORT_ORDER: readonly ThinkingLevel[] = [
16+
'off',
17+
'minimal',
18+
'low',
19+
'medium',
20+
'high',
21+
'xhigh',
22+
'max',
23+
];
24+
25+
/** Fallback effort set for models without `supportEfforts` metadata. */
26+
const DEFAULT_SUPPORTED_EFFORTS: readonly ThinkingLevel[] = ['low', 'medium', 'high'];
27+
928
export function modelThinkingAvailability(
1029
model: ModelThinkingInfo | undefined,
1130
): ThinkingAvailability {
@@ -16,16 +35,43 @@ export function modelThinkingAvailability(
1635
return 'unsupported';
1736
}
1837

38+
/** Effort levels the model actually supports ('off' excluded), canonical order. */
39+
function baseEffortsForModel(model: ModelThinkingInfo | undefined): ThinkingLevel[] {
40+
const supportEfforts = model?.supportEfforts;
41+
if (supportEfforts?.length === 0) return ['high'];
42+
const declared = new Set<string>(supportEfforts ?? []);
43+
const ordered = CANONICAL_EFFORT_ORDER.filter(
44+
(effort) => effort !== 'off' && declared.has(effort),
45+
);
46+
return ordered.length > 0 ? [...ordered] : [...DEFAULT_SUPPORTED_EFFORTS];
47+
}
48+
49+
/** Selectable levels for a model, in order. `['off']` when thinking is unsupported. */
50+
export function effortLevelsForModel(model: ModelThinkingInfo | undefined): ThinkingLevel[] {
51+
const availability = modelThinkingAvailability(model);
52+
if (availability === 'unsupported') return ['off'];
53+
const base = baseEffortsForModel(model);
54+
return availability === 'always-on' ? base : ['off', ...base];
55+
}
56+
57+
/**
58+
* Clamp a requested effort to what the model supports:
59+
* always-on + 'off' → first supported level; unsupported → 'off'; a level the
60+
* model lacks → nearest lower supported level (else the first selectable one).
61+
*/
1962
export function coerceThinkingForModel(
2063
model: ModelThinkingInfo | undefined,
2164
requested: ThinkingLevel,
2265
): ThinkingLevel {
23-
switch (modelThinkingAvailability(model)) {
24-
case 'always-on':
25-
return requested === 'off' ? 'high' : requested;
26-
case 'unsupported':
27-
return 'off';
28-
case 'toggle':
29-
return requested;
66+
const availability = modelThinkingAvailability(model);
67+
if (availability === 'unsupported') return 'off';
68+
const levels = effortLevelsForModel(model);
69+
if (availability === 'always-on' && requested === 'off') return levels[0]!;
70+
if (levels.includes(requested)) return requested;
71+
const requestedIdx = CANONICAL_EFFORT_ORDER.indexOf(requested);
72+
for (let i = requestedIdx - 1; i >= 0; i--) {
73+
const candidate = CANONICAL_EFFORT_ORDER[i]!;
74+
if (levels.includes(candidate)) return candidate;
3075
}
76+
return levels[0]!;
3177
}

‎apps/pythinker-web/test/composer.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,18 @@ describe('Composer model dropdown', () => {
416416
await pill.trigger('click');
417417
await pill.trigger('click');
418418
expect(wrapper.get('.model-dropdown').element.style.maxHeight).toBe('284px');
419+
420+
// A tall window used to let the menu grow to the full viewport height.
421+
rect.mockReturnValue({ top: 1180 } as DOMRect);
422+
await pill.trigger('click');
423+
await pill.trigger('click');
424+
expect(wrapper.get('.model-dropdown').element.style.maxHeight).toBe('360px');
425+
});
426+
427+
it('replaces the binary thinking toggle with the effort list', () => {
428+
expect(composerSource).not.toContain('toggleThinking');
429+
expect(composerSource).not.toContain('md-row-toggle');
430+
expect(composerSource).toContain('selectEffort');
419431
});
420432
});
421433

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
coerceThinkingForModel,
4+
effortLevelsForModel,
5+
} from '../src/lib/modelThinking';
6+
7+
describe('effortLevelsForModel', () => {
8+
it('keeps declared efforts in canonical order and prepends off', () => {
9+
expect(
10+
effortLevelsForModel({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }),
11+
).toEqual(['off', 'low', 'high', 'max']);
12+
});
13+
14+
it('falls back to low/medium/high when supportEfforts is absent', () => {
15+
expect(effortLevelsForModel({ capabilities: ['thinking'] })).toEqual([
16+
'off',
17+
'low',
18+
'medium',
19+
'high',
20+
]);
21+
});
22+
23+
it('treats an empty supportEfforts array as high only', () => {
24+
expect(effortLevelsForModel({ capabilities: ['thinking'], supportEfforts: [] })).toEqual([
25+
'off',
26+
'high',
27+
]);
28+
});
29+
30+
it('omits off for always-thinking models', () => {
31+
expect(effortLevelsForModel({ capabilities: ['always_thinking'] })).toEqual([
32+
'low',
33+
'medium',
34+
'high',
35+
]);
36+
});
37+
38+
it('offers only off when thinking is unsupported', () => {
39+
expect(effortLevelsForModel({ capabilities: [] })).toEqual(['off']);
40+
});
41+
});
42+
43+
describe('coerceThinkingForModel', () => {
44+
it('clamps an unsupported level down to the nearest lower supported one', () => {
45+
expect(
46+
coerceThinkingForModel(
47+
{ capabilities: ['thinking'], supportEfforts: ['low', 'medium'] },
48+
'xhigh',
49+
),
50+
).toBe('medium');
51+
});
52+
53+
it('promotes off to the first supported level for always-thinking models', () => {
54+
expect(
55+
coerceThinkingForModel(
56+
{ capabilities: ['always_thinking'], supportEfforts: ['medium', 'max'] },
57+
'off',
58+
),
59+
).toBe('medium');
60+
});
61+
62+
it('returns off when thinking is unsupported', () => {
63+
expect(coerceThinkingForModel({ capabilities: [] }, 'high')).toBe('off');
64+
});
65+
});

‎apps/pythinker-web/test/set-model-rollback.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,12 @@ describe('setModel failure handling', () => {
139139

140140
await client.setModel('model-new');
141141

142-
expect(client.thinking.value).toBe('high');
142+
// The model declares no supportEfforts, so it falls back to low/medium/high
143+
// and takes the first level — same rule as the SDK's coerceEffortForModel.
144+
expect(client.thinking.value).toBe('low');
143145
expect(api.updateSession).toHaveBeenLastCalledWith('sess_1', {
144146
model: 'model-new',
145-
thinking: 'high',
147+
thinking: 'low',
146148
});
147149
});
148150
});

0 commit comments

Comments
 (0)