feat(tui): add cache-expiry hint dialog for resumed and idle sessions (v2 engine) (#2646)

* feat(agent-core-v2): detect prompt-cache breaks from per-step usage and emit telemetry

Track consecutive turn-scoped LLM requests per agent; when the cache-read
token count drops by more than 5% and by more than 2000 tokens between
requests, log a debug line and emit cache_break_detected with both usages,
the drop ratio, and the interval. Operation requests (e.g. compaction) act
as a baseline barrier so expected drops are not reported.

* feat(tui): add cache-expiry hint dialog for resumed and idle sessions (v2 engine)

Resuming a long-idle session or submitting after a long idle stretch
re-sends the whole history with an expired context cache. Show a dialog
offering to compact, start a new session, continue as-is, or never ask
again (persisted as cache_expiry_hint in tui.toml). Thresholds come from
the client_configs endpoint (estimated_cache_duration) via a generic
per-name cached client; only OAuth-managed providers participate.

* fix(tui): preserve submit order and revalidate session in cache-hint flows

Cold-cache submits during the in-flight config fetch are now swallowed and
replayed through a FIFO chain, so a later prompt can never overtake the
stashed one. Both the resume and idle paths re-check the current session
after the async fetch: a switch mid-flight drops the dialog (resume) or
hands the stashed input back to the editor instead of sending it into the
wrong session (idle).

* chore(agent-core-v2): regenerate state manifest after merging main

* fix(tui): apply cache_expiry_hint on /reload and /reload-tui

* fix(agent-core-v2): skip unmeasured all-zero usage in cache break detection

* fix(tui): restore chained cache-hint submits when the dialog is not sent

When several submits are swallowed during the cold-config fetch and the
first dialog is dismissed (or its compact/new action fails), the stashed
inputs were restored while later chained submits were still released —
reordering the conversation. Chained submits now follow the fate of the
message that opened the dialog, and multiple restores append newline-joined
instead of overwriting the editor.

* fix(agent-core-v2): reset cache-break baseline on model change

Caches are per-model, so a cache-read drop after /model is expected, not a
break. The baseline now carries the model and only same-model records are
compared.

* fix(tui): only count LLM-activity replay records for the resume cache hint

The v2 resume replay also carries local-only state records (permission,
plan, config updates, approval results) that slash commands append without
an LLM request. Filter lastActiveAt to message/compaction records so a
recent local change no longer masks an expired cache.

* style(agent-core-v2): rewrite the cacheBreak impl header per package convention

State the domain role, collaborators, and scope instead of narrating
implementation steps; the behavior guards now live in the code alone.

* fix(tui): drop the resume cache hint when a turn started mid-fetch

The resume dialog is fire-and-forget over an async config fetch; if the
user already sent the first prompt by the time it resolves, mounting would
overlay an active turn and its actions would hit the live session. Re-check
streamingPhase/isCompacting after the await, next to the session check.

* style(agent-core-v2): trim the cacheBreak contract header to contract and scope

* refactor: report cache-break detection from the TUI client

Move the detector out of the engine so the telemetry event carries the
client's own identity (which client produced it is now attributable). The
TUI observes main-loop turn.step.completed usage directly, with the same
guards: first-step/unmeasured/all-zero records skipped, model change and
compaction reset the baseline. The agent-core-v2 cacheBreak module is
removed.

* chore: drop accidentally committed dist-web build output and ignore it

* chore: revert the dist-web ignore rule

* chore: restore dist-web to the tracked content from main

* feat(tui): record cache breaks caused by mid-session model/effort switches

A model or effort change mid-session busts the prompt-cache key — that is
a real cache break worth attributing, not noise. The baseline now carries
model and effort, the same-model exemption is gone, and cache_break_detected
reports prev/curr model and effort alongside both usages.

* chore(changeset): simplify the cache-expiry hint entry

* chore(changeset): trim the cache-expiry hint entry to one line

* fix(tui): cache-hint review follow-ups

- carry the pre-dialog media extraction through compact/new resends so
  pasted attachments survive the image-store clear on a new session
- reset the cache-break baseline after /undo — the context cut makes the
  next cache-read drop expected
- release the stashed submit when a foreground operation started during
  the cold-config fetch instead of mounting the dialog over it
- count a completed compaction as activity so the next submit is not
  judged against the pre-compaction timestamp

* chore(changeset): drop the v2-engine-only suffix

* fix(tui): seed the activity baseline when the resume check skips

* fix(tui): cache-hint review follow-ups

* fix(tui): record cache activity on completed steps, not turn begin

* feat(cli): persist the client-configs cache across restarts
This commit is contained in:
liruifengv 2026-08-06 13:26:37 +08:00 committed by GitHub
parent 713bf1a5a2
commit 3c75a27da6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2287 additions and 4 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Show a cache-expiry reminder when resuming a long-idle session or submitting after a long idle stretch.

View file

@ -52,13 +52,15 @@ function hasConversationHistory(host: SlashCommandHost): boolean {
);
}
function currentTuiConfig(host: SlashCommandHost): TuiConfig {
export function currentTuiConfig(host: Pick<SlashCommandHost, 'state'>): TuiConfig {
return {
theme: host.state.appState.theme,
editorCommand: host.state.appState.editorCommand,
disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
statusLine: host.state.appState.statusLine ?? DEFAULT_TUI_CONFIG.statusLine,
};
}

View file

@ -163,6 +163,9 @@ export interface SlashCommandHost {
failSessionRequest(message: string): void;
sendQueuedMessage(session: Session, item: QueuedMessage): void;
requestQueuedGoalPromotion?(): void;
/** Reset the client-side cache-break baseline after the context was cut
* (/undo): the next step's cache-read drop is expected, not a break. */
noteContextCut?(): void;
// UI
showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle;

View file

@ -63,6 +63,7 @@ export async function applyReloadedTuiConfig(
host.setAppState({
editorCommand: config.editorCommand,
disablePasteBurst: config.disablePasteBurst,
cacheExpiryHint: config.cacheExpiryHint,
notifications: config.notifications,
upgrade: config.upgrade,
statusLine: config.statusLine,

View file

@ -103,6 +103,7 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise<boole
host.showError(`Failed to undo: ${message}`);
return false;
}
host.noteContextCut?.();
const children = host.state.transcriptContainer.children;
const lastUserComponentIndex = findUndoAnchorComponentIndex(children, count);

View file

@ -0,0 +1,121 @@
/**
* CacheHintDialog shown when a resumed (or long-idle) session's context
* cache has almost certainly expired, so the next turn re-sends the whole
* history uncached. Offers compact / new session / continue / never.
*
* Layout mirrors the list-dialog spec (DESIGN.md): top border, title, hint,
* body line, then options with right-column descriptions.
*/
import {
Container,
matchesKey,
Key,
truncateToWidth,
visibleWidth,
type Focusable,
} from '@moonshot-ai/pi-tui';
import { SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { formatIdleDuration } from '#/tui/utils/cache-hint';
import { SearchableList } from '#/tui/utils/searchable-list';
import { formatTokenCount } from '#/utils/usage/usage-format';
export type CacheHintAction = 'compact' | 'new' | 'continue' | 'never';
interface CacheHintOption {
readonly value: CacheHintAction;
readonly label: string;
readonly description?: string;
}
const OPTIONS: readonly CacheHintOption[] = [
{
value: 'compact',
label: 'Compact and continue',
description: 'one-time compact cost · cheapest way to keep this topic',
},
{
value: 'new',
label: 'Start a new session',
description: 'zero context cost · best for a new task',
},
{
value: 'continue',
label: 'Continue as-is',
description: 'full history kept · highest cost per turn',
},
{ value: 'never', label: "Don't ask me again" },
];
export interface CacheHintDialogOptions {
readonly idleSeconds: number;
readonly totalTokens: number;
readonly onSelect: (action: CacheHintAction) => void;
readonly onCancel: () => void;
}
export class CacheHintDialogComponent extends Container implements Focusable {
focused = false;
private readonly opts: CacheHintDialogOptions;
private readonly list: SearchableList<CacheHintOption>;
constructor(opts: CacheHintDialogOptions) {
super();
this.opts = opts;
this.list = new SearchableList({
items: OPTIONS,
toSearchText: (o) => o.label,
initialIndex: 0,
searchable: false,
});
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.enter)) {
const chosen = this.list.selected();
if (chosen !== undefined) this.opts.onSelect(chosen.value);
return;
}
this.list.handleKey(data);
}
override render(width: number): string[] {
const view = this.list.view();
const title = `This session has been idle for ${formatIdleDuration(this.opts.idleSeconds)} and is ~${formatTokenCount(this.opts.totalTokens)} tokens.`;
const lines: string[] = [
currentTheme.fg('primary', '─'.repeat(width)),
currentTheme.boldFg('primary', ` ${title}`),
currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'),
'',
currentTheme.fg(
'text',
' Cache expired — the next message re-sends the entire history at full price.',
),
];
const maxLabelWidth = Math.max(...OPTIONS.map((o) => visibleWidth(o.label)));
for (let i = view.page.start; i < view.page.end; i++) {
const opt = view.items[i]!;
const isSelected = i === view.selectedIndex;
const pointer = isSelected ? SELECT_POINTER : ' ';
let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `);
line += isSelected
? currentTheme.boldFg('primary', opt.label)
: currentTheme.fg('text', opt.label);
if (opt.description !== undefined) {
const gap = maxLabelWidth - visibleWidth(opt.label) + 4;
line += ' '.repeat(gap) + currentTheme.fg('textMuted', opt.description);
}
lines.push(line);
}
lines.push('');
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width));
}
}

View file

@ -54,6 +54,7 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = {
export const TuiConfigFileSchema = z.object({
theme: TuiThemeSchema.optional(),
disable_paste_burst: z.boolean().optional(),
cache_expiry_hint: z.boolean().optional(),
editor: z
.object({
command: z.string().optional(),
@ -76,6 +77,9 @@ export const TuiConfigFileSchema = z.object({
export const TuiConfigSchema = z.object({
theme: TuiThemeSchema,
disablePasteBurst: z.boolean(),
/** Present in every normalized config; optional only so hand-built test
* fixtures from before this field existed still typecheck. */
cacheExpiryHint: z.boolean().optional(),
editorCommand: z.string().nullable(),
notifications: NotificationsConfigSchema,
upgrade: UpgradePreferencesSchema,
@ -101,6 +105,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = {
export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({
theme: 'auto',
disablePasteBurst: false,
cacheExpiryHint: true,
editorCommand: null,
notifications: DEFAULT_NOTIFICATIONS_CONFIG,
upgrade: DEFAULT_UPGRADE_PREFERENCES,
@ -186,6 +191,7 @@ export function normalizeTuiConfig(
return TuiConfigSchema.parse({
theme: config.theme ?? DEFAULT_TUI_CONFIG.theme,
disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint,
editorCommand: command === undefined || command.length === 0 ? null : command,
notifications: {
enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled,
@ -234,6 +240,7 @@ export function renderTuiConfig(config: TuiConfig): string {
theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name
disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback
cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit
[editor]
command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR

View file

@ -0,0 +1,487 @@
/**
* CacheHintController drives the "cache expired" dialog for the two trigger
* scenarios: resuming a long-idle session (fires right after the resume
* finishes loading) and submitting after an in-process idle stretch
* (intercepts the submit). Owns the frequency guards and the in-process
* activity baseline; the pure trigger rule lives in `../utils/cache-hint`.
*/
import type { Component, Focusable } from '@moonshot-ai/pi-tui';
import type { KimiHarness, Session, TokenUsage } from '@moonshot-ai/kimi-code-sdk';
import { getCacheHintConfig, peekCacheHintConfig } from '#/utils/cache-hint-config';
import { currentTuiConfig } from '../commands/config';
import {
CacheHintDialogComponent,
type CacheHintAction,
} from '../components/dialogs/cache-hint-dialog';
import { saveTuiConfig } from '../config';
import { MAIN_AGENT_ID } from '../constant/kimi-tui';
import type { AppState } from '../types';
import type { TUIState } from '../tui-state';
import { evaluateCacheHint } from '../utils/cache-hint';
import { formatErrorMessage } from '../utils/event-payload';
import type { ExtractionResult } from '../utils/image-placeholder';
/** A swallowed submit: the raw text plus its media extraction (done before
* the dialog so pasted attachments survive a later store clear). */
interface StashedSubmit {
readonly text: string;
readonly extraction?: ExtractionResult;
}
export interface CacheHintHost {
readonly engineV2: boolean;
readonly harness: KimiHarness;
readonly session: Session | undefined;
readonly state: TUIState;
track(event: string, props?: Record<string, unknown>): void;
setAppState(patch: Partial<AppState>): void;
mountEditorReplacement(panel: Component & Focusable): void;
restoreEditor(): void;
restoreInputText(text: string): void;
showError(message: string): void;
createNewSession(): Promise<void>;
sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise<void>;
}
type HintDecision = { readonly idleSeconds: number; readonly totalTokens: number };
/** Cache-break detection: a step's cache read dropping under 95% of the
* previous step's by more than this many tokens counts as a break. */
const CACHE_BREAK_MIN_DROP_TOKENS = 2000;
const CACHE_BREAK_DROP_RATIO = 0.95;
interface CacheBreakBaseline {
readonly model: string;
readonly effort: string;
readonly usage: TokenUsage;
readonly time: number;
}
export class CacheHintController {
/** Latest in-process LLM round-trip time (turn begin / turn end). */
private lastActivityAt: number | undefined;
/** One prompt per idle cycle; reset when a real send starts a turn. */
private idlePrompted = false;
/** Cold-cache trigger fetches at most once per idle cycle (loop guard for
* the release-and-resend path). */
private triggerFetchAttempted = false;
/** Swallowed submits waiting on the cold-cache interception chain. */
private pendingInterceptions = 0;
/** FIFO chain serializing swallowed submits so they keep submit order. */
private interceptionTail: Promise<void> = Promise.resolve();
/** Set while a stashed message is being released back into the send path. */
private releasingStashed = false;
/** Whether the idle dialog's triggering message was restored, not sent. */
private lastDialogRestored = false;
/** Inputs restored this cycle chained restores append (newline-joined)
* instead of overwriting the editor. */
private restoredTexts: string[] = [];
/** Resume scenario fires at most once per session per TUI instance. */
private readonly resumedSessions = new Set<string>();
/** Last measured main-loop step usage for cache-break detection. */
private breakBaseline: CacheBreakBaseline | undefined;
constructor(private readonly host: CacheHintHost) {}
/**
* Cache-break detection (client-side, main loop): feed each completed
* step's usage. A step whose cache read drops sharply below the previous
* one is reported as `cache_break_detected` with both usages, both
* model/effort values, the drop ratio, and the interval a mid-session
* model/effort switch busts the cache key, and that cause is exactly what
* the report should carry. Unmeasured (missing/all-zero) usage is skipped
* without touching the baseline; compaction resets it (the drop there is
* expected).
*
* Also doubles as the cache-activity signal: a completed step is a real
* provider round trip, so the server-side cache was just refreshed
* unlike a bare turn begin, whose prompt may still fail before any model
* request.
*/
noteStepUsage(usage: TokenUsage | undefined): void {
this.recordActivity();
if (usage === undefined) return;
if (
usage.inputOther === 0 &&
usage.output === 0 &&
usage.inputCacheRead === 0 &&
usage.inputCacheCreation === 0
) {
return;
}
const model = this.host.state.appState.model;
const effort = this.host.state.appState.thinkingEffort;
const now = Date.now();
const prev = this.breakBaseline;
this.breakBaseline = { model, effort, usage, time: now };
if (prev === undefined) return;
const prevRead = prev.usage.inputCacheRead;
const currRead = usage.inputCacheRead;
if (currRead >= prevRead * CACHE_BREAK_DROP_RATIO) return;
if (prevRead - currRead <= CACHE_BREAK_MIN_DROP_TOKENS) return;
this.host.track('cache_break_detected', {
prev_model: prev.model,
curr_model: model,
prev_effort: prev.effort,
curr_effort: effort,
prev_input_cache_read: prevRead,
curr_input_cache_read: currRead,
prev_input_other: prev.usage.inputOther,
curr_input_other: usage.inputOther,
prev_output: prev.usage.output,
curr_output: usage.output,
prev_input_cache_creation: prev.usage.inputCacheCreation,
curr_input_cache_creation: usage.inputCacheCreation,
cache_read_drop_ratio: (prevRead - currRead) / prevRead,
interval_ms: now - prev.time,
});
}
/** Compaction legitimately shrinks the cached prefix reset the baseline.
* Also used when the context is cut by other means (e.g. /undo). */
resetCacheBreakBaseline(): void {
this.breakBaseline = undefined;
}
recordActivity(): void {
this.lastActivityAt = Date.now();
}
/**
* A real send starts a turn open a fresh idle cycle. Cache activity is
* deliberately NOT recorded here: the prompt may still fail before any
* model request (rejected call, hook-blocked turn), and only a completed
* provider round trip refreshes the server-side cache.
*/
onTurnBegin(): void {
this.idlePrompted = false;
this.triggerFetchAttempted = false;
this.lastDialogRestored = false;
this.restoredTexts = [];
}
/** Session switch / create: the new session has no in-process baseline. */
resetRuntime(): void {
this.lastActivityAt = undefined;
this.idlePrompted = false;
this.triggerFetchAttempted = false;
this.lastDialogRestored = false;
this.restoredTexts = [];
this.breakBaseline = undefined;
}
/** Background warm-up on session creation; never blocks, never throws. */
refreshConfigInBackground(): void {
void this.resolveConfig();
}
/** Scenario 1: call right after a resume finishes loading. */
async maybeShowOnResume(): Promise<void> {
const { host } = this;
const session = host.session;
if (!host.engineV2 || session === undefined) return;
if (this.resumedSessions.has(session.id)) return;
const main = session.getResumeState()?.agents[MAIN_AGENT_ID];
let lastActiveAt = 0;
for (const record of main?.replay ?? []) {
// Only message/compaction records correspond to LLM round-trips; state
// records (permission/plan/config updates, approval results) can be
// appended by slash commands without touching the cache.
if (record.type !== 'message' && record.type !== 'compaction') continue;
if (record.time > lastActiveAt) lastActiveAt = record.time;
}
// `summary.updatedAt` ≈ last user prompt — a coarser but valid fallback.
if (lastActiveAt === 0) lastActiveAt = session.summary?.updatedAt ?? 0;
if (lastActiveAt === 0) return;
const config = await this.resolveConfig();
// The config fetch above can outlive the user's patience: if they switched
// sessions meanwhile, this dialog (and its actions) would target the wrong
// session — drop it. Likewise, if they already sent the first prompt and
// a turn is now running, don't mount over the active turn.
if (host.session !== session) return;
if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) {
return;
}
// Fold in-process activity into the replay-derived baseline before
// judging: a turn may have completed during the fetch, and a completed
// turn refreshes the server-side cache — the stale replay timestamp would
// warn about an expiration the user just paid to fix. Seeding the
// baseline either way also lets a resume inside the cache window expire
// via the idle-submit path while the user idles in the TUI.
lastActiveAt = Math.max(lastActiveAt, this.lastActivityAt ?? 0);
this.lastActivityAt = lastActiveAt;
const decision = evaluateCacheHint({
now: Date.now(),
lastActiveAt,
totalTokens: main?.context.tokenCount,
modelId: this.upstreamModelId(),
config,
dismissed: host.state.appState.cacheExpiryHint === false,
});
if (decision.kind === 'skip') return;
this.resumedSessions.add(session.id);
// The resume dialog also covers this idle cycle: the first submit right
// after it must not be intercepted again.
this.idlePrompted = true;
await this.showDialog('resume', decision, undefined);
}
/**
* Scenario 2: intercept an idle submit. Returns true when swallowed.
* Synchronous in every non-hint path the send pipeline must stay
* await-free up to `sendMessage` (tests assert `prompt()` synchronously
* right after `handleUserInput`). When the config cache is cold the submit
* is swallowed while the config is fetched (spec: the trigger must reach
* the interface); the message is then either shown the dialog or released.
*/
maybeInterceptOnSubmit(text: string, extraction?: ExtractionResult): boolean {
const { host } = this;
if (!host.engineV2 || host.session === undefined) return false;
// A stashed message being released re-enters the send path here — never
// re-intercept it (that would start a second fetch loop).
if (this.releasingStashed) return false;
if (this.idlePrompted || this.lastActivityAt === undefined) return false;
if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) {
return false;
}
if (host.state.appState.cacheExpiryHint === false) return false;
// Providers that can never match a cache rule (apiKey / self-hosted) must
// not pay the cold-fetch stall below — no hint can ever come of it.
if (this.upstreamModelId() === undefined) return false;
// Coarse floor: configured cache durations are 10min+, so anything
// fresher than a minute can never hint.
if (Date.now() - this.lastActivityAt < 60_000) return false;
const stash: StashedSubmit = { text, extraction };
const cached = peekCacheHintConfig();
if (cached !== undefined) {
const decision = evaluateCacheHint({
now: Date.now(),
lastActiveAt: this.lastActivityAt,
totalTokens: host.state.appState.contextTokens,
modelId: this.upstreamModelId(),
config: cached,
dismissed: false,
});
if (decision.kind === 'skip') return false;
this.idlePrompted = true;
// Mounts synchronously inside; the action resolution runs async.
void this.showDialog('idle', decision, stash);
return true;
}
// Config cache cold: fetch at trigger time. Submits arriving while the
// interception is in flight are swallowed too and replayed through a FIFO
// chain, so a later prompt can never overtake the stashed one. A fetch
// that already failed this cycle falls through to the normal send path.
if (this.triggerFetchAttempted && this.pendingInterceptions === 0) return false;
this.triggerFetchAttempted = true;
const sessionId = host.session.id;
this.pendingInterceptions += 1;
this.interceptionTail = this.interceptionTail
.then(() => this.interceptAfterFetch(stash, sessionId))
.finally(() => {
this.pendingInterceptions -= 1;
});
return true;
}
/** Cold-cache path: fetch the config, then show the dialog or release. */
private async interceptAfterFetch(stash: StashedSubmit, sessionId: string): Promise<void> {
const { host } = this;
// A dialog already ran for this idle cycle: chained submits follow the
// fate of the message that opened it. If that message was restored
// (dismissed or its action failed), restore these too — sending them now
// would reorder the conversation.
if (this.idlePrompted) {
if (this.lastDialogRestored) {
this.restoreStashedInput(stash.text);
} else {
await this.releaseStashed(stash);
}
return;
}
const config = await this.resolveConfig();
// The fetch window is unbounded for the user: if they switched sessions
// meanwhile, never send the stashed text into the wrong session — hand it
// back to the editor instead.
if (host.session?.id !== sessionId) {
this.restoreStashedInput(stash.text);
return;
}
// If a foreground operation (turn, /compact, …) started meanwhile, don't
// mount over it — release through the normal path, which queues behind
// the running operation.
if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) {
await this.releaseStashed(stash);
return;
}
if (config !== undefined) {
const decision = evaluateCacheHint({
now: Date.now(),
lastActiveAt: this.lastActivityAt ?? 0,
totalTokens: host.state.appState.contextTokens,
modelId: this.upstreamModelId(),
config,
dismissed: false,
});
if (decision.kind === 'hint') {
this.idlePrompted = true;
await this.showDialog('idle', decision, stash);
return;
}
}
// No hint (fetch failed or rules don't match): release the message. The
// re-entry skips the fetch (fresh cache or triggerFetchAttempted) and
// flows straight to send.
await this.releaseStashed(stash);
}
/** Release a stashed message through the normal send path, bypassing the
* interception gate so the re-entry cannot start a second fetch. */
private async releaseStashed(stash: StashedSubmit): Promise<void> {
this.releasingStashed = true;
try {
await this.host.sendNormalUserInput(stash.text, stash.extraction);
} finally {
this.releasingStashed = false;
}
}
/** Restore a stashed input to the editor, appending to anything already
* restored this cycle so earlier text is not overwritten. */
private restoreStashedInput(text: string | undefined): void {
if (text === undefined) return;
this.restoredTexts.push(text);
this.host.restoreInputText(this.restoredTexts.join('\n'));
}
private upstreamModelId(): string | undefined {
const { model, availableModels, availableProviders } = this.host.state.appState;
const alias = availableModels[model];
if (alias === undefined) return undefined;
// The cache rules describe the managed service's server-side cache, so
// they only apply to OAuth-managed providers — apiKey or self-hosted
// providers never hint.
if (availableProviders[alias.provider]?.oauth === undefined) return undefined;
return alias.model;
}
private async resolveConfig() {
let accessToken: string | undefined;
try {
accessToken = await this.host.harness.auth.getCachedAccessToken();
} catch {
// Facade unavailable (test doubles) — never fetch.
return undefined;
}
// The endpoint is public: apiKey-only users fetch anonymously.
return getCacheHintConfig({ accessToken });
}
private async showDialog(
scene: 'resume' | 'idle',
decision: HintDecision,
stashed: StashedSubmit | undefined,
): Promise<void> {
const { host } = this;
host.track('cache_hint_shown', {
scene,
model: host.state.appState.model,
idle_seconds: decision.idleSeconds,
total_tokens: decision.totalTokens,
});
const action = await new Promise<CacheHintAction | 'dismiss'>((resolve) => {
host.state.activeDialog = 'cache-hint';
host.mountEditorReplacement(
new CacheHintDialogComponent({
idleSeconds: decision.idleSeconds,
totalTokens: decision.totalTokens,
onSelect: (a) => {
resolve(a);
},
onCancel: () => {
resolve('dismiss');
},
}),
);
});
host.state.activeDialog = null;
host.restoreEditor();
host.track('cache_hint_action', { action, scene });
await this.runAction(action, stashed);
}
private async runAction(
action: CacheHintAction | 'dismiss',
stashed: StashedSubmit | undefined,
): Promise<void> {
const { host } = this;
const restoreInput = () => {
this.lastDialogRestored = true;
this.restoreStashedInput(stashed?.text);
};
switch (action) {
case 'dismiss':
restoreInput();
return;
case 'never':
host.setAppState({ cacheExpiryHint: false });
try {
await saveTuiConfig({ ...currentTuiConfig(host), cacheExpiryHint: false });
} catch {
host.showError('Failed to save the tui.toml preference.');
}
break;
case 'compact': {
const session = host.session;
if (session !== undefined) {
try {
await session.compact({});
} catch (error) {
host.showError(`Compact failed: ${formatErrorMessage(error)}`);
restoreInput();
return;
}
if (stashed !== undefined) {
// compact() is trigger-only — the engine engages asynchronously.
// Wait for the engagement barrier so the resend lands in the
// queue and drains automatically when compaction finishes.
if (!(await this.waitForCompactionStart())) {
host.showError('Compact did not start; message not sent.');
restoreInput();
return;
}
}
}
break;
}
case 'new': {
const previousId = host.state.appState.sessionId;
await host.createNewSession();
if (host.state.appState.sessionId === previousId) {
// Creation failed (error already surfaced); keep the input for retry.
restoreInput();
return;
}
break;
}
case 'continue':
break;
}
this.lastDialogRestored = false;
if (stashed !== undefined) await host.sendNormalUserInput(stashed.text, stashed.extraction);
}
/** Bounded wait for the engine to flip `isCompacting` after a compact RPC. */
private async waitForCompactionStart(timeoutMs = 3000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (this.host.state.appState.isCompacting) return true;
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
return false;
}
}

View file

@ -28,6 +28,7 @@ import type {
TurnStepCompletedEvent,
TurnStepInterruptedEvent,
TurnStepStartedEvent,
TokenUsage,
WarningEvent,
} from '@moonshot-ai/kimi-code-sdk';
@ -104,6 +105,9 @@ export interface SessionEventHost {
showNotice(title: string, detail?: string): void;
updateActivityPane(): void;
track(event: string, props?: Record<string, unknown>): void;
recordSessionActivity(): void;
noteStepUsage(usage: TokenUsage | undefined): void;
noteCompactionFinished(): void;
mountEditorReplacement(panel: Component & Focusable): void;
restoreEditor(): void;
restoreInputText(text: string): void;
@ -365,6 +369,7 @@ export class SessionEventHandler {
}
this.host.streamingUI.resetToolUi();
this.host.streamingUI.finalizeTurn(sendQueued);
this.host.recordSessionActivity();
this.renderPendingModelBlockedFallback();
this.currentTurnHasAssistantText = false;
this.goalCompletionTurnEnded = true;
@ -405,6 +410,7 @@ export class SessionEventHandler {
private handleStepCompleted(event: TurnStepCompletedEvent): void {
this.host.streamingUI.flushNow();
this.host.noteStepUsage(event.usage);
this.maybeShowDebugTiming(event);
if (event.providerFinishReason === 'filtered') {
@ -1041,6 +1047,12 @@ export class SessionEventHandler {
event.result.tokensAfter,
event.result.summary,
);
// A completed compaction just refreshed and shrank the cached context —
// count it as activity so the next submit isn't judged against the
// pre-compaction timestamp, and reset the cache-break baseline (the drop
// is expected). Cancellations do neither: the context was not cut.
this.host.recordSessionActivity();
this.host.noteCompactionFinished();
this.finishCompaction(sendQueued);
}

View file

@ -14,6 +14,7 @@ import type {
PromptPart,
Session,
SkillSummary,
TokenUsage,
WorkspaceTrustInfo,
} from '@moonshot-ai/kimi-code-sdk';
import type { MigrationPlan } from '@moonshot-ai/migration-legacy';
@ -49,6 +50,7 @@ import {
type SkillListSession,
} from './commands';
import * as slashCommands from './commands/dispatch';
import { CacheHintController } from './controllers/cache-hint-controller';
import { BannerComponent } from './components/chrome/banner';
import { DeviceCodeBoxComponent } from './components/chrome/device-code-box';
import { GutterContainer } from './components/chrome/gutter-container';
@ -144,6 +146,7 @@ import { formatErrorMessage } from './utils/event-payload';
import { pickForegroundTasks } from './utils/foreground-task';
import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store';
import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder';
import type { ExtractionResult } from './utils/image-placeholder';
import { installInputLatencyProbe } from './utils/input-latency';
import { startupTrace } from '#/utils/startup-trace';
import { REPLAY_TURN_LIMIT } from './utils/message-replay';
@ -242,6 +245,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
version: input.version,
editorCommand: input.tuiConfig.editorCommand,
disablePasteBurst: input.tuiConfig.disablePasteBurst,
cacheExpiryHint: input.tuiConfig.cacheExpiryHint,
notifications: input.tuiConfig.notifications,
upgrade: input.tuiConfig.upgrade,
statusLine: input.tuiConfig.statusLine,
@ -314,6 +318,7 @@ export class KimiTUI {
state: TUIState;
/** In-flight lazy session creation (v2 engine), shared by concurrent first-use triggers. */
private ensureSessionPromise: Promise<Session | undefined> | null = null;
private readonly cacheHint = new CacheHintController(this);
private readonly approvalController = new ApprovalController();
private readonly questionController = new QuestionController();
private readonly reverseRpcDisposers: Array<() => void> = [];
@ -774,6 +779,9 @@ export class KimiTUI {
this.sessionEventHandler.startSubscription();
void this.showSessionWarnings(this.session);
}
if (shouldReplayHistory) {
void this.cacheHint.maybeShowOnResume();
}
void this.fetchSessions();
if (this.session !== undefined) {
this.updateTerminalTitle();
@ -1240,7 +1248,7 @@ export class KimiTUI {
this.updateQueueDisplay();
}
async sendNormalUserInput(text: string): Promise<void> {
async sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise<void> {
if (this.btwPanelController.sendUserInput(text)) return;
if (this.state.appState.model.trim().length === 0) {
this.showError(LLM_NOT_SET_MESSAGE);
@ -1251,7 +1259,11 @@ export class KimiTUI {
// Pasted videos are copied into the cache and expand to a `file://`
// `video_url` part; the engine resolves (uploads or degrades) them
// inside the turn, so submission stays fully synchronous.
extraction = extractMediaAttachments(text, this.imageStore);
//
// A cache-hint-swallowed resend passes its pre-dialog extraction back
// in: the image store may already be cleared (e.g. after "Start a new
// session"), so re-extracting from the text would lose the media.
extraction = preExtracted ?? extractMediaAttachments(text, this.imageStore);
} catch (error) {
// A video cache copy failed (unwritable cache dir, vanished source…);
// nothing was dispatched.
@ -1259,6 +1271,10 @@ export class KimiTUI {
return;
}
if (!this.validateMediaCapabilities(extraction)) return;
// Idle cache-hint interception sits before session creation; it is
// synchronous unless a hint actually fires, keeping the send path
// await-free up to sendMessage.
if (this.cacheHint.maybeInterceptOnSubmit(text, extraction)) return;
let session = this.session;
if (session === undefined) {
if (!this.engineV2) {
@ -1368,6 +1384,7 @@ export class KimiTUI {
}
beginSessionRequest(): void {
this.cacheHint.onTurnBegin();
this.streamingUI.setTurnId(undefined);
this.streamingUI.resetLiveText();
this.streamingUI.resetToolUi();
@ -1716,6 +1733,8 @@ export class KimiTUI {
}
private async createSessionFromCurrentState(bindStartupAgent = false): Promise<Session> {
// Background warm-up of the cache-hint config on every new session.
this.cacheHint.refreshConfigInBackground();
const model = this.state.appState.model.trim();
if (model.length === 0) {
throw new Error(LLM_NOT_SET_MESSAGE);
@ -1965,6 +1984,7 @@ export class KimiTUI {
resetSessionRuntime(): void {
this.aborted = false;
this.cacheHint.resetRuntime();
this.streamingUI.discardPending();
this.state.queuedMessages = [];
this.state.swarmModeEntry = undefined;
@ -2054,6 +2074,7 @@ export class KimiTUI {
}
this.showStatus(statusMessage);
void this.showSessionWarnings(session);
void this.cacheHint.maybeShowOnResume();
}
async reloadCurrentSessionView(session: Session, statusMessage: string): Promise<void> {
@ -3069,6 +3090,26 @@ export class KimiTUI {
this.state.ui.requestRender();
}
/** Latest in-process LLM round-trip; feeds the idle cache-hint scenario. */
recordSessionActivity(): void {
this.cacheHint.recordActivity();
}
/** Per-step usage for the client-side cache-break detector. */
noteStepUsage(usage: TokenUsage | undefined): void {
this.cacheHint.noteStepUsage(usage);
}
/** Compaction shrinks the cached prefix — reset the cache-break baseline. */
noteCompactionFinished(): void {
this.cacheHint.resetCacheBreakBaseline();
}
/** /undo cut the context — the next step's cache drop is expected. */
noteContextCut(): void {
this.cacheHint.resetCacheBreakBaseline();
}
private async runMigrationScreen(plan: MigrationPlan): Promise<MigrationScreenResult> {
const result = await new Promise<MigrationScreenResult>((resolve) => {
const screen = new MigrationScreenComponent({

View file

@ -48,7 +48,7 @@ export interface TUIState {
sessions: SessionRow[];
loadingSessions: boolean;
sessionsScope: 'cwd' | 'all';
activeDialog: 'session-picker' | 'help' | 'trust-prompt' | null;
activeDialog: 'session-picker' | 'help' | 'trust-prompt' | 'cache-hint' | null;
tasksBrowser: TasksBrowserState | undefined;
externalEditorRunning: boolean;
queuedMessages: QueuedMessage[];

View file

@ -69,6 +69,8 @@ export interface AppState {
editorCommand: string | null;
/** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */
disablePasteBurst?: boolean;
/** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */
cacheExpiryHint?: boolean;
notifications: NotificationsConfig;
upgrade: UpgradePreferences;
/** Footer status line customization from tui.toml; absent means the default layout. */

View file

@ -0,0 +1,52 @@
import type { CacheHintConfig } from '#/utils/cache-hint-config';
export interface CacheHintInput {
/** Current time, epoch ms. */
readonly now: number;
/** Last session activity, epoch ms. Missing → skip. */
readonly lastActiveAt?: number;
/** Current context size in tokens. Missing → skip (no local estimation). */
readonly totalTokens?: number;
/** Upstream model ID used to look up the rule. Missing/unconfigured → skip. */
readonly modelId?: string;
/** Fetch failure → undefined → skip. */
readonly config?: CacheHintConfig;
/** User chose "Don't ask me again" (tui.toml). */
readonly dismissed: boolean;
}
export type CacheHintDecision =
| { readonly kind: 'skip' }
| { readonly kind: 'hint'; readonly idleSeconds: number; readonly totalTokens: number };
/**
* Shared trigger rule for both the resume and the idle scenarios. Every
* missing-data branch skips false negatives are acceptable, false positives
* are not.
*/
export function evaluateCacheHint(input: CacheHintInput): CacheHintDecision {
if (input.dismissed) return { kind: 'skip' };
const { config, modelId, lastActiveAt, totalTokens } = input;
if (config === undefined || modelId === undefined) return { kind: 'skip' };
if (lastActiveAt === undefined || totalTokens === undefined) return { kind: 'skip' };
const rule = config.config[modelId];
if (rule === undefined) return { kind: 'skip' };
const idleMs = input.now - lastActiveAt;
if (idleMs <= rule.cache_duration * 1000) return { kind: 'skip' };
if (totalTokens < rule.min_tokens_to_hint) return { kind: 'skip' };
return { kind: 'hint', idleSeconds: Math.floor(idleMs / 1000), totalTokens };
}
/** `45m` / `3h 20m` / `26d 22h`. */
export function formatIdleDuration(idleSeconds: number): string {
const minutes = Math.max(1, Math.floor(idleSeconds / 60));
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) {
const restMinutes = minutes % 60;
return restMinutes === 0 ? `${hours}h` : `${hours}h ${restMinutes}m`;
}
const days = Math.floor(hours / 24);
const restHours = hours % 24;
return restHours === 0 ? `${days}d` : `${days}d ${restHours}h`;
}

View file

@ -0,0 +1,52 @@
import { z } from 'zod';
import {
getClientConfig,
peekClientConfig,
resetClientConfigCache,
type ClientConfigFetchOptions,
} from '#/utils/client-configs';
/** The cache-hint rules are one named config on the client-configs endpoint. */
const CONFIG_NAME = 'estimated_cache_duration';
const cacheHintModelRuleSchema = z.object({
min_tokens_to_hint: z.number(),
cache_duration: z.number(),
});
const cacheHintConfigSchema = z.object({
version: z.literal(1),
config: z.record(z.string(), cacheHintModelRuleSchema),
});
export type CacheHintConfig = z.infer<typeof cacheHintConfigSchema>;
export type CacheHintConfigFetchOptions = ClientConfigFetchOptions;
/**
* Returns the cache-hint config, preferring the cache (1 day, persisted
* across restarts). Any failure resolves to `undefined` callers treat
* that as "do not hint".
*/
export async function getCacheHintConfig(
options: CacheHintConfigFetchOptions = {},
): Promise<CacheHintConfig | undefined> {
return getClientConfig(CONFIG_NAME, cacheHintConfigSchema, options);
}
/** Fire-and-forget refresh, e.g. on new-session creation. Never throws. */
export function refreshCacheHintConfigInBackground(
options: CacheHintConfigFetchOptions = {},
): void {
void getCacheHintConfig(options).catch(() => undefined);
}
/** Synchronous peek at the fresh cache; undefined when missing or stale. */
export function peekCacheHintConfig(now?: number): CacheHintConfig | undefined {
return peekClientConfig(CONFIG_NAME, cacheHintConfigSchema, now);
}
/** Test hook: drop the in-process cache. */
export function resetCacheHintConfigCache(): void {
resetClientConfigCache(CONFIG_NAME);
}

View file

@ -0,0 +1,187 @@
import { join } from 'node:path';
import { kimiCodeBaseUrl } from '@moonshot-ai/kimi-code-oauth';
import { z } from 'zod';
import { getCacheDir } from '#/utils/paths';
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
/**
* Generic client for the public client-configs endpoint:
* `POST {kimiCodeBaseUrl}/client_configs {"name": "<config name>"}` returns
* `{ name, config: <payload> }`, where the payload shape is config-specific
* and validated by the caller-supplied schema.
*
* Each named config is cached for a day, in two layers: an in-process map
* (the only layer the synchronous peek can see) and a JSON file under the
* CLI cache dir (survives restarts, so the TTL holds across processes). An
* entry missing/stale in both layers triggers a refetch. Any failure
* resolves to `undefined` callers treat that as "config unavailable" and
* degrade quietly.
*/
const CLIENT_CONFIGS_PATH = '/client_configs';
/** Cache validity per config name: 1 day. */
const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 5000;
export interface ClientConfigFetchOptions {
/** Managed OAuth token; sent as Bearer when present. The endpoint is
* public, so anonymous fetches work too. */
readonly accessToken?: string;
/** Test hook. */
readonly fetchImpl?: typeof fetch;
/** Test hook. */
readonly now?: number;
/** Test hook: override the cache file path, or null to skip the disk
* layer entirely. */
readonly cacheFile?: string | null;
}
const cache = new Map<string, { readonly fetchedAt: number; readonly data: unknown }>();
const cacheFileEnvelopeSchema = z.object({
version: z.literal(1),
fetchedAt: z.number(),
config: z.unknown(),
});
function cacheFileFor(name: string, options: ClientConfigFetchOptions): string | undefined {
if (options.cacheFile === null) return undefined;
if (options.cacheFile !== undefined) return options.cacheFile;
return join(getCacheDir(), 'client-configs', `${name.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}.json`);
}
/** Fresh disk entry, or undefined when missing/stale/invalid. */
async function readDiskCache<S extends z.ZodType>(
file: string,
schema: S,
now: number,
): Promise<{ readonly fetchedAt: number; readonly data: z.infer<S> } | undefined> {
let envelope: z.infer<typeof cacheFileEnvelopeSchema>;
try {
// The sentinel's fetchedAt=0 reads as stale, i.e. missing.
envelope = await readJsonFile(file, cacheFileEnvelopeSchema, {
version: 1,
fetchedAt: 0,
config: undefined,
});
} catch {
return undefined; // malformed cache file — treat as missing
}
if (now - envelope.fetchedAt >= CONFIG_CACHE_TTL_MS) return undefined;
const parsed = schema.safeParse(envelope.config);
return parsed.success
? { fetchedAt: envelope.fetchedAt, data: parsed.data as z.infer<S> }
: undefined;
}
/** Best-effort persist; a cache write failure must never break the caller. */
async function writeDiskCache(file: string, data: unknown, now: number): Promise<void> {
try {
await writeJsonFile(file, cacheFileEnvelopeSchema, {
version: 1,
fetchedAt: now,
config: data,
});
} catch {
// A cache that cannot be written just means the next process refetches.
}
}
/** Returns the named client config, preferring the caches over the network. */
export async function getClientConfig<S extends z.ZodType>(
name: string,
schema: S,
options: ClientConfigFetchOptions = {},
): Promise<z.infer<S> | undefined> {
const now = options.now ?? Date.now();
const hit = cache.get(name);
if (hit !== undefined && now - hit.fetchedAt < CONFIG_CACHE_TTL_MS) {
return hit.data as z.infer<S>;
}
const file = cacheFileFor(name, options);
if (file !== undefined) {
const diskHit = await readDiskCache(file, schema, now);
if (diskHit !== undefined) {
// Warm the in-process layer with the original fetch time, so the entry
// still expires a day after it was actually fetched.
cache.set(name, diskHit);
return diskHit.data;
}
}
const data = await fetchClientConfig(name, schema, options);
if (data === undefined) return undefined;
cache.set(name, { fetchedAt: now, data });
if (file !== undefined) await writeDiskCache(file, data, now);
return data;
}
/** Fire-and-forget refresh of a named config. Never throws. */
export function refreshClientConfigInBackground<S extends z.ZodType>(
name: string,
schema: S,
options: ClientConfigFetchOptions = {},
): void {
void getClientConfig(name, schema, options).catch(() => undefined);
}
/**
* Synchronous peek at the fresh in-process cache; undefined when missing or
* stale. Only sees the in-process layer the disk layer is read by the
* async `getClientConfig`, which warms this layer.
*/
export function peekClientConfig<S extends z.ZodType>(
name: string,
schema: S,
now: number = Date.now(),
): z.infer<S> | undefined {
const hit = cache.get(name);
if (hit === undefined || now - hit.fetchedAt >= CONFIG_CACHE_TTL_MS) return undefined;
const parsed = schema.safeParse(hit.data);
return parsed.success ? (parsed.data as z.infer<S>) : undefined;
}
export async function fetchClientConfig<S extends z.ZodType>(
name: string,
schema: S,
options: ClientConfigFetchOptions = {},
): Promise<z.infer<S> | undefined> {
const fetchFn = options.fetchImpl ?? fetch;
const headers: Record<string, string> = {
accept: 'application/json',
'content-type': 'application/json',
};
if (options.accessToken !== undefined) {
headers['authorization'] = `Bearer ${options.accessToken}`;
}
try {
const response = await fetchFn(`${kimiCodeBaseUrl()}${CLIENT_CONFIGS_PATH}`, {
method: 'POST',
headers,
body: JSON.stringify({ name }),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return undefined;
const body: unknown = await response.json();
if (typeof body !== 'object' || body === null) return undefined;
const envelope = body as Record<string, unknown>;
if (envelope['name'] !== name) return undefined;
const parsed = schema.safeParse(envelope['config']);
return parsed.success ? (parsed.data as z.infer<S>) : undefined;
} catch {
return undefined;
}
}
/**
* Test hook: drop one or all in-process cached configs. Disk files in tests
* are isolated via the `cacheFile` option.
*/
export function resetClientConfigCache(name?: string): void {
if (name === undefined) {
cache.clear();
} else {
cache.delete(name);
}
}

View file

@ -34,6 +34,7 @@ describe('reload slash commands', () => {
it('reloads tui.toml without touching Core session state', async () => {
await writeTuiConfig(`
theme = "light"
cache_expiry_hint = false
[editor]
command = "vim"
@ -56,6 +57,7 @@ auto_install = false
expect(host.state.appState).toMatchObject({
theme: 'light',
editorCommand: 'vim',
cacheExpiryHint: false,
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
});

View file

@ -43,8 +43,10 @@ describe('update preference commands', () => {
theme: 'auto',
editorCommand: null,
disablePasteBurst: false,
cacheExpiryHint: true,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: false },
statusLine: { items: null, command: null },
});
expect(setAppState).toHaveBeenCalledWith({ upgrade: { autoInstall: false } });
expect(track).toHaveBeenCalledWith('upgrade_preference_changed', { auto_install: false });

View file

@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import { CacheHintDialogComponent } from '#/tui/components/dialogs/cache-hint-dialog';
const ANSI_SGR = /\[[0-9;]*m/g;
function strip(text: string): string {
return text.replaceAll(ANSI_SGR, '');
}
function renderDialog(idleSeconds = (26 * 24 + 22) * 3600, totalTokens = 293000) {
const onSelect = vi.fn();
const onCancel = vi.fn();
const dialog = new CacheHintDialogComponent({ idleSeconds, totalTokens, onSelect, onCancel });
return { dialog, onSelect, onCancel, lines: dialog.render(120).map(strip) };
}
describe('CacheHintDialogComponent', () => {
it('renders the title with idle duration and token count', () => {
const { lines } = renderDialog();
expect(
lines.some((l) => l.includes('This session has been idle for 26d 22h and is ~286k tokens.')),
).toBe(true);
});
it('renders the body line and the standard hint vocabulary', () => {
const { lines } = renderDialog();
expect(
lines.some((l) =>
l.includes('Cache expired — the next message re-sends the entire history at full price.'),
),
).toBe(true);
const titleIdx = lines.findIndex((l) => l.includes('This session has been idle'));
expect(lines[titleIdx + 1]).toContain('↑↓ navigate');
expect(lines[titleIdx + 1]).toContain('Enter select');
expect(lines[titleIdx + 1]).toContain('Esc cancel');
});
it('renders all four options in order with right-column descriptions', () => {
const { lines } = renderDialog();
const compact = lines.findIndex((l) => l.includes('Compact and continue'));
const fresh = lines.findIndex((l) => l.includes('Start a new session'));
const asIs = lines.findIndex((l) => l.includes('Continue as-is'));
const never = lines.findIndex((l) => l.includes("Don't ask me again"));
expect(compact).toBeGreaterThanOrEqual(0);
expect(compact).toBeLessThan(fresh);
expect(fresh).toBeLessThan(asIs);
expect(asIs).toBeLessThan(never);
expect(lines[compact]).toContain('one-time compact cost · cheapest way to keep this topic');
expect(lines[fresh]).toContain('zero context cost · best for a new task');
expect(lines[asIs]).toContain('full history kept · highest cost per turn');
});
it('aligns description columns across options', () => {
const { lines } = renderDialog();
const colOf = (labelNeedle: string, descNeedle: string) => {
const line = lines.find((l) => l.includes(labelNeedle));
expect(line).toBeDefined();
return line!.indexOf(descNeedle);
};
const reference = colOf('Compact and continue', 'one-time compact cost');
expect(colOf('Start a new session', 'zero context cost')).toBe(reference);
expect(colOf('Continue as-is', 'full history kept')).toBe(reference);
});
it('selects the highlighted option on Enter, compact by default', () => {
const { dialog, onSelect } = renderDialog();
dialog.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith('compact');
});
it('navigates with arrows before selecting', () => {
const { dialog, onSelect } = renderDialog();
dialog.handleInput('\u001B[B'); // down
dialog.handleInput('\u001B[B'); // down
dialog.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith('continue');
});
it('cancels on Esc without selecting', () => {
const { dialog, onSelect, onCancel } = renderDialog();
dialog.handleInput('\u001B');
expect(onCancel).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
});

View file

@ -34,6 +34,7 @@ describe('TUI config', () => {
const text = readFileSync(filePath, 'utf-8');
expect(text).toContain('Client preferences for kimi-code.');
expect(text).toContain('theme = "auto"');
expect(text).toContain('cache_expiry_hint = true');
expect(text).toContain('command = ""');
expect(text).toContain('[upgrade]');
expect(text).toContain('auto_install = true');
@ -60,6 +61,7 @@ auto_install = false
expect(config).toEqual({
theme: 'light',
disablePasteBurst: false,
cacheExpiryHint: true,
editorCommand: 'code --wait',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
@ -76,6 +78,15 @@ disable_paste_burst = true
expect(config.disablePasteBurst).toBe(true);
});
it('parses cache_expiry_hint', () => {
const config = parseTuiConfig(`
theme = "dark"
cache_expiry_hint = false
`);
expect(config.cacheExpiryHint).toBe(false);
});
it('normalizes an empty editor command to auto-detect', () => {
const config = parseTuiConfig(`
[editor]
@ -85,6 +96,7 @@ command = " "
expect(config).toEqual({
theme: 'auto',
disablePasteBurst: false,
cacheExpiryHint: true,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },
@ -118,6 +130,7 @@ command = " "
{
theme: 'light',
disablePasteBurst: false,
cacheExpiryHint: true,
editorCommand: 'vim',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
@ -129,6 +142,7 @@ command = " "
expect(await loadTuiConfig(filePath)).toEqual({
theme: 'light',
disablePasteBurst: false,
cacheExpiryHint: true,
editorCommand: 'vim',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
@ -142,6 +156,7 @@ command = " "
{
theme,
disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst,
cacheExpiryHint: DEFAULT_TUI_CONFIG.cacheExpiryHint,
editorCommand: null,
notifications: DEFAULT_TUI_CONFIG.notifications,
upgrade: DEFAULT_TUI_CONFIG.upgrade,

View file

@ -0,0 +1,662 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
CacheHintController,
type CacheHintHost,
} from '#/tui/controllers/cache-hint-controller';
import type { CacheHintConfig } from '#/utils/cache-hint-config';
const peekMock = vi.fn<() => CacheHintConfig | undefined>(() => undefined);
const getMock = vi.fn(async (): Promise<CacheHintConfig | undefined> => undefined);
vi.mock('#/utils/cache-hint-config', () => ({
peekCacheHintConfig: () => peekMock(),
getCacheHintConfig: (...args: unknown[]) => getMock(...(args as [])),
refreshCacheHintConfigInBackground: () => undefined,
resetCacheHintConfigCache: () => undefined,
}));
const CONFIG: CacheHintConfig = {
version: 1,
config: { 'kimi-k2': { min_tokens_to_hint: 100000, cache_duration: 600 } },
};
function makeHost(
overrides: {
session?: unknown;
appState?: Record<string, unknown>;
createNewSessionFails?: boolean;
} = {},
) {
const state = {
activeDialog: null as string | null,
appState: {
model: 'k2',
availableModels: { k2: { model: 'kimi-k2', provider: 'managed:kimi-code' } },
availableProviders: { 'managed:kimi-code': { oauth: { key: 'kimi-code' } } },
sessionId: 's1',
streamingPhase: 'idle',
isCompacting: false,
contextTokens: 150000,
cacheExpiryHint: true,
...overrides.appState,
},
};
const host: CacheHintHost = {
engineV2: true,
harness: { auth: { getCachedAccessToken: vi.fn(async () => 'tok') } } as never,
session: (overrides.session ?? { id: 's1' }) as never,
state: state as never,
track: vi.fn(),
setAppState: vi.fn((patch) => Object.assign(state.appState, patch)),
mountEditorReplacement: vi.fn(),
restoreEditor: vi.fn(),
restoreInputText: vi.fn(),
showError: vi.fn(),
createNewSession: vi.fn(async () => {
if (overrides.createNewSessionFails !== true) state.appState.sessionId = 's2';
}),
sendNormalUserInput: vi.fn(async () => undefined),
};
return { host, state };
}
function resumeSession(replayTimes: number[], tokenCount: number, updatedAt = 0) {
return {
id: 's1',
summary: { updatedAt },
getResumeState: () => ({
agents: {
main: {
replay: replayTimes.map((time) => ({ type: 'message', time })),
context: { tokenCount },
},
},
}),
};
}
async function flush(times = 20): Promise<void> {
for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r));
}
beforeEach(() => {
peekMock.mockReset().mockReturnValue(undefined);
getMock.mockReset().mockResolvedValue(undefined);
vi.useRealTimers();
});
describe('CacheHintController scenario 2 (idle submit)', () => {
it('does not intercept a fresh submit (no activity baseline)', () => {
const { host } = makeHost();
const controller = new CacheHintController(host);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(false);
});
it('does not intercept when idle for less than the coarse floor', () => {
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
expect(controller.maybeInterceptOnSubmit('hello')).toBe(false);
expect(peekMock).not.toHaveBeenCalled();
});
it('does not intercept when the provider is not OAuth-managed', () => {
peekMock.mockReturnValue(CONFIG);
const { host } = makeHost({
appState: {
availableProviders: { 'managed:kimi-code': {} }, // apiKey form: no oauth
},
});
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(false);
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
vi.restoreAllMocks();
});
it('does not cold-fetch for providers that can never match a rule', async () => {
// Config cache cold (peek returns undefined by default): without the
// applicability gate this submit would be swallowed for a fetch that can
// never produce a hint.
const { host } = makeHost({
appState: {
availableProviders: { 'managed:kimi-code': {} }, // apiKey form: no oauth
},
});
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(false);
await flush();
expect(getMock).not.toHaveBeenCalled();
vi.restoreAllMocks();
});
it('swallows a cold-cache submit, fetches, and releases when no rule matches', async () => {
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
await flush();
expect(getMock).toHaveBeenCalled();
// Fetch resolved without a matching rule → the message is released.
expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined);
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
vi.restoreAllMocks();
});
it('fetches on a cold-cache submit and shows the dialog when a rule matches', async () => {
getMock.mockResolvedValue(CONFIG);
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
await vi.waitFor(() => {
expect(host.mountEditorReplacement).toHaveBeenCalled();
});
expect(host.track).toHaveBeenCalledWith(
'cache_hint_shown',
expect.objectContaining({ scene: 'idle' }),
);
vi.restoreAllMocks();
});
it('serializes cold-cache submits so they keep their order', async () => {
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
expect(controller.maybeInterceptOnSubmit('world')).toBe(true);
await flush();
vi.restoreAllMocks();
expect(host.sendNormalUserInput).toHaveBeenCalledTimes(2);
expect(
(host.sendNormalUserInput as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]),
).toEqual(['hello', 'world']);
});
it('restores chained submits instead of sending when the dialog is dismissed', async () => {
getMock.mockResolvedValue(CONFIG);
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
expect(controller.maybeInterceptOnSubmit('world')).toBe(true);
await vi.waitFor(() => {
expect(host.mountEditorReplacement).toHaveBeenCalled();
});
vi.restoreAllMocks();
const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as {
handleInput: (data: string) => void;
};
dialog.handleInput('\u001B'); // dismiss the first dialog
await flush();
// Nothing was sent; both inputs are back in the editor, newline-joined.
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
expect(host.restoreInputText).toHaveBeenLastCalledWith('hello\nworld');
});
it('hands the stashed input back when the session switched during the fetch', async () => {
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
(host as unknown as { session: unknown }).session = { id: 's2' };
await flush();
vi.restoreAllMocks();
expect(host.restoreInputText).toHaveBeenCalledWith('hello');
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});
it('releases instead of mounting when a foreground operation started during the fetch', async () => {
getMock.mockResolvedValue(CONFIG);
const { host, state } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
// A foreground operation (turn / /compact) kicked off mid-fetch.
state.appState.streamingPhase = 'waiting';
await flush();
vi.restoreAllMocks();
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined);
});
it('intercepts and shows the dialog when all conditions hold', () => {
peekMock.mockReturnValue(CONFIG);
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
expect(host.mountEditorReplacement).toHaveBeenCalledOnce();
expect(host.track).toHaveBeenCalledWith(
'cache_hint_shown',
expect.objectContaining({ scene: 'idle', model: 'k2' }),
);
vi.restoreAllMocks();
});
it('does not advance the cache baseline at turn begin (the prompt may fail pre-model)', () => {
peekMock.mockReturnValue(CONFIG);
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
// A send begins a turn but fails before any model request — the expired
// baseline must survive, so the retry still intercepts.
controller.onTurnBegin();
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
vi.restoreAllMocks();
});
it('does not intercept twice in the same idle cycle', () => {
peekMock.mockReturnValue(CONFIG);
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
expect(controller.maybeInterceptOnSubmit('again')).toBe(false);
vi.restoreAllMocks();
});
it('resends the stashed input on continue', async () => {
peekMock.mockReturnValue(CONFIG);
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
controller.maybeInterceptOnSubmit('hello');
vi.restoreAllMocks();
const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as {
handleInput: (data: string) => void;
};
dialog.handleInput('\u001B[B'); // down → new
dialog.handleInput('\u001B[B'); // down → continue
dialog.handleInput('\r');
await flush();
expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined);
expect(host.track).toHaveBeenCalledWith('cache_hint_action', {
action: 'continue',
scene: 'idle',
});
});
it('restores the input on Esc without sending', async () => {
peekMock.mockReturnValue(CONFIG);
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
controller.maybeInterceptOnSubmit('hello');
vi.restoreAllMocks();
const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as {
handleInput: (data: string) => void;
};
dialog.handleInput('\u001B');
await flush();
expect(host.restoreInputText).toHaveBeenCalledWith('hello');
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});
it('compacts then resends once compaction engages', async () => {
peekMock.mockReturnValue(CONFIG);
const compact = vi.fn(async () => undefined);
const { host, state } = makeHost({ session: { id: 's1', compact } });
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
controller.maybeInterceptOnSubmit('hello');
vi.restoreAllMocks();
const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as {
handleInput: (data: string) => void;
};
dialog.handleInput('\r'); // compact (default)
// The engine flips isCompacting asynchronously via the started event.
setTimeout(() => {
state.appState.isCompacting = true;
}, 10);
await vi.waitFor(() => {
expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined);
});
expect(compact).toHaveBeenCalledWith({});
});
it('starts a new session and resends', async () => {
peekMock.mockReturnValue(CONFIG);
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
controller.maybeInterceptOnSubmit('hello');
vi.restoreAllMocks();
const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as {
handleInput: (data: string) => void;
};
dialog.handleInput('\u001B[B'); // down → new
dialog.handleInput('\r');
await flush();
expect(host.createNewSession).toHaveBeenCalled();
expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined);
});
it('keeps the input when new-session creation fails', async () => {
peekMock.mockReturnValue(CONFIG);
const { host, state } = makeHost({ createNewSessionFails: true });
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
controller.maybeInterceptOnSubmit('hello');
vi.restoreAllMocks();
const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as {
handleInput: (data: string) => void;
};
dialog.handleInput('\u001B[B');
dialog.handleInput('\r');
await flush();
expect(state.appState.sessionId).toBe('s1');
expect(host.restoreInputText).toHaveBeenCalledWith('hello');
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});
});
describe('CacheHintController cache-break detection', () => {
const u = (inputCacheRead: number) => ({
inputOther: 100,
output: 50,
inputCacheRead,
inputCacheCreation: 0,
});
it('does not judge the first measured step', () => {
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.noteStepUsage(u(10000));
expect(host.track).not.toHaveBeenCalled();
});
it('records cache activity on a completed step, even one without usage', () => {
peekMock.mockReturnValue(CONFIG);
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
// 20 min later a step completes — the provider round trip refreshed the
// server-side cache…
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
controller.noteStepUsage(undefined);
// …so a submit right after is fresh and must not be intercepted.
expect(controller.maybeInterceptOnSubmit('hello')).toBe(false);
vi.restoreAllMocks();
});
it('reports a drop beyond the ratio and token gates with both usages', () => {
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.noteStepUsage(u(10000));
controller.noteStepUsage(u(7000));
expect(host.track).toHaveBeenCalledWith(
'cache_break_detected',
expect.objectContaining({
prev_model: 'k2',
curr_model: 'k2',
prev_input_cache_read: 10000,
curr_input_cache_read: 7000,
cache_read_drop_ratio: 0.3,
}),
);
});
it('stays quiet within the ratio gate or under the token threshold', () => {
const a = makeHost();
const controllerA = new CacheHintController(a.host);
controllerA.noteStepUsage(u(100000));
controllerA.noteStepUsage(u(96000)); // 4% drop — inside the ratio gate
expect(a.host.track).not.toHaveBeenCalled();
const b = makeHost();
const controllerB = new CacheHintController(b.host);
controllerB.noteStepUsage(u(4000));
controllerB.noteStepUsage(u(2500)); // drop 1500 ≤ 2000 — under the token threshold
expect(b.host.track).not.toHaveBeenCalled();
});
it('skips unmeasured usage without touching the baseline', () => {
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.noteStepUsage(u(10000));
controller.noteStepUsage(undefined);
controller.noteStepUsage({ inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 });
controller.noteStepUsage(u(5000));
expect(host.track).toHaveBeenCalledWith(
'cache_break_detected',
expect.objectContaining({ prev_input_cache_read: 10000, curr_input_cache_read: 5000 }),
);
});
it('resets the baseline on compaction, but records a mid-session model/effort switch', () => {
const { host, state } = makeHost();
const controller = new CacheHintController(host);
controller.noteStepUsage(u(10000));
controller.resetCacheBreakBaseline();
controller.noteStepUsage(u(100)); // post-compaction drop is expected
expect(host.track).not.toHaveBeenCalled();
// A model switch busts the cache key — that drop IS the signal to record.
controller.noteStepUsage(u(10000));
state.appState.model = 'other-model';
controller.noteStepUsage(u(100));
expect(host.track).toHaveBeenCalledWith(
'cache_break_detected',
expect.objectContaining({
prev_model: 'k2',
curr_model: 'other-model',
prev_input_cache_read: 10000,
curr_input_cache_read: 100,
}),
);
});
});
describe('CacheHintController scenario 1 (resume)', () => {
/** maybeShowOnResume awaits the user's choice; dismiss the dialog once mounted. */
async function showOnResumeAndDismiss(
controller: CacheHintController,
host: CacheHintHost,
): Promise<void> {
const pending = controller.maybeShowOnResume();
await vi.waitFor(() => {
expect(host.mountEditorReplacement).toHaveBeenCalled();
});
const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as {
handleInput: (data: string) => void;
};
dialog.handleInput('\u001B');
await pending;
}
it('shows the dialog on resume when idle beyond cache_duration', async () => {
getMock.mockResolvedValue(CONFIG);
const session = resumeSession([Date.now() - 1200_000], 150000);
const { host } = makeHost({ session });
const controller = new CacheHintController(host);
await showOnResumeAndDismiss(controller, host);
expect(host.track).toHaveBeenCalledWith(
'cache_hint_shown',
expect.objectContaining({ scene: 'resume' }),
);
});
it('shows at most once per session', async () => {
getMock.mockResolvedValue(CONFIG);
const session = resumeSession([Date.now() - 1200_000], 150000);
const { host } = makeHost({ session });
const controller = new CacheHintController(host);
await showOnResumeAndDismiss(controller, host);
await controller.maybeShowOnResume();
expect(host.mountEditorReplacement).toHaveBeenCalledOnce();
});
it('falls back to summary.updatedAt when there are no replay records', async () => {
getMock.mockResolvedValue(CONFIG);
const session = resumeSession([], 150000, Date.now() - 1200_000);
const { host } = makeHost({ session });
const controller = new CacheHintController(host);
await showOnResumeAndDismiss(controller, host);
expect(host.mountEditorReplacement).toHaveBeenCalledOnce();
});
it('drops the dialog when the session switched during the config fetch', async () => {
let resolveFetch!: (config: CacheHintConfig) => void;
getMock.mockImplementation(
() =>
new Promise<CacheHintConfig>((res) => {
resolveFetch = res;
}),
);
const session = resumeSession([Date.now() - 1200_000], 150000);
const { host } = makeHost({ session });
const controller = new CacheHintController(host);
const pending = controller.maybeShowOnResume();
await flush(); // let the fetch start (resolveFetch gets assigned)
(host as unknown as { session: unknown }).session = { id: 'other-session' };
resolveFetch(CONFIG);
await pending;
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
});
it('drops the dialog when a turn started during the config fetch', async () => {
let resolveFetch!: (config: CacheHintConfig) => void;
getMock.mockImplementation(
() =>
new Promise<CacheHintConfig>((res) => {
resolveFetch = res;
}),
);
const session = resumeSession([Date.now() - 1200_000], 150000);
const { host, state } = makeHost({ session });
const controller = new CacheHintController(host);
const pending = controller.maybeShowOnResume();
await flush();
// The user sent the first prompt while the fetch was in flight.
state.appState.streamingPhase = 'waiting';
resolveFetch(CONFIG);
await pending;
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
});
it('re-evaluates against fresh activity when a turn completed during the config fetch', async () => {
let resolveFetch!: (config: CacheHintConfig) => void;
getMock.mockImplementation(
() =>
new Promise<CacheHintConfig>((res) => {
resolveFetch = res;
}),
);
// Idle long past the window when the resume check started…
const session = resumeSession([Date.now() - 1200_000], 150000);
const { host } = makeHost({ session });
const controller = new CacheHintController(host);
const pending = controller.maybeShowOnResume();
await flush(); // let the fetch start (resolveFetch gets assigned)
// …but the user's first prompt ran to completion while the config was in
// flight, refreshing the server-side cache — the stale replay timestamp
// must not trigger the dialog.
controller.recordActivity();
resolveFetch(CONFIG);
await pending;
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
});
it('ignores local-only state records when computing the resume idle time', async () => {
getMock.mockResolvedValue(CONFIG);
const oldMessage = { type: 'message', time: Date.now() - 1200_000 };
const recentStateRecord = { type: 'permission_updated', time: Date.now() - 5000 };
const session = {
id: 's1',
summary: { updatedAt: 0 },
getResumeState: () => ({
agents: {
main: { replay: [oldMessage, recentStateRecord], context: { tokenCount: 150000 } },
},
}),
};
const { host } = makeHost({ session });
const controller = new CacheHintController(host);
// The recent permission change must not mask the expired cache.
await showOnResumeAndDismiss(controller, host);
expect(host.mountEditorReplacement).toHaveBeenCalledOnce();
});
it('skips when the config cannot be resolved', async () => {
const session = resumeSession([Date.now() - 1200_000], 150000);
const { host } = makeHost({ session });
const controller = new CacheHintController(host);
await controller.maybeShowOnResume();
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
});
it('skips small sessions below the token threshold', async () => {
getMock.mockResolvedValue(CONFIG);
const session = resumeSession([Date.now() - 1200_000], 50_000);
const { host } = makeHost({ session });
const controller = new CacheHintController(host);
await controller.maybeShowOnResume();
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
});
it('seeds the activity baseline when the resume check skips inside the cache window', async () => {
getMock.mockResolvedValue(CONFIG);
peekMock.mockReturnValue(CONFIG);
const now = Date.now();
// 9 minutes into a 10-minute cache window — nothing to show on resume.
const session = resumeSession([now - 540_000], 150000);
const { host } = makeHost({ session });
const controller = new CacheHintController(host);
await controller.maybeShowOnResume();
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
// Two minutes later the window has expired; the seeded baseline lets the
// idle-submit path catch it instead of waving the prompt through.
vi.spyOn(Date, 'now').mockReturnValue(now + 660_000);
expect(controller.maybeInterceptOnSubmit('hello')).toBe(true);
expect(host.mountEditorReplacement).toHaveBeenCalledOnce();
expect(host.track).toHaveBeenCalledWith(
'cache_hint_shown',
expect.objectContaining({ scene: 'idle' }),
);
vi.restoreAllMocks();
});
});

View file

@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest';
import { SessionEventHandler } from '#/tui/controllers/session-event-handler';
import { getBuiltInPalette } from '#/tui/theme';
function makeHost() {
const host = {
state: {
appState: {
sessionId: 's1',
streamingPhase: 'waiting',
isCompacting: true,
model: 'kimi-model',
permissionMode: 'auto',
},
queuedMessages: [],
queuedMessageDispatchPending: false,
theme: { palette: getBuiltInPalette('dark') },
toolOutputExpanded: false,
todoPanel: { getTodos: vi.fn(() => []) },
transcriptContainer: { addChild: vi.fn() },
ui: { requestRender: vi.fn() },
},
session: { id: 's1' },
aborted: false,
sessionEventUnsubscribe: undefined,
streamingUI: {
setTurnId: vi.fn(),
flushNow: vi.fn(),
resetToolUi: vi.fn(),
finalizeTurn: vi.fn(),
hasActiveTurn: vi.fn(() => false),
hasThinkingDraft: vi.fn(() => false),
flushThinkingToTranscript: vi.fn(),
appendAssistantDelta: vi.fn(),
scheduleFlush: vi.fn(),
beginCompaction: vi.fn(),
endCompaction: vi.fn(),
cancelCompaction: vi.fn(),
},
requireSession: vi.fn(),
setAppState: vi.fn((patch: Record<string, unknown>) =>
Object.assign(host.state.appState, patch),
),
patchLivePane: vi.fn(),
resetLivePane: vi.fn(),
showError: vi.fn(),
showStatus: vi.fn(),
showNotice: vi.fn(),
track: vi.fn(),
recordSessionActivity: vi.fn(),
noteStepUsage: vi.fn(),
noteCompactionFinished: vi.fn(),
mountEditorReplacement: vi.fn(),
restoreEditor: vi.fn(),
restoreInputText: vi.fn(),
appendTranscriptEntry: vi.fn(),
sendNormalUserInput: vi.fn(),
sendQueuedMessage: vi.fn(),
shiftQueuedMessage: vi.fn(),
btwPanelController: { routeEvent: vi.fn(() => false) },
tasksBrowserController: {},
};
return { host: host as any };
}
const compactionCompleted = {
type: 'compaction.completed',
sessionId: 's1',
agentId: 'main',
result: { summary: 'summary', tokensBefore: 100, tokensAfter: 10, compactedCount: 1 },
} as const;
const compactionCancelled = {
type: 'compaction.cancelled',
sessionId: 's1',
agentId: 'main',
} as const;
describe('SessionEventHandler compaction cache bookkeeping', () => {
it('records activity and resets the cache-break baseline after a completed compaction', () => {
const { host } = makeHost();
const handler = new SessionEventHandler(host);
handler.handleEvent(compactionCompleted, vi.fn());
expect(host.recordSessionActivity).toHaveBeenCalledOnce();
expect(host.noteCompactionFinished).toHaveBeenCalledOnce();
});
it('keeps both baselines after a cancelled compaction (context was not cut)', () => {
const { host } = makeHost();
const handler = new SessionEventHandler(host);
handler.handleEvent(compactionCancelled, vi.fn());
expect(host.noteCompactionFinished).not.toHaveBeenCalled();
expect(host.recordSessionActivity).not.toHaveBeenCalled();
});
});

View file

@ -86,6 +86,9 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) {
showStatus: vi.fn(),
showNotice: vi.fn(),
track: vi.fn(),
recordSessionActivity: vi.fn(),
noteStepUsage: vi.fn(),
noteCompactionFinished: vi.fn(),
mountEditorReplacement: vi.fn(),
restoreEditor: vi.fn(),
restoreInputText: vi.fn(),

View file

@ -47,6 +47,9 @@ function makeHost() {
showNotice: vi.fn(),
updateActivityPane: vi.fn(),
track: vi.fn(),
recordSessionActivity: vi.fn(),
noteStepUsage: vi.fn(),
noteCompactionFinished: vi.fn(),
mountEditorReplacement: vi.fn(),
restoreEditor: vi.fn(),
restoreInputText: vi.fn(),

View file

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import type { CacheHintConfig } from '#/utils/cache-hint-config';
import { evaluateCacheHint, formatIdleDuration } from '#/tui/utils/cache-hint';
const CONFIG: CacheHintConfig = {
version: 1,
config: {
k3: { min_tokens_to_hint: 100000, cache_duration: 600 },
},
};
const NOW = 1_800_000_000_000;
const IDLE_BEYOND_TTL = NOW - 601_000; // 601s > 600s cache_duration
function input(overrides: Partial<Parameters<typeof evaluateCacheHint>[0]> = {}) {
return {
now: NOW,
lastActiveAt: IDLE_BEYOND_TTL,
totalTokens: 150000,
modelId: 'k3',
config: CONFIG,
dismissed: false,
...overrides,
};
}
describe('evaluateCacheHint', () => {
it('hints when idle exceeds cache_duration and tokens clear the threshold', () => {
expect(evaluateCacheHint(input())).toEqual({
kind: 'hint',
idleSeconds: 601,
totalTokens: 150000,
});
});
it('skips when dismissed', () => {
expect(evaluateCacheHint(input({ dismissed: true })).kind).toBe('skip');
});
it.each([
['config', { config: undefined }],
['modelId', { modelId: undefined }],
['lastActiveAt', { lastActiveAt: undefined }],
['totalTokens', { totalTokens: undefined }],
] as const)('skips when %s is missing', (_name, overrides) => {
expect(evaluateCacheHint(input(overrides)).kind).toBe('skip');
});
it('skips when the model is not in the config', () => {
expect(evaluateCacheHint(input({ modelId: 'unknown-model' })).kind).toBe('skip');
});
it('skips at exactly cache_duration (strictly-greater rule)', () => {
expect(
evaluateCacheHint(input({ lastActiveAt: NOW - 600_000 })).kind,
).toBe('skip');
});
it('skips below the token threshold', () => {
expect(evaluateCacheHint(input({ totalTokens: 99999 })).kind).toBe('skip');
});
it('skips on clock skew (negative idle)', () => {
expect(evaluateCacheHint(input({ lastActiveAt: NOW + 60_000 })).kind).toBe('skip');
});
});
describe('formatIdleDuration', () => {
it.each([
[45 * 60, '45m'],
[60 * 60, '1h'],
[(3 * 60 + 20) * 60, '3h 20m'],
[24 * 60 * 60, '1d'],
[(2 * 24 + 4) * 60 * 60, '2d 4h'],
[(26 * 24 + 22) * 60 * 60, '26d 22h'],
])('formats %ss as %s', (seconds, expected) => {
expect(formatIdleDuration(seconds)).toBe(expected);
});
});

View file

@ -0,0 +1,356 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchClientConfig,
getClientConfig,
peekClientConfig,
resetClientConfigCache,
} from '#/utils/client-configs';
import { z } from 'zod';
const configSchema = z.object({
version: z.literal(1),
config: z.record(z.string(), z.object({ min_tokens_to_hint: z.number(), cache_duration: z.number() })),
});
const CONFIG = {
version: 1,
config: { k3: { min_tokens_to_hint: 200000, cache_duration: 600 } },
};
const ENVELOPE = { name: 'estimated_cache_duration', config: CONFIG };
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
afterEach(() => {
resetClientConfigCache();
});
describe('fetchClientConfig', () => {
it('POSTs the config name and unwraps the envelope', async () => {
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
const result = await fetchClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
});
expect(result).toEqual(CONFIG);
expect(fetchImpl).toHaveBeenCalledWith(
expect.stringContaining('/client_configs'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'estimated_cache_duration' }),
}),
);
});
it('sends the bearer token when provided, anonymous otherwise', async () => {
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
await fetchClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
accessToken: 'tok',
});
expect(fetchImpl).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ authorization: 'Bearer tok' }),
}),
);
await fetchClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
});
expect(fetchImpl).toHaveBeenLastCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.not.objectContaining({ authorization: expect.anything() }),
}),
);
});
it('returns undefined on non-OK responses', async () => {
const fetchImpl = vi.fn(async () => jsonResponse('no', 503));
await expect(
fetchClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
}),
).resolves.toBeUndefined();
});
it('returns undefined when the payload fails the caller schema', async () => {
const fetchImpl = vi.fn(async () =>
jsonResponse({ name: 'estimated_cache_duration', config: { version: 2, config: {} } }),
);
await expect(
fetchClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
}),
).resolves.toBeUndefined();
});
it('returns undefined when the envelope name does not match', async () => {
const fetchImpl = vi.fn(async () =>
jsonResponse({ name: 'some_other_config', config: CONFIG }),
);
await expect(
fetchClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
}),
).resolves.toBeUndefined();
});
it('returns undefined when fetch throws', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('offline');
});
await expect(
fetchClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
}),
).resolves.toBeUndefined();
});
});
describe('getClientConfig', () => {
it('serves the in-process cache within a day', async () => {
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
const now = Date.now();
const first = await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now,
cacheFile: null,
});
const second = await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now: now + 60_000,
cacheFile: null,
});
expect(first).toEqual(CONFIG);
expect(second).toEqual(CONFIG);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('refetches when the cache is older than a day', async () => {
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
const now = Date.now();
await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now,
cacheFile: null,
});
const result = await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now: now + 25 * 60 * 60 * 1000,
cacheFile: null,
});
expect(result).toEqual(CONFIG);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it('caches each config name independently', async () => {
const other = { name: 'other_config', config: CONFIG };
const fetchImpl = vi.fn(async (url: unknown, init?: { body?: string }) =>
jsonResponse(init?.body?.includes('other') ? other : ENVELOPE),
);
const now = Date.now();
await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now,
cacheFile: null,
});
const second = await getClientConfig('other_config', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now,
cacheFile: null,
});
expect(second).toEqual(CONFIG);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it('resolves to undefined when the refetch fails', async () => {
const fetchImpl = vi.fn(async () => jsonResponse('no', 500));
await expect(
getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
cacheFile: null,
}),
).resolves.toBeUndefined();
});
});
describe('peekClientConfig', () => {
it('returns the cached config only while fresh', async () => {
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
const now = Date.now();
await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now,
cacheFile: null,
});
expect(peekClientConfig('estimated_cache_duration', configSchema, now + 60_000)).toEqual(CONFIG);
expect(
peekClientConfig('estimated_cache_duration', configSchema, now + 25 * 60 * 60 * 1000),
).toBeUndefined();
});
it('returns undefined for a config that was never fetched', () => {
expect(peekClientConfig('estimated_cache_duration', configSchema)).toBeUndefined();
});
});
describe('getClientConfig disk cache', () => {
let dir: string;
let file: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'client-configs-'));
file = join(dir, 'estimated_cache_duration.json');
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('serves a fresh disk entry without network and warms the in-process cache', async () => {
const now = Date.now();
await writeFile(file, JSON.stringify({ version: 1, fetchedAt: now, config: CONFIG }));
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
const result = await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now: now + 60_000,
cacheFile: file,
});
expect(result).toEqual(CONFIG);
expect(fetchImpl).not.toHaveBeenCalled();
// The in-process layer was warmed with the original fetch time.
expect(peekClientConfig('estimated_cache_duration', configSchema, now + 60_000)).toEqual(CONFIG);
});
it('serves the disk entry after the in-process cache is dropped (restart)', async () => {
const now = Date.now();
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now,
cacheFile: file,
});
resetClientConfigCache();
const offline = vi.fn(async (): Promise<Response> => {
throw new Error('offline');
});
const result = await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: offline as typeof fetch,
now: now + 60_000,
cacheFile: file,
});
expect(result).toEqual(CONFIG);
expect(offline).not.toHaveBeenCalled();
});
it('keeps the original fetch time when warming from disk (no TTL extension)', async () => {
const now = Date.now();
await writeFile(
file,
JSON.stringify({ version: 1, fetchedAt: now - 23 * 60 * 60 * 1000, config: CONFIG }),
);
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
// 23h old on disk: still fresh.
await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now,
cacheFile: file,
});
// 2h later (25h since the actual fetch): the warmed entry must be stale.
expect(peekClientConfig('estimated_cache_duration', configSchema, now + 2 * 60 * 60 * 1000)).toBeUndefined();
});
it('refetches and rewrites the file when the disk entry is stale', async () => {
const now = Date.now();
await writeFile(
file,
JSON.stringify({ version: 1, fetchedAt: now - 25 * 60 * 60 * 1000, config: CONFIG }),
);
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
const result = await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
now,
cacheFile: file,
});
expect(result).toEqual(CONFIG);
expect(fetchImpl).toHaveBeenCalledTimes(1);
const onDisk = JSON.parse(await readFile(file, 'utf-8')) as { fetchedAt: number };
expect(onDisk.fetchedAt).toBe(now);
});
it('treats a malformed cache file as missing', async () => {
await writeFile(file, 'not json');
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
const result = await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
cacheFile: file,
});
expect(result).toEqual(CONFIG);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('ignores a disk entry whose payload fails the caller schema', async () => {
await writeFile(
file,
JSON.stringify({ version: 1, fetchedAt: Date.now(), config: { version: 2 } }),
);
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
const result = await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
cacheFile: file,
});
expect(result).toEqual(CONFIG);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('still resolves when the cache file cannot be written', async () => {
// The parent path is a regular file, so mkdir for the cache file fails.
const blocker = join(dir, 'blocker');
await writeFile(blocker, 'x');
const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE));
const result = await getClientConfig('estimated_cache_duration', configSchema, {
fetchImpl: fetchImpl as typeof fetch,
cacheFile: join(blocker, 'nested', 'config.json'),
});
expect(result).toEqual(CONFIG);
});
});

View file

@ -429,6 +429,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c
| --- | --- | --- | --- |
| `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes.md) |
| `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line |
| `cache_expiry_hint` | `boolean` | `true` | Show a dialog when resuming a long-idle session or submitting after a long idle stretch, warning that the context cache has likely expired and offering to compact or start a new session (v2 engine only) |
| `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` |
| `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent |
| `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` |
@ -440,6 +441,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c
# ~/.kimi-code/tui.toml
theme = "auto" # "auto" | "dark" | "light" | custom theme name
disable_paste_burst = false # true disables non-bracketed paste-burst fallback
cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit
[editor]
command = "" # empty uses $VISUAL / $EDITOR

View file

@ -429,6 +429,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod
| --- | --- | --- | --- |
| `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark``light`,或[自定义主题](../customization/themes.md)的名字 |
| `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 |
| `cache_expiry_hint` | `boolean` | `true` | resume 长时间未活动的会话、或长时间空闲后发送消息时,若上下文缓存可能已过期则弹出提醒,可选择先压缩或新建会话(仅 v2 引擎) |
| `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` |
| `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 |
| `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) |
@ -440,6 +441,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod
# ~/.kimi-code/tui.toml
theme = "auto" # "auto" | "dark" | "light" | 自定义主题名
disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底
cache_expiry_hint = true # false 表示关闭 resume / 空闲提交时的"缓存已过期"提醒弹窗
[editor]
command = "" # 留空则使用 $VISUAL / $EDITOR