diff --git a/.changeset/mid-conversation-switch-warning.md b/.changeset/mid-conversation-switch-warning.md new file mode 100644 index 0000000000..620003eabb --- /dev/null +++ b/.changeset/mid-conversation-switch-warning.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Warn in the /model and /effort pickers that switching invalidates the existing prompt cache, and hint to use /new to avoid extra token costs. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index e3d6ad852c..61b68b955a 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -35,6 +35,21 @@ import type { SlashCommandHost } from './dispatch'; const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000; +const MODEL_SWITCH_CACHE_WARNING = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; +const EFFORT_SWITCH_CACHE_WARNING = + 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + +/** True once the conversation has at least one user message: a switch from + * then on resends the accumulated context, losing the cache. Shell-command + * echoes are also 'user' transcript entries but carry an empty `bullet`, so + * they're excluded. */ +function hasConversationHistory(host: SlashCommandHost): boolean { + return host.state.transcriptEntries.some( + (entry) => entry.kind === 'user' && entry.bullet !== '', + ); +} + function currentTuiConfig(host: SlashCommandHost): TuiConfig { return { theme: host.state.appState.theme, @@ -276,6 +291,7 @@ function showEffortPicker( new EffortSelectorComponent({ efforts: segments, currentValue, + warning: hasConversationHistory(host) ? EFFORT_SWITCH_CACHE_WARNING : undefined, onSelect: (effort) => { host.restoreEditor(); void performModelSwitch(host, alias, effort, true); @@ -392,6 +408,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = currentValue: host.state.appState.model, selectedValue, currentThinkingEffort: host.state.appState.thinkingEffort, + warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined, onSelect: ({ alias, thinking }) => { host.restoreEditor(); void performModelSwitch(host, alias, thinking, true); diff --git a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts index 2678899ad9..bba3243062 100644 --- a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -3,6 +3,7 @@ import { Key, matchesKey, truncateToWidth, + wrapTextWithAnsi, type Focusable, } from '@moonshot-ai/pi-tui'; @@ -22,6 +23,10 @@ export interface EffortSelectorOptions { /** When provided, Alt+S applies the choice to the current session only. */ readonly onSessionOnlySelect?: (effort: ThinkingEffort) => void; readonly onCancel: () => void; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; } /** @@ -76,8 +81,13 @@ export class EffortSelectorComponent extends Container implements Focusable { currentTheme.fg('primary', '─'.repeat(width)), currentTheme.boldFg('primary', ` ${this.opts.title ?? 'Select thinking effort'}`), currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), - '', ]; + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); + } + } + lines.push(''); const segments = this.opts.efforts.map((effort, index) => { const label = effortLabel(effort); diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 82906d1d50..64646e0229 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -5,6 +5,7 @@ import { matchesKey, truncateToWidth, visibleWidth, + wrapTextWithAnsi, type Focusable, } from '@moonshot-ai/pi-tui'; @@ -73,6 +74,10 @@ export interface ModelSelectorOptions { /** When true, the hint line mentions the Tab provider switch — set by * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ readonly providerSwitchHint?: boolean; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; readonly onSelect: (selection: ModelSelection) => void; /** When provided, Alt+S invokes this instead of onSelect — used to apply the * choice to the current session only, without persisting it as the default. */ @@ -286,8 +291,13 @@ export class ModelSelectorComponent extends Container implements Focusable { currentTheme.fg('primary', '─'.repeat(width)), currentTheme.boldFg('primary', ' Select a model') + titleSuffix, currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), - '', ]; + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); + } + } + lines.push(''); if (searchable && view.query.length > 0) { lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 9a986b0962..8adc5efa14 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -43,6 +43,10 @@ export interface TabbedModelSelectorOptions { /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; + /** Forwarded to each inner selector; when set, warning-colored lines are + * rendered directly below the key-hint line, wrapping as needed (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; readonly onSelect: (selection: ModelSelection) => void; /** Forwarded to each inner selector; when set, Alt+S applies the choice to * the current session only without persisting it as the default. */ @@ -100,24 +104,20 @@ export class TabbedModelSelectorComponent extends Container implements Focusable if (this.tabs.length <= 1) { return inner.map((line) => truncateToWidth(line, width)); } - // Layout: divider, title, hint, blank, tab strip, blank, then the model - // list. The inner selector's blank line (inner[3]) separates the hint from - // the tab strip; an extra blank separates the tabs from their list. + // Layout: divider, title, hint, optional warning, blank, tab strip, blank, + // then the model list. The header ends at its first blank line — keep that + // blank above the strip, and separate the tabs from the list with another + // blank. const stripLine = renderTabStrip({ labels: this.tabs.map((tab) => tab.label), activeIndex: this.activeIndex, width, colors: currentTheme.palette, }); - const out: string[] = [ - inner[0] ?? '', - inner[1] ?? '', - inner[2] ?? '', - inner[3] ?? '', - stripLine, - '', - ]; - for (let i = 4; i < inner.length; i++) out.push(inner[i]!); + const headerEnd = inner.findIndex((line) => line === ''); + const splitAt = headerEnd === -1 ? 3 : headerEnd; + const out: string[] = [...inner.slice(0, splitAt + 1), stripLine, '']; + for (let i = splitAt + 1; i < inner.length; i++) out.push(inner[i]!); return out.map((line) => truncateToWidth(line, width)); } @@ -182,6 +182,7 @@ function makeSelector( currentThinkingEffort: opts.currentThinkingEffort, searchable: true, providerSwitchHint: true, + warning: opts.warning, onSelect: opts.onSelect, onSessionOnlySelect: opts.onSessionOnlySelect, onCancel: opts.onCancel, diff --git a/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts index e74fa7aa11..53ff5b1702 100644 --- a/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts @@ -102,4 +102,50 @@ describe('EffortSelectorComponent', () => { picker.handleInput(ESC); expect(onCancel).toHaveBeenCalledTimes(1); }); + + it('renders the warning line directly below the key-hint line when provided', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + }); + + it('renders no warning line without the warning option', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toBe(''); + }); + + it('wraps a warning longer than the width instead of truncating it', () => { + const warning = + 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + warning, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(40).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(lines[hintIdx + 1]).not.toBe(''); + expect(lines[hintIdx + 2]).not.toBe(''); + // Word-wrapped: nothing dropped — the full warning survives across lines. + const squashed = lines.join('').replaceAll(/\s+/g, ''); + expect(squashed).toContain(warning.replaceAll(/\s+/g, '')); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index f40cc4093b..8fced41768 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -470,6 +470,45 @@ describe('ModelSelectorComponent', () => { // middle entry (medium), not a hardcoded level. expect(text(picker)).toContain('[ Medium ]'); }); + + it('renders the warning line directly below the key-hint line when provided', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('↑↓ navigate')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + // Model list is pushed below the inserted warning line, not overlapped. + expect(lines.findIndex((l) => l.includes('Kimi K2'))).toBeGreaterThan(hintIdx + 1); + }); + + it('wraps a warning longer than the width instead of truncating it', () => { + const warning = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + warning, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = picker.render(50).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('↑↓ navigate')); + expect(lines[hintIdx + 1]).not.toBe(''); + expect(lines[hintIdx + 2]).not.toBe(''); + // Word-wrapped: nothing dropped — the full warning survives across lines. + const squashed = lines.join('').replaceAll(/\s+/g, ''); + expect(squashed).toContain(warning.replaceAll(/\s+/g, '')); + }); }); describe('ModelSelectorComponent overrides', () => { diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index 28fc4210fc..3f42874861 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -131,4 +131,26 @@ describe('TabbedModelSelectorComponent', () => { // It comes first, before the navigation hint. expect(hint!.indexOf('Tab toggle provider')).toBeLessThan(hint!.indexOf('↑↓ navigate')); }); + + it('keeps the tab strip between hint and list when a warning line is present', () => { + const component = new TabbedModelSelectorComponent({ + models: { + k2: model('Kimi K2', 'managed:kimi-code'), + gpt: model('GPT-5', 'openai'), + }, + currentValue: 'k2', + currentThinkingEffort: 'off', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = component.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('navigate') && l.includes('Esc cancel')); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + expect(lines[hintIdx + 2]).toBe(''); // blank between warning and tabs + const stripIdx = lines.findIndex((l) => l.includes('All') && l.includes('openai')); + expect(stripIdx).toBe(hintIdx + 3); + expect(lines[stripIdx + 1]).toBe(''); // blank between tabs and list + expect(lines.findIndex((l) => l.includes('Kimi K2'))).toBeGreaterThan(stripIdx); + }); });