Skip to content

Commit 354d4c2

Browse files
luoxuanzaoQoder-AI
andauthored
feat: add per-model context and thinking editor mirroring the Qoder IDE (#22)
- Model dropdown becomes a two-card cascade: the model list stays in the left pane while a per-model editor expands beside the hovered row, with flip/max-height placement as pure tested functions - Editor shows context window tiers (ascending), a thinking on/off toggle, and server thinking effort levels sorted by the canonical intensity scale (low to max), matching the IDE selector - Overrides persist per model in settings and apply at request time via SDK pull-mode resolveModel (contextWindow / reasoningEffort); probe maps context_config / thinking_config and getters normalize stale persisted order on read - Editing a non-selected model no longer rewrites the conversation usage meter; reopening the dropdown (mouse or keyboard) returns to the list view; runtime degradation clears a stale editor card - Edit pencil appears on hover/focus only; i18n keys in 10 locales Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent fde5d0d commit 354d4c2

35 files changed

Lines changed: 2205 additions & 19 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@ coverage/
4343

4444
# Local preview material, not shipped
4545
/docs/preview/
46+
47+
# Temporary screenshots from automated UI verification
48+
/.cu_crop_*.png

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ version with its date and start a fresh empty `[Unreleased]` above it.
2323
China build (`qoderclicn`, config under `~/.qoder-cn`). Auto-
2424
detection, session history, global plugins, and login hints all
2525
follow the selected edition.
26+
- Per-model context and thinking editor in the model selector,
27+
mirroring the Qoder IDE: hovering a model row reveals an edit
28+
affordance that opens a side editor card with context window
29+
tiers, a thinking on/off toggle, and the model's server-provided
30+
thinking effort levels. Choices persist per model and are applied
31+
to every request.
2632

2733
### Fixed
2834

src/core/types/services.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ManagedMcpServer,
66
PluginInfo,
77
} from './index';
8+
import type { ModelContextTier, ModelThinkingEffort } from './settings';
89
// ---------------------------------------------------------------------------
910
// App-level service interfaces
1011
// ---------------------------------------------------------------------------
@@ -121,6 +122,12 @@ export interface UIOption {
121122
priceLabel?: string;
122123
/** Trailing promotion badge shown on the option row, e.g. '错峰5折'. */
123124
promotionLabel?: string;
125+
/** Configurable context-window tiers for the model edit panel. */
126+
contextTiers?: ModelContextTier[];
127+
/** Whether the model edit panel may offer disabling thinking. */
128+
thinkingDisableable?: boolean;
129+
/** Configurable thinking effort levels for the model edit panel. */
130+
thinkingEfforts?: ModelThinkingEffort[];
124131
}
125132

126133
export interface PathIconSvg {

src/core/types/settings.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,33 @@ export const QODER_CLI_EDITIONS = ['global', 'cn'] as const;
7373
*/
7474
export type QoderCliEdition = typeof QODER_CLI_EDITIONS[number];
7575

76+
/** Server context-window tier, e.g. the 200K/400K/1M editor choices. */
77+
export interface ModelContextTier {
78+
/** Display tier label from the server, e.g. '200K'. */
79+
label: string;
80+
tokenCount: number;
81+
isDefault: boolean;
82+
}
83+
84+
/** Server thinking effort level, e.g. the low/medium/xhigh editor choices. */
85+
export interface ModelThinkingEffort {
86+
/** Effort value accepted by the CLI, e.g. 'xhigh'. */
87+
value: string;
88+
isDefault: boolean;
89+
/** Server description of the level, shown as a tooltip in the IDE. */
90+
description?: string;
91+
}
92+
93+
/** Per-model editor overrides mirroring the Qoder IDE model edit panel. */
94+
export interface QoderModelOverride {
95+
/** Selected context-window tier in tokens; absent means server default. */
96+
contextWindow?: number;
97+
/** False disables thinking for models that support it. */
98+
thinkingEnabled?: boolean;
99+
/** Per-model reasoning effort; absent means server default. */
100+
thinkingEffort?: string;
101+
}
102+
76103
/** Qoder CLI settings stored alongside Qoderian's general preferences. */
77104
export interface QoderSettings {
78105
cliPath: string;
@@ -91,6 +118,12 @@ export interface QoderSettings {
91118
priceFactor?: number;
92119
/** Pre-discount multiplier, when the server prices this model down. */
93120
originalPriceFactor?: number;
121+
/** Configurable context-window tiers reported by the server. */
122+
contextTiers?: ModelContextTier[];
123+
/** Whether the server allows explicitly disabling thinking. */
124+
thinkingDisableable?: boolean;
125+
/** Configurable thinking effort levels reported by the server. */
126+
thinkingEfforts?: ModelThinkingEffort[];
94127
promotion?: {
95128
active?: boolean;
96129
/** Server-localized badge text keyed by `en` / `zh`. */
@@ -107,6 +140,8 @@ export interface QoderSettings {
107140
model?: string;
108141
}>;
109142
lastModel: string;
143+
/** Per-model editor overrides keyed by runtime model id. */
144+
modelOverrides: Record<string, QoderModelOverride>;
110145
}
111146

112147
/**

src/features/chat/tabs/tab.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { getEnhancedPath } from '../../../core/env/environment';
66
import { getVaultPath } from '../../../core/fs/path';
77
import type { ChatRuntime } from '../../../core/runtime/chat-runtime';
88
import type { ChatMessage, Conversation, QoderState } from '../../../core/types';
9+
import type { QoderModelOverride } from '../../../core/types/settings';
910
import { t } from '../../../i18n/i18n';
1011
import type QoderianPlugin from '../../../main';
11-
import { getQoderSettings } from '../../../qoder/config/settings';
12+
import { getQoderSettings, updateQoderSettings } from '../../../qoder/config/settings';
1213
import {
1314
SlashCommandDropdown,
1415
toSlashCommandDropdownEntries,
@@ -431,6 +432,37 @@ function initializeInputToolbar(
431432
settings.effortLevel = effort;
432433
});
433434
},
435+
onModelOverrideChange: async (model: string, override: Partial<QoderModelOverride>) => {
436+
await updateTabQoderSettings(tab, plugin, (settings) => {
437+
const current = getQoderSettings(settings).modelOverrides;
438+
const merged: QoderModelOverride = { ...current[model] };
439+
for (const [key, value] of Object.entries(override)) {
440+
if (value === undefined) {
441+
delete merged[key as keyof QoderModelOverride];
442+
} else {
443+
(merged as Record<string, unknown>)[key] = value;
444+
}
445+
}
446+
const next = { ...current };
447+
if (Object.keys(merged).length > 0) next[model] = merged;
448+
else delete next[model];
449+
updateQoderSettings(settings, { modelOverrides: next });
450+
});
451+
452+
// The context meter tracks the effective window of the edited model.
453+
// Overrides for other models must not rewrite this conversation's
454+
// usage, which belongs to the currently selected model.
455+
const currentUsage = tab.state.usage;
456+
if (currentUsage && model === plugin.settings.model) {
457+
const modelConfig = getTabModelConfig(tab, plugin);
458+
const newContextWindow = modelConfig.getEffectiveContextWindowSize(
459+
model,
460+
plugin.settings,
461+
);
462+
tab.state.usage = recalculateUsageForModel(currentUsage, model, newContextWindow);
463+
tab.ui.contextUsageMeter?.update(tab.state.usage);
464+
}
465+
},
434466
onPermissionModeChange: async (mode) => {
435467
await updateTabQoderSettings(tab, plugin, (settings) => {
436468
settings.permissionMode = mode;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Pure placement decisions for the model dropdown cascade panel.
3+
*
4+
* The rules mirror the flip/size middleware of floating-positioning
5+
* libraries: prefer the anchor's inline-start edge and flip only when the
6+
* panel would overflow the viewport there, and cap the panel height to the
7+
* space available above the toolbar. Keeping the decisions pure (no DOM
8+
* access) makes them unit-testable without a layout engine; the selector
9+
* component only measures and applies the results.
10+
*/
11+
12+
export interface ModelDropdownAnchorRect {
13+
left: number;
14+
right: number;
15+
top: number;
16+
}
17+
18+
/** Breathing room kept between the panel and the viewport edge. */
19+
export const MODEL_DROPDOWN_VIEWPORT_MARGIN = 8;
20+
/** Repo-wide popover height convention (see nav TOC popover). */
21+
export const MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT = 420;
22+
/** Floor so the panel stays usable even in very short windows. */
23+
export const MODEL_DROPDOWN_MIN_HEIGHT = 160;
24+
25+
/**
26+
* Decides whether the panel should anchor to the inline-end edge of its
27+
* toolbar instead of the inline-start edge. Start alignment keeps the panel
28+
* flush with the trigger button; flipping is a last resort for anchors whose
29+
* start side cannot fit the panel.
30+
*/
31+
export function shouldFlipModelDropdown(
32+
anchor: ModelDropdownAnchorRect,
33+
panelWidth: number,
34+
viewportWidth: number,
35+
): boolean {
36+
const margin = MODEL_DROPDOWN_VIEWPORT_MARGIN;
37+
const fitsAtStart = anchor.left + panelWidth <= viewportWidth - margin;
38+
const fitsAtEnd = anchor.right - panelWidth >= margin;
39+
return !fitsAtStart && fitsAtEnd;
40+
}
41+
42+
/**
43+
* Caps the panel to the vertical space above the toolbar so the upward
44+
* growing dropdown never covers the viewport top, without exceeding the
45+
* repo-wide 420px popover convention.
46+
*/
47+
export function modelDropdownMaxHeight(anchorTop: number): number {
48+
const available = anchorTop - MODEL_DROPDOWN_VIEWPORT_MARGIN;
49+
if (!Number.isFinite(available)) return MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT;
50+
return Math.max(
51+
MODEL_DROPDOWN_MIN_HEIGHT,
52+
Math.min(available, MODEL_DROPDOWN_FALLBACK_MAX_HEIGHT),
53+
);
54+
}
55+
56+
/**
57+
* Anchors the compact editor card to its edited row like an IDE flyout,
58+
* clamped so the card never escapes the visible list area.
59+
*/
60+
export function modelEditorPaneOffset(
61+
rowVisibleTop: number,
62+
editorHeight: number,
63+
listVisibleHeight: number,
64+
): number {
65+
if (!Number.isFinite(rowVisibleTop)
66+
|| !Number.isFinite(editorHeight)
67+
|| !Number.isFinite(listVisibleHeight)) {
68+
return 0;
69+
}
70+
return Math.max(0, Math.min(rowVisibleTop, listVisibleHeight - editorHeight));
71+
}

0 commit comments

Comments
 (0)