Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/web-thinking-effort-levels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add a segmented thinking-level control in the web model picker for models that support multiple reasoning efforts. Open the composer model menu to choose a level.
20 changes: 17 additions & 3 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { openDialogCount } from './composables/dialogStack';
import ServerAuthDialog from './components/ServerAuthDialog.vue';
import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth';
import type { AppConfig, ThinkingLevel } from './api/types';
import { coerceThinkingForModel, commitLevel, segmentsFor } from './lib/modelThinking';
import Button from './components/ui/Button.vue';
import IconButton from './components/ui/IconButton.vue';
import Icon from './components/ui/Icon.vue';
Expand Down Expand Up @@ -87,10 +88,22 @@ const { showAuthGate, blinkAuthLogo } = useAuthGate({ client, authLogoRef });
// spinner while the agent is running so activity is visible at a glance.
usePageTitle({ running, showAuthGate });

// Thinking is on/off (TUI parity — no effort-level cycling). The /thinking
// command flips between off and the backend default effort ('high').
// The /thinking slash command has no popover anchor, so it steps to the next
// segment for the active model (effort models cycle through their declared
// levels; boolean models flip on/off; unsupported stays off).
function nextThinkingLevel(current: ThinkingLevel): ThinkingLevel {
return current === 'off' ? 'high' : 'off';
const raw = client.status.value.modelId ?? client.status.value.model ?? '';
const model = client.models.value.find(
(m) => m.id === raw || m.model === raw || m.displayName === client.status.value.model,
);
const segs = segmentsFor(model);
// Coerce the stored level against the active model before indexing, so a
// stale value (e.g. 'on' from a boolean model) doesn't resolve to index -1
// and jump to 'off' instead of advancing from the model's default effort.
const coerced = coerceThinkingForModel(model, current);
const idx = segs.indexOf(coerced);
const next = segs[(idx + 1) % segs.length] ?? segs[0] ?? 'off';
return commitLevel(model, next);
}

// First-run onboarding (language + welcome greeting). Shown until the user
Expand Down Expand Up @@ -939,6 +952,7 @@ function openPr(url: string): void {
v-model="showMobileSettings"
:status="client.status.value"
:thinking="client.thinking.value"
:models="client.models.value"
:plan-mode="client.planMode.value"
:swarm-mode="client.swarmMode.value"
:color-scheme="client.colorScheme.value"
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,8 @@ export function toAppModel(wire: WireModel): AppModel {
displayName: wire.display_name,
maxContextSize: wire.max_context_size,
capabilities: wire.capabilities,
supportEfforts: wire.support_efforts,
defaultEffort: wire.default_effort,
};
}

Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ export interface WireModel {
display_name?: string;
max_context_size: number;
capabilities?: string[];
support_efforts?: string[];
default_effort?: string;
}

export interface WireProvider {
Expand Down
17 changes: 16 additions & 1 deletion apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,17 @@ export interface CompactionMarkerMetadata {
// Prompt
// ---------------------------------------------------------------------------

export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
/**
* Runtime thinking level. 'off' disables extended thinking; 'on' is the
* enable signal for legacy boolean models (those without `support_efforts`);
* any other string is a model-declared effort level (e.g. 'low'/'high'/'max').
*
* `support_efforts` is the single source of truth for which concrete levels a
* model accepts; providers silently drop unknown efforts rather than erroring.
* Collapses to `string` at runtime — this is a semantic marker, not a closed
* enum. Mirrors kosong's `ThinkingEffort`.
*/
export type ThinkingLevel = 'off' | 'on' | (string & {});

export interface PromptSubmission {
content: AppMessageContent[];
Expand Down Expand Up @@ -550,6 +560,11 @@ export interface AppModel {
maxContextSize: number;
/** Optional capability tags (e.g. ["vision", "thinking"]) */
capabilities?: string[];
/** Effort levels this model supports for extended thinking (e.g. ["low", "high", "max"]).
Sourced from the model catalog (managed) or config [models.<id>.overrides]. */
supportEfforts?: readonly string[];
/** Catalog-declared default effort for extended thinking. */
defaultEffort?: string;
}

export interface AppProvider {
Expand Down
167 changes: 148 additions & 19 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import { buildSlashItems, parseSlash } from '../../lib/slashCommands';
import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
import type { AppModel, AppSkill, ThinkingLevel } from '../../api/types';
import { modelThinkingAvailability } from '../../lib/modelThinking';
import {
coerceThinkingForModel,
commitLevel,
effortLabel,
isThinkingOn,
modelThinkingAvailability,
segmentsFor,
} from '../../lib/modelThinking';
import { useInputHistory } from '../../composables/useInputHistory';
import { useSlashMenu } from '../../composables/useSlashMenu';
import { useMentionMenu } from '../../composables/useMentionMenu';
Expand Down Expand Up @@ -592,15 +599,45 @@ const currentModel = computed(() => {
);
});
const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value));
const thinkingToggleable = computed(() => thinkingAvailability.value === 'toggle');
const thinkingSegments = computed(() => segmentsFor(currentModel.value));
// The persisted level can be stale relative to the active model (e.g. a
// boolean 'on'/'off' carried over when selecting another session). Coerce it
// against the current model before deriving display state so an always-on
// model never shows "thinking: off" and an effort model shows its concrete
// level instead of the bare "thinking" tag.
const coercedThinkingLevel = computed(() =>
coerceThinkingForModel(currentModel.value, props.thinking ?? 'off'),
);
Comment thread
wbxl2000 marked this conversation as resolved.
// Runtime level clamped to the segments this model actually offers, so a
// carried-over value never highlights a segment that doesn't exist here.
const activeThinkingSegment = computed(() => {
const segs = thinkingSegments.value;
const level = coercedThinkingLevel.value;
if (segs.includes(level)) return level;
if (segs.includes('on')) return 'on';
return segs[0] ?? 'off';
});
const thinkingOn = computed(() => {
if (thinkingAvailability.value === 'always-on') return true;
if (thinkingAvailability.value === 'unsupported') return false;
return (props.thinking ?? 'off') !== 'off';
return isThinkingOn(coercedThinkingLevel.value);
});
// Single-segment (always-on boolean) or unsupported models can't be changed.
const thinkingReadonly = computed(
() => thinkingAvailability.value === 'unsupported' || thinkingSegments.value.length <= 1,
);
// Footer-style suffix: effort models show the concrete level; boolean models
// keep the plain "thinking" tag; off shows nothing.
const thinkingSuffix = computed(() => {
if (!thinkingOn.value) return '';
const hasEfforts = (currentModel.value?.supportEfforts?.length ?? 0) > 0;
const level = coercedThinkingLevel.value;
if (hasEfforts && level !== 'on') return t('composer.thinkingSuffixEffort', { level });
return t('composer.thinkingSuffix');
});
function toggleThinking(): void {
if (!thinkingToggleable.value) return;
emit('setThinking', thinkingOn.value ? 'off' : 'high');
function setThinkingSegment(draft: string): void {
if (thinkingReadonly.value) return;
emit('setThinking', commitLevel(currentModel.value, draft));
}

// Plan toggle
Expand Down Expand Up @@ -928,7 +965,7 @@ function selectModel(modelId: string): void {
@keydown.space.prevent="toggleDropdown"
>
<b>{{ status.model }}</b>
<span v-if="thinkingOn" class="think-suffix">{{ t('composer.thinkingSuffix') }}</span>
<span v-if="thinkingSuffix" class="think-suffix">{{ thinkingSuffix }}</span>
<Icon class="cv" name="chevron-down" size="sm" />
</span>
<Tooltip v-if="running" :text="t('composer.interruptTitle')">
Expand Down Expand Up @@ -986,19 +1023,31 @@ function selectModel(modelId: string): void {

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

<!-- Thinking toggle -->
<button
class="md-row md-row-toggle"
role="menuitem"
:class="{ 'is-on': thinkingOn, 'is-disabled': !thinkingToggleable }"
:disabled="!thinkingToggleable"
@click="toggleThinking()"
>
<span class="md-check"><Icon v-if="thinkingOn" name="check" size="sm" /></span>
<!-- Thinking level — segmented control. Effort models show every
declared level; boolean models show On/Off; unsupported shows a note. -->
<div class="md-thinking" :class="{ 'is-readonly': thinkingReadonly }">
<span class="md-name">{{ t('status.thinkingLabel') }}</span>
<span v-if="thinkingAvailability === 'always-on'" class="md-note">{{ t('status.planOn') }}</span>
<span v-else-if="thinkingAvailability === 'unsupported'" class="md-note">{{ t('status.modeNotSupported') }}</span>
</button>
<span
v-if="thinkingAvailability === 'unsupported'"
class="md-note"
>{{ t('status.modeNotSupported') }}</span>
<div
v-else
class="effort-segments"
role="group"
:aria-label="t('status.thinkingLabel')"
>
<button
v-for="seg in thinkingSegments"
:key="seg"
type="button"
class="effort-seg"
:class="{ 'is-active': seg === activeThinkingSegment }"
:disabled="thinkingReadonly"
@click="setThinkingSegment(seg)"
>{{ effortLabel(seg) }}</button>
</div>
</div>

<div class="md-divider" />

Expand Down Expand Up @@ -1551,6 +1600,73 @@ function selectModel(modelId: string): void {
margin: 3px 0;
}

/* Thinking level segmented control — sits inside the model dropdown. */
.md-thinking {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 7px;
border-radius: var(--radius-sm);
}
.md-thinking .md-name {
font-family: var(--mono);
font-size: var(--ui-font-size);
color: var(--color-text);
flex: none;
}
.md-thinking .md-note {
margin-left: auto;
}
.effort-segments {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 1px;
padding: 2px;
background: var(--color-surface-sunken);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
}
.effort-seg {
appearance: none;
border: none;
background: none;
cursor: pointer;
font-family: var(--mono);
font-size: var(--ui-font-size-xs);
line-height: 1;
color: var(--color-text-muted);
padding: 4px 9px;
border-radius: var(--radius-sm);
white-space: nowrap;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}
.effort-seg:hover:not(:disabled):not(.is-active) {
background: var(--color-surface-raised);
color: var(--color-text);
}
.effort-seg:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
.effort-seg.is-active {
background: var(--color-accent);
color: var(--color-text-on-accent);
box-shadow: var(--shadow-xs);
font-weight: 500;
}
.effort-seg:disabled {
cursor: default;
}
.md-thinking.is-readonly .effort-segments {
opacity: 0.62;
}
.md-thinking.is-readonly .effort-seg.is-active {
background: var(--color-surface-raised);
color: var(--color-text-muted);
box-shadow: none;
}

/* Permission dropdown — anchored to the toolbar left side */
.perm-dropdown {
position: absolute;
Expand Down Expand Up @@ -1872,6 +1988,19 @@ function selectModel(modelId: string): void {
.md-section {
font-size: var(--ui-font-size);
}
.md-thinking {
flex-wrap: wrap;
row-gap: 6px;
}
.md-thinking .effort-segments {
margin-left: 0;
width: 100%;
justify-content: space-between;
}
.md-thinking .effort-seg {
flex: 1;
padding: 5px 6px;
}
.pd-name {
font-size: var(--ui-font-size);
}
Expand Down
Loading
Loading