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/mid-conversation-switch-warning.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 !== '',
Comment thread
liruifengv marked this conversation as resolved.
);
}

function currentTuiConfig(host: SlashCommandHost): TuiConfig {
return {
theme: host.state.appState.theme,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion apps/kimi-code/src/tui/components/dialogs/effort-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Key,
matchesKey,
truncateToWidth,
wrapTextWithAnsi,
type Focusable,
} from '@moonshot-ai/pi-tui';

Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion apps/kimi-code/src/tui/components/dialogs/model-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Focusable,
} from '@moonshot-ai/pi-tui';

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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));
Expand Down
25 changes: 13 additions & 12 deletions apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ''));
});
});
39 changes: 39 additions & 0 deletions apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading