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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ coverage/

# Local preview material, not shipped
/docs/preview/

# Temporary screenshots from automated UI verification
/.cu_crop_*.png
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ version with its date and start a fresh empty `[Unreleased]` above it.
China build (`qoderclicn`, config under `~/.qoder-cn`). Auto-
detection, session history, global plugins, and login hints all
follow the selected edition.
- Per-model context and thinking editor in the model selector,
mirroring the Qoder IDE: hovering a model row reveals an edit
affordance that opens a side editor card with context window
tiers, a thinking on/off toggle, and the model's server-provided
thinking effort levels. Choices persist per model and are applied
to every request.

### Fixed

Expand Down
7 changes: 7 additions & 0 deletions src/core/types/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ManagedMcpServer,
PluginInfo,
} from './index';
import type { ModelContextTier, ModelThinkingEffort } from './settings';
// ---------------------------------------------------------------------------
// App-level service interfaces
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -121,6 +122,12 @@ export interface UIOption {
priceLabel?: string;
/** Trailing promotion badge shown on the option row, e.g. '错峰5折'. */
promotionLabel?: string;
/** Configurable context-window tiers for the model edit panel. */
contextTiers?: ModelContextTier[];
/** Whether the model edit panel may offer disabling thinking. */
thinkingDisableable?: boolean;
/** Configurable thinking effort levels for the model edit panel. */
thinkingEfforts?: ModelThinkingEffort[];
}

export interface PathIconSvg {
Expand Down
35 changes: 35 additions & 0 deletions src/core/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,33 @@ export const QODER_CLI_EDITIONS = ['global', 'cn'] as const;
*/
export type QoderCliEdition = typeof QODER_CLI_EDITIONS[number];

/** Server context-window tier, e.g. the 200K/400K/1M editor choices. */
export interface ModelContextTier {
/** Display tier label from the server, e.g. '200K'. */
label: string;
tokenCount: number;
isDefault: boolean;
}

/** Server thinking effort level, e.g. the low/medium/xhigh editor choices. */
export interface ModelThinkingEffort {
/** Effort value accepted by the CLI, e.g. 'xhigh'. */
value: string;
isDefault: boolean;
/** Server description of the level, shown as a tooltip in the IDE. */
description?: string;
}

/** Per-model editor overrides mirroring the Qoder IDE model edit panel. */
export interface QoderModelOverride {
/** Selected context-window tier in tokens; absent means server default. */
contextWindow?: number;
/** False disables thinking for models that support it. */
thinkingEnabled?: boolean;
/** Per-model reasoning effort; absent means server default. */
thinkingEffort?: string;
}

/** Qoder CLI settings stored alongside Qoderian's general preferences. */
export interface QoderSettings {
cliPath: string;
Expand All @@ -91,6 +118,12 @@ export interface QoderSettings {
priceFactor?: number;
/** Pre-discount multiplier, when the server prices this model down. */
originalPriceFactor?: number;
/** Configurable context-window tiers reported by the server. */
contextTiers?: ModelContextTier[];
/** Whether the server allows explicitly disabling thinking. */
thinkingDisableable?: boolean;
/** Configurable thinking effort levels reported by the server. */
thinkingEfforts?: ModelThinkingEffort[];
promotion?: {
active?: boolean;
/** Server-localized badge text keyed by `en` / `zh`. */
Expand All @@ -107,6 +140,8 @@ export interface QoderSettings {
model?: string;
}>;
lastModel: string;
/** Per-model editor overrides keyed by runtime model id. */
modelOverrides: Record<string, QoderModelOverride>;
}

/**
Expand Down
34 changes: 33 additions & 1 deletion src/features/chat/tabs/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { getEnhancedPath } from '../../../core/env/environment';
import { getVaultPath } from '../../../core/fs/path';
import type { ChatRuntime } from '../../../core/runtime/chat-runtime';
import type { ChatMessage, Conversation, QoderState } from '../../../core/types';
import type { QoderModelOverride } from '../../../core/types/settings';
import { t } from '../../../i18n/i18n';
import type QoderianPlugin from '../../../main';
import { getQoderSettings } from '../../../qoder/config/settings';
import { getQoderSettings, updateQoderSettings } from '../../../qoder/config/settings';
import {
SlashCommandDropdown,
toSlashCommandDropdownEntries,
Expand Down Expand Up @@ -431,6 +432,37 @@ function initializeInputToolbar(
settings.effortLevel = effort;
});
},
onModelOverrideChange: async (model: string, override: Partial<QoderModelOverride>) => {
await updateTabQoderSettings(tab, plugin, (settings) => {
const current = getQoderSettings(settings).modelOverrides;
const merged: QoderModelOverride = { ...current[model] };
for (const [key, value] of Object.entries(override)) {
if (value === undefined) {
delete merged[key as keyof QoderModelOverride];
} else {
(merged as Record<string, unknown>)[key] = value;
}
}
const next = { ...current };
if (Object.keys(merged).length > 0) next[model] = merged;
else delete next[model];
updateQoderSettings(settings, { modelOverrides: next });
});

// The context meter tracks the effective window of the edited model.
// Overrides for other models must not rewrite this conversation's
// usage, which belongs to the currently selected model.
const currentUsage = tab.state.usage;
if (currentUsage && model === plugin.settings.model) {
const modelConfig = getTabModelConfig(tab, plugin);
const newContextWindow = modelConfig.getEffectiveContextWindowSize(
model,
plugin.settings,
);
tab.state.usage = recalculateUsageForModel(currentUsage, model, newContextWindow);
tab.ui.contextUsageMeter?.update(tab.state.usage);
}
},
onPermissionModeChange: async (mode) => {
await updateTabQoderSettings(tab, plugin, (settings) => {
settings.permissionMode = mode;
Expand Down
71 changes: 71 additions & 0 deletions src/features/chat/ui/toolbar/model-dropdown-placement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Pure placement decisions for the model dropdown cascade panel.
*
* The rules mirror the flip/size middleware of floating-positioning
* libraries: prefer the anchor's inline-start edge and flip only when the
* panel would overflow the viewport there, and cap the panel height to the
* space available above the toolbar. Keeping the decisions pure (no DOM
* access) makes them unit-testable without a layout engine; the selector
* component only measures and applies the results.
*/

export interface ModelDropdownAnchorRect {
left: number;
right: number;
top: number;
}

/** Breathing room kept between the panel and the viewport edge. */
export const MODEL_DROPDOWN_VIEWPORT_MARGIN = 8;
/** Repo-wide popover height convention (see nav TOC popover). */
export const MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT = 420;
/** Floor so the panel stays usable even in very short windows. */
export const MODEL_DROPDOWN_MIN_HEIGHT = 160;

/**
* Decides whether the panel should anchor to the inline-end edge of its
* toolbar instead of the inline-start edge. Start alignment keeps the panel
* flush with the trigger button; flipping is a last resort for anchors whose
* start side cannot fit the panel.
*/
export function shouldFlipModelDropdown(
anchor: ModelDropdownAnchorRect,
panelWidth: number,
viewportWidth: number,
): boolean {
const margin = MODEL_DROPDOWN_VIEWPORT_MARGIN;
const fitsAtStart = anchor.left + panelWidth <= viewportWidth - margin;
const fitsAtEnd = anchor.right - panelWidth >= margin;
return !fitsAtStart && fitsAtEnd;
}

/**
* Caps the panel to the vertical space above the toolbar so the upward
* growing dropdown never covers the viewport top, without exceeding the
* repo-wide 420px popover convention.
*/
export function modelDropdownMaxHeight(anchorTop: number): number {
const available = anchorTop - MODEL_DROPDOWN_VIEWPORT_MARGIN;
if (!Number.isFinite(available)) return MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT;
return Math.max(
MODEL_DROPDOWN_MIN_HEIGHT,
Math.min(available, MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT),
);
}

/**
* Anchors the compact editor card to its edited row like an IDE flyout,
* clamped so the card never escapes the visible list area.
*/
export function modelEditorPaneOffset(
rowVisibleTop: number,
editorHeight: number,
listVisibleHeight: number,
): number {
if (!Number.isFinite(rowVisibleTop)
|| !Number.isFinite(editorHeight)
|| !Number.isFinite(listVisibleHeight)) {
return 0;
}
return Math.max(0, Math.min(rowVisibleTop, listVisibleHeight - editorHeight));
}
Loading
Loading