Merge remote-tracking branch 'origin/main' into kimi-code-v2

This commit is contained in:
haozhe.yang 2026-07-09 20:06:12 +08:00
commit a729657f34
107 changed files with 4237 additions and 893 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kimi-code-sdk": patch
---
Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix resuming sessions whose original working directory no longer exists.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep image-heavy sessions within provider request-size limits: model-read images now honor a 256 KB per-image budget and a 2000px downscale cap (configurable via `[image]` in config.toml or `KIMI_IMAGE_*` env vars), oversized WebP is compressed as well, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and a request-too-large rejection (HTTP 413) now recovers automatically — the request and /compact both retry with older media replaced by text markers instead of failing the session.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Polish the session sidebar layout, colors, icons, and typography.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling.

View file

@ -46,13 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/;
* Parses a headless prompt into a goal-create request, or `undefined` when the
* prompt is not a `/goal` create command (so the caller runs it as a normal
* prompt). Non-create goal subcommands are not supported headless and fall
* through to normal prompt handling.
* through to normal prompt handling. Malformed create commands throw instead of
* falling through, so validation errors are reported before anything is sent to
* the model.
*/
export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined {
const trimmed = prompt.trim();
if (!GOAL_PREFIX.test(trimmed)) return undefined;
const args = trimmed.replace(/^\/goal/, '').trim();
const parsed = parseGoalCommand(args);
if (parsed.kind === 'error') {
throw new Error(parsed.message);
}
if (parsed.kind !== 'create') return undefined;
return { objective: parsed.objective, replace: parsed.replace };
}

View file

@ -250,7 +250,7 @@ async function runHeadlessGoal(
try {
// The objective is sent as the normal prompt; goal continuation keeps the
// turn alive until a terminal state is reached.
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr);
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr, true);
} finally {
unsubscribeGoalEvents();
const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
@ -448,9 +448,11 @@ function runPromptTurn(
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
waitForGoalTerminal = false,
): Promise<void> {
let activeTurnId: number | undefined;
let activeAgentId: string | undefined;
let latestStartedTurnId: number | undefined;
const outputWriter =
outputFormat === 'stream-json'
? new PromptJsonWriter(stdout)
@ -485,6 +487,18 @@ function runPromptTurn(
}
activeTurnId = event.turnId;
activeAgentId = event.agentId;
latestStartedTurnId = event.turnId;
return;
}
if (
waitForGoalTerminal &&
event.type === 'goal.updated' &&
event.agentId === PROMPT_MAIN_AGENT_ID &&
activeTurnId === undefined &&
event.snapshot !== null &&
event.snapshot.status !== 'active'
) {
void finishCompletedTurn();
return;
}
if (
@ -531,19 +545,29 @@ function runPromptTurn(
return;
case 'turn.ended':
if (event.reason === 'completed') {
void (async () => {
// Flush the buffered assistant message before draining background
// tasks: in stream-json mode the final message is only emitted by
// finish(), so a long background wait would otherwise withhold the
// main turn's result until the drain settles.
outputWriter.flushAssistant();
try {
await session.waitForBackgroundTasksOnPrint();
} catch (error) {
log.warn('waitForBackgroundTasksOnPrint failed', { error });
}
finish();
})();
outputWriter.flushAssistant();
if (waitForGoalTerminal) {
const completedTurnId = event.turnId;
activeTurnId = undefined;
activeAgentId = undefined;
void (async () => {
try {
const { goal } = await session.getGoal();
if (
activeTurnId !== undefined ||
latestStartedTurnId !== completedTurnId
) {
return;
}
if (goal?.status === 'active') return;
await finishCompletedTurn();
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
})();
return;
}
void finishCompletedTurn();
return;
}
finish(new Error(formatTurnEndedFailure(event)));
@ -576,6 +600,20 @@ function runPromptTurn(
session.prompt(prompt).catch((error: unknown) => {
finish(error instanceof Error ? error : new Error(String(error)));
});
async function finishCompletedTurn(): Promise<void> {
// Flush the buffered assistant message before draining background tasks:
// in stream-json mode the final message is only emitted by finish(), so a
// long background wait would otherwise withhold the main turn's result
// until the drain settles.
outputWriter.flushAssistant();
try {
await session.waitForBackgroundTasksOnPrint();
} catch (error) {
log.warn('waitForBackgroundTasksOnPrint failed', { error });
}
finish();
}
});
}

View file

@ -213,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUs
if (res.kind === 'error') {
return { error: res.message };
}
return { usage: { summary: res.summary, limits: res.limits } };
return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } };
}

View file

@ -22,7 +22,11 @@ import {
safeUsageRatio,
} from '#/utils/usage/usage-format';
import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel';
import {
buildExtraUsageSection,
buildManagedUsageReportLines,
type ManagedUsageReport,
} from './usage-panel';
interface FieldRow {
readonly label: string;
@ -145,5 +149,16 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] {
lines.push(...managedSection);
}
const extraSection = buildExtraUsageSection(
options.managedUsage?.extraUsage,
accent,
value,
muted,
);
if (extraSection.length > 0) {
lines.push('');
lines.push(...extraSection);
}
return lines;
}

View file

@ -30,9 +30,19 @@ export interface ManagedUsageRow {
readonly resetHint?: string;
}
export interface BoosterWalletInfo {
readonly balanceCents: number;
readonly totalCents: number;
readonly monthlyChargeLimitEnabled: boolean;
readonly monthlyChargeLimitCents: number;
readonly monthlyUsedCents: number;
readonly currency: string;
}
export interface ManagedUsageReport {
readonly summary: ManagedUsageRow | null;
readonly limits: readonly ManagedUsageRow[];
readonly extraUsage?: BoosterWalletInfo | null;
}
export interface UsageReportOptions {
@ -121,8 +131,7 @@ function buildManagedUsageSection(
r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0;
const labelWidth = Math.max(10, ...rows.map((r) => r.label.length));
const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length));
const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' =>
sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
const out: string[] = [accent('Plan usage')];
for (const row of rows) {
const ratioUsed = usedRatio(row);
@ -136,6 +145,91 @@ function buildManagedUsageSection(
return out;
}
function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' {
return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
}
function currencySymbol(currency: string): string {
switch (currency.toUpperCase()) {
case 'CNY':
return '¥';
case 'USD':
return '$';
default:
return '';
}
}
interface CurrencyParts {
readonly symbol: string;
readonly number: string;
}
function formatCurrencyParts(cents: number, currency: string): CurrencyParts {
const symbol = currencySymbol(currency);
const main = cents / 100;
const formatted = main.toFixed(2);
return symbol.length > 0
? { symbol, number: formatted }
: { symbol: '', number: `${formatted} ${currency}` };
}
export function buildExtraUsageSection(
extraUsage: BoosterWalletInfo | undefined | null,
accent: Colorize,
value: Colorize,
muted: Colorize,
): string[] {
if (extraUsage === undefined || extraUsage === null) return [];
const hasMonthlyLimit =
extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0;
const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency);
const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency);
const rows: Array<{ label: string; symbol: string; number: string }> = [];
let barLine: string | null = null;
if (hasMonthlyLimit) {
const ratio = Math.max(
0,
Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1),
);
const bar = renderProgressBar(ratio, 20);
barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`;
const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency);
rows.push({ label: 'Used this month', ...used });
rows.push({ label: 'Monthly limit', ...limit });
rows.push({ label: 'Balance', ...balance });
} else {
rows.push({ label: 'Used this month', ...used });
rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' });
rows.push({ label: 'Balance', ...balance });
}
// `Used this month` is the longest label; size the column to the widest label
// so the currency symbol starts in the same column on every row.
const labelWidth = Math.max(...rows.map((r) => r.label.length));
// Right-align the numeric part of currency rows against each other so the
// decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as
// `Unlimited` carry no currency symbol, so they must not widen the numeric
// column — otherwise money values get padded with stray spaces.
const numberWidth = Math.max(
0,
...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)),
);
const row = (label: string, symbol: string, number: string): string => {
const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number;
return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`;
};
const lines: string[] = [accent('Extra Usage')];
if (barLine !== null) lines.push(barLine);
for (const r of rows) lines.push(row(r.label, r.symbol, r.number));
return lines;
}
export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] {
const accent = (text: string) => currentTheme.boldFg('primary', text);
const value = (text: string) => currentTheme.fg('text', text);
@ -157,8 +251,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
const value = (text: string) => currentTheme.fg('text', text);
const muted = (text: string) => currentTheme.fg('textDim', text);
const errorStyle = (text: string) => currentTheme.fg('error', text);
const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' =>
sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
const lines: string[] = [
accent('Session usage'),
@ -197,6 +289,17 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
lines.push(...managedSection);
}
const extraSection = buildExtraUsageSection(
options.managedUsage?.extraUsage,
accent,
value,
muted,
);
if (extraSection.length > 0) {
lines.push('');
lines.push(...extraSection);
}
return lines;
}

View file

@ -46,6 +46,12 @@ describe('parseHeadlessGoalCreate', () => {
expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined();
expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined();
});
it('rejects malformed goal create prompts instead of falling through', () => {
expect(() => parseHeadlessGoalCreate(`/goal ${'x'.repeat(4001)}`)).toThrow(
'Goal objective is too long',
);
});
});
describe('goal summary', () => {
@ -97,6 +103,7 @@ const mocks = vi.hoisted(() => {
handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
}),
waitForBackgroundTasksOnPrint: vi.fn(async () => {}),
};
return {
session,
@ -164,6 +171,8 @@ describe('runPrompt headless goal mode', () => {
mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }];
mocks.sessions = [];
mocks.session.createGoal.mockClear();
mocks.session.prompt.mockClear();
mocks.session.waitForBackgroundTasksOnPrint.mockClear();
mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never);
mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'complete' }) } as never);
});
@ -243,6 +252,113 @@ describe('runPrompt headless goal mode', () => {
expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X');
});
it('keeps listening across continuation turns until the goal is terminal', async () => {
const active = snapshot({ status: 'active', turnsUsed: 1, tokensUsed: 80 });
const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 });
mocks.session.getGoal.mockResolvedValueOnce({ goal: active } as never);
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: '1' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
await Promise.resolve();
for (const handler of mocks.eventHandlers) {
handler(
mocks.mainEvent({
type: 'turn.started',
turnId: 2,
origin: { kind: 'system_trigger', name: 'goal_continuation' },
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: '2' }));
handler(
mocks.mainEvent({
type: 'goal.updated',
snapshot: completed,
change: { kind: 'completion', status: 'complete' },
}),
);
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
});
expect(stdout.text()).toBe('• 1\n\n• 2\n\n');
expect(stderr.text()).toContain('Goal [complete]');
expect(stderr.text()).toContain('turns: 2');
});
it('ignores stale goal checks once a continuation turn has started', async () => {
const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 });
let resolveFirstGoal: ((value: { goal: null }) => void) | undefined;
const firstGoal = new Promise<{ goal: null }>((resolve) => {
resolveFirstGoal = resolve;
});
mocks.session.getGoal
.mockImplementationOnce(() => firstGoal as never)
.mockResolvedValue({ goal: null } as never);
mocks.session.prompt.mockImplementationOnce(async () => {
const emit = (event: Record<string, unknown>) => {
for (const handler of [...mocks.eventHandlers]) {
handler(mocks.mainEvent(event));
}
};
emit({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } });
emit({ type: 'assistant.delta', turnId: 1, delta: '1' });
emit({ type: 'turn.ended', turnId: 1, reason: 'completed' });
emit({
type: 'turn.started',
turnId: 2,
origin: { kind: 'system_trigger', name: 'goal_continuation' },
});
emit({ type: 'assistant.delta', turnId: 2, delta: '2' });
emit({
type: 'goal.updated',
snapshot: completed,
change: { kind: 'completion', status: 'complete' },
});
resolveFirstGoal?.({ goal: null });
await Promise.resolve();
emit({ type: 'assistant.delta', turnId: 2, delta: ' tail' });
emit({ type: 'turn.ended', turnId: 2, reason: 'completed' });
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
});
expect(stdout.text()).toBe('• 1\n\n• 2 tail\n\n');
expect(stderr.text()).toContain('Goal [complete]');
});
it('does not send an invalid goal create prompt as a normal prompt', async () => {
const stdout = writer();
const stderr = writer();
await expect(
runPrompt(opts({ prompt: `/goal ${'x'.repeat(4001)}` }), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
}),
).rejects.toThrow('Goal objective is too long');
expect(mocks.session.createGoal).not.toHaveBeenCalled();
expect(mocks.session.prompt).not.toHaveBeenCalled();
});
it('validates the resumed session model before creating a headless goal', async () => {
mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }];
mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never);

View file

@ -68,6 +68,44 @@ describe('status panel report lines', () => {
expect(output).not.toContain('Runtime');
});
it('formats extra usage section in status report', () => {
const lines = buildStatusReportLines({
version: '1.2.3',
model: 'k2',
workDir: '/tmp/project',
sessionId: 'ses-1',
sessionTitle: null,
thinkingEffort: 'off',
permissionMode: 'manual',
planMode: false,
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
availableModels: {},
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 15000,
totalCents: 20000,
monthlyChargeLimitEnabled: true,
monthlyChargeLimitCents: 20000,
monthlyUsedCents: 5000,
currency: 'USD',
},
},
}).map(strip);
const output = lines.join('\n');
expect(output).toContain('Extra Usage');
expect(output).toContain('Balance');
expect(output).toContain('150.00');
expect(output).toContain('Used this month');
expect(output).toContain('50.00');
expect(output).toContain('Monthly limit');
expect(output).toContain('200.00');
});
it('falls back to app state and shows status load errors as warnings', () => {
const lines = buildStatusReportLines({
version: '1.2.3',

View file

@ -24,7 +24,7 @@ describe('UsagePanelComponent', () => {
output: 250,
},
},
} as never,
},
contextUsage: 0.25,
contextTokens: 2500,
maxContextTokens: 10000,
@ -48,6 +48,142 @@ describe('UsagePanelComponent', () => {
expect(lines.join('\n')).toContain('resets tomorrow');
});
it('formats extra usage with a monthly limit', () => {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 10000,
totalCents: 20000,
monthlyChargeLimitEnabled: true,
monthlyChargeLimitCents: 20000,
monthlyUsedCents: 5000,
currency: 'USD',
},
},
}).map(strip);
const output = lines.join('\n');
expect(lines).toContain('Extra Usage');
expect(output).toContain('Balance');
expect(output).toContain('100.00');
expect(output).toContain('Used this month');
expect(output).toContain('50.00');
expect(output).toContain('Monthly limit');
expect(output).toContain('200.00');
// bar row contains block glyphs but no percentage text
expect(output).toContain('░');
});
it('formats extra usage without a monthly limit and omits the progress bar', () => {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 18208,
totalCents: 40000,
monthlyChargeLimitEnabled: false,
monthlyChargeLimitCents: 0,
monthlyUsedCents: 21792,
currency: 'CNY',
},
},
}).map(strip);
const output = lines.join('\n');
expect(lines).toContain('Extra Usage');
expect(output).toContain('Balance');
expect(output).toContain('¥182.08');
expect(output).toContain('Used this month');
expect(output).toContain('¥217.92');
expect(output).toContain('Monthly limit');
expect(output).toContain('Unlimited');
expect(output).not.toContain('░');
expect(output).not.toContain('█');
});
it('omits the extra usage section when extraUsage is omitted or null', () => {
for (const extraUsage of [undefined, null]) {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: { summary: null, limits: [], extraUsage },
}).map(strip);
expect(lines).not.toContain('Extra Usage');
}
});
it('formats extra usage with CNY currency', () => {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 10000,
totalCents: 20000,
monthlyChargeLimitEnabled: true,
monthlyChargeLimitCents: 20000,
monthlyUsedCents: 5000,
currency: 'CNY',
},
},
}).map(strip);
const output = lines.join('\n');
expect(output).toContain('Balance');
expect(output).toContain('100.00');
expect(output).toContain('Used this month');
expect(output).toContain('50.00');
expect(output).toContain('Monthly limit');
expect(output).toContain('200.00');
});
it('aligns the currency symbol and decimal point across extra usage rows', () => {
const lines = buildUsageReportLines({
sessionUsage: { byModel: {} },
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 15901,
totalCents: 300000,
monthlyChargeLimitEnabled: true,
monthlyChargeLimitCents: 300000,
monthlyUsedCents: 24099,
currency: 'CNY',
},
},
}).map(strip);
const extraRows = lines.filter((line) => line.includes('¥'));
expect(extraRows).toHaveLength(3);
// The currency symbol stays in one column...
expect(new Set(extraRows.map((line) => line.indexOf('¥'))).size).toBe(1);
// ...and the right-aligned numeric parts end in the same column, so the
// decimal points line up across rows.
expect(new Set(extraRows.map((line) => line.length)).size).toBe(1);
});
it('wraps preformatted usage lines in a bordered panel', () => {
const component = new UsagePanelComponent(() => ['Session usage'], 'primary');
const output = component.render(80).map(strip);

View file

@ -141,8 +141,8 @@ describe('clipboard image paste compression', () => {
if (att?.kind !== 'image') throw new Error('expected image attachment');
// Stored metadata reflects the compressed size.
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(3000);
expect(att.placeholder).toContain('3000×1500');
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000);
expect(att.placeholder).toContain('2000×1000');
// The stored bytes decode to the compressed dimensions — the thumbnail and
// the submitted image both read from these bytes, so they cannot diverge.

View file

@ -1,7 +1,7 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { app, BrowserWindow, Menu, shell } from 'electron';
import { app, BrowserWindow, Menu, nativeTheme, shell } from 'electron';
import type { MenuItemConstructorOptions } from 'electron';
import { ensureServer, kimiHome, serverLogPath } from './ensure-server';
@ -152,8 +152,13 @@ function createWindow(): void {
title: 'Kimi Code Desktop',
// macOS: hide the native title bar and float the traffic lights over the
// content; the web UI reserves a draggable strip at the top to clear them.
// 'hidden' (not 'hiddenInset') so trafficLightPosition can pin the lights
// to the vertical center of the web UI's 48px header row (y 18 + 12px
// button height / 2 = 24 = the header's midline — same line as the
// sidebar-expand button and the conversation title).
// 'default' on other platforms (they keep their native title bar).
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
trafficLightPosition: { x: 16, y: 18 },
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
@ -165,6 +170,63 @@ function createWindow(): void {
win.webContents.on('page-title-updated', (event) => {
event.preventDefault();
});
// macOS traffic lights.
//
// 1) Visibility across transitions: with titleBarStyle 'hidden' + a custom
// trafficLightPosition, the buttons can vanish (or lose their custom
// position) after a full-screen round-trip or on re-focus. Re-assert both
// on those transitions (observed on Electron 33; belt-and-braces).
//
// 2) Blur is NOT such a case: unfocused traffic lights are merely DIMMED by
// AppKit, and the dimmed color follows the WINDOW appearance, not the
// page (electron#27295) — with the OS in dark mode but the web UI on a
// light theme, the light-gray dimmed dots become invisible against the
// light sidebar. That is fixed by the theme sync below, which keeps the
// window appearance aligned with the web UI's <html data-color-scheme>.
if (process.platform === 'darwin') {
const showTrafficLights = (): void => {
if (win.isDestroyed()) return;
win.setWindowButtonPosition({ x: 16, y: 18 });
win.setWindowButtonVisibility(true);
};
win.on('enter-full-screen', showTrafficLights);
win.on('leave-full-screen', showTrafficLights);
win.on('focus', showTrafficLights);
// Theme sync: no preload/IPC channel exists, so inject a tiny observer
// that reports <html data-color-scheme> ('light' | 'dark' | 'system')
// through a tagged console message, and mirror it into
// nativeTheme.themeSource (same three states). The startup/error screens
// (data: URLs) have no such attribute and harmlessly report 'system'.
const THEME_TAG = '__kimi_desktop_theme__:';
win.webContents.on('console-message', (_event, _level, message) => {
if (!message.startsWith(THEME_TAG)) return;
const scheme = message.slice(THEME_TAG.length);
if (scheme === 'light' || scheme === 'dark' || scheme === 'system') {
nativeTheme.themeSource = scheme;
}
});
win.webContents.on('did-finish-load', () => {
win.webContents
.executeJavaScript(
`(() => {
const report = () => {
const v = document.documentElement.dataset.colorScheme;
console.info(${JSON.stringify(THEME_TAG)} + (v === 'light' || v === 'dark' ? v : 'system'));
};
new MutationObserver(report).observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-color-scheme'],
});
report();
})();`,
)
.catch(() => {
// Navigation can tear the page down mid-injection; theme sync is
// cosmetic, so ignore.
});
});
}
win.on('close', () => {
saveBounds(win);
});

View file

@ -13,7 +13,8 @@
"check:style": "node scripts/check-style.mjs"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@chenglou/pretext": "0.0.8",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
@ -27,6 +28,7 @@
},
"devDependencies": {
"@iconify-json/ri": "^1.2.10",
"@iconify-json/tabler": "^1.2.35",
"@vitejs/plugin-vue": "^5.2.4",
"typescript": "6.0.2",
"unplugin-icons": "^23.0.0",

View file

@ -43,6 +43,8 @@ import { stripSkillPrefix } from './lib/slashCommands';
import Button from './components/ui/Button.vue';
import IconButton from './components/ui/IconButton.vue';
import Icon from './components/ui/Icon.vue';
import InternalBuildBanner from './components/InternalBuildBanner.vue';
import { isMacosDesktop } from './lib/desktopFlag';
// Hydrate the server-transport credential (fragment token or sessionStorage)
// BEFORE the client connects, so the first REST/WS calls already carry it.
@ -213,6 +215,7 @@ const {
sidebarMax,
sessionColWidth,
sidebarCollapsed,
sidebarDragging,
sideWidth,
loadSidebarCollapsed,
toggleSidebarCollapse,
@ -645,13 +648,18 @@ function openPr(url: string): void {
<div
v-else
class="app"
:class="{ mobile: isMobile, 'sidebar-collapsed': sidebarCollapsed && !isMobile }"
:style="{ '--side-w': sideWidth + 'px', '--preview-w': previewPanelWidth + 'px' }"
:class="{
mobile: isMobile,
'sidebar-collapsed': sidebarCollapsed && !isMobile,
'macos-desktop': isMacosDesktop,
}"
:style="{ '--preview-w': previewPanelWidth + 'px' }"
>
<!-- Desktop navigation: workspace rail + resizable session column. -->
<template v-if="!isMobile">
<Sidebar
v-show="!sidebarCollapsed"
:collapsed="sidebarCollapsed"
:dragging="sidebarDragging"
:col-width="sideWidth"
:active-workspace="client.visibleWorkspace.value"
:active-workspace-id="client.activeWorkspaceId.value"
@ -681,21 +689,14 @@ function openPr(url: string): void {
/>
<ResizeHandle
v-show="!sidebarCollapsed"
class="side-handle"
:storage-key="SIDEBAR_WIDTH_KEY"
:default-width="SIDEBAR_DEFAULT"
:min="SIDEBAR_MIN"
:max="sidebarMax"
@update:width="sessionColWidth = $event"
@update:dragging="sidebarDragging = $event"
/>
<div v-if="sidebarCollapsed" class="sidebar-rail">
<IconButton
size="sm"
:label="t('sidebar.expandSidebar')"
@click="toggleSidebarCollapse"
>
<Icon name="panel-expand" size="sm" />
</IconButton>
</div>
</template>
<!-- Mobile navigation: slim top bar (switcher + settings sheets). -->
@ -792,8 +793,29 @@ function openPr(url: string): void {
@edit-message="handleEditMessage"
/>
<!-- Sidebar toggle floating only when the in-header control can't serve:
on macOS desktop it's RESIDENT (always rendered beside the traffic
lights, the sidebar slides underneath and only the glyph swaps, so it
never moves or flashes); on Windows/web the collapse button lives
inside the sidebar header, so this floating button only appears while
COLLAPSED (to re-expand the sidebar). It must come AFTER
ConversationPane in the DOM: Electron computes the window-drag region
in tree order (drag rects union, no-drag rects subtract), so a no-drag
element placed before the ChatHeader drag region would have its hole
painted back over making the button an inert drag area. -->
<IconButton
v-if="!isMobile && (isMacosDesktop || sidebarCollapsed)"
class="sidebar-toggle-btn"
size="sm"
:label="sidebarCollapsed ? t('sidebar.expandSidebar') : t('sidebar.collapseSidebar')"
@click="toggleSidebarCollapse"
>
<Icon :name="sidebarCollapsed ? 'panel-expand' : 'panel-collapse'" />
</IconButton>
<ResizeHandle
v-if="sidePanelVisible && !isMobile"
class="preview-handle"
:storage-key="PREVIEW_WIDTH_KEY"
:default-width="previewDefaultWidth"
:min="PREVIEW_MIN"
@ -875,6 +897,11 @@ function openPr(url: string): void {
/>
</aside>
<!-- Internal-build tag pinned to the app's bottom-right corner, above
whatever pane happens to be there. Purely informational: pointer
events pass through so it never blocks clicks. -->
<InternalBuildBanner class="internal-build-fab" />
<!-- Model Picker overlay -->
<ModelPicker
v-if="showModelPicker"
@ -1101,18 +1128,20 @@ function openPr(url: string): void {
color: var(--dim);
}
.app {
--side-w: 248px;
--preview-w: 460px;
flex: 1;
min-height: 0;
position: relative;
display: grid;
/* sidebar (rail + resizable session column) | 0-width handle | conversation.
The 4px ResizeHandle overflows its zero-width track via negative margins so
the whole strip is grabbable without consuming layout space. */
/* The right-panel track is PERMANENT (auto = follows the aside's width, 0
when closed) opening animates the aside's width, so the conversation
column is squeezed over smoothly instead of snapping to a new template. */
grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
/* sidebar | 0-width handle | conversation | 0-width handle | right panel.
The 4px ResizeHandles overflow their zero-width tracks via negative margins
so the whole strip is grabbable without consuming layout space. */
/* Both side tracks are PERMANENT (auto = follows the aside's width, 0 when
closed/collapsed) opening or collapsing animates the aside's width, so
the conversation column is squeezed over smoothly instead of snapping to a
new template. Every column is pinned explicitly (grid-column 15) so a
display:none handle can't shift auto-placement. */
grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
background: var(--bg);
color: var(--color-text);
overflow: hidden;
@ -1126,20 +1155,50 @@ function openPr(url: string): void {
min-width: 0;
}
/* Collapsed sidebar rail: keeps a slim, dedicated grid track so the expand
button never overlaps the conversation header or squeezes the main pane. */
.sidebar-rail {
grid-column: 1;
display: flex;
justify-content: center;
padding-top: 8px;
background: var(--panel);
border-right: 1px solid var(--line);
/* Pin every desktop grid child to its track so auto-placement can never
reshuffle columns when a handle is display:none (v-show/v-if). */
.app > .side { grid-column: 1; }
.side-handle { grid-column: 2; }
.app:not(.mobile) > .con { grid-column: 3; }
.preview-handle { grid-column: 4; }
/* Sidebar toggle floating button pinned to the top-left corner. On macOS
desktop it is resident (rendered in both states beside the traffic lights);
on Windows/web it only appears while the sidebar is collapsed (the collapse
button lives inside the sidebar header). While collapsed the conversation
header pads left so its content clears the button (global block below). */
.sidebar-toggle-btn {
position: absolute;
/* Vertically centered in the 48px conversation header. */
top: 11px;
left: 16px;
z-index: var(--z-sticky);
/* Fade in on appearance (Windows/web: only rendered while collapsed, so
this plays as the sidebar finishes sliding away). macOS disables it. */
animation: sidebar-toggle-btn-in 0.18s var(--ease-out) 0.12s backwards;
/* Floats over the macOS-desktop window-drag header; keep it clickable. */
-webkit-app-region: no-drag;
}
/* The collapsed rail occupies track 1; keep the main pane pinned to the
conversation track even though the sidebar/handle are display:none. */
.app.sidebar-collapsed > .con {
grid-column: 3;
/* macOS desktop (hidden title bar): resident beside the floating traffic
lights (green light's right edge 68px; 72 keeps a gap that matches the
lights' own 8px rhythm); no entrance animation since it never appears. */
.app.macos-desktop .sidebar-toggle-btn {
left: 72px;
animation: none;
}
@keyframes sidebar-toggle-btn-in {
from { opacity: 0; }
}
/* Internal-build tag pinned to the app's bottom-right corner (desktop app
only the component renders nothing elsewhere). Informational: never
intercepts pointer input. */
.internal-build-fab {
position: absolute;
right: var(--space-3);
bottom: var(--space-3);
z-index: var(--z-sticky);
pointer-events: none;
}
/* Mobile single-column shell: slim top bar (auto) over the full-width
@ -1208,4 +1267,19 @@ function openPr(url: string): void {
one continuous line across the layout. */
--panel-head-h: 48px;
}
/* Sidebar collapsed (desktop): the conversation header pads left so its
content clears the floating sidebar toggle (.sidebar-toggle-btn) and the
macOS traffic lights on desktop builds. Animated in step with the sidebar
width transition. Cross-component rule (ChatHeader renders the header), so
it lives in this global block. */
.app:not(.mobile) .chat-header {
transition: padding-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.app.sidebar-collapsed .chat-header {
padding-left: 52px;
}
.app.sidebar-collapsed.macos-desktop .chat-header {
padding-left: 108px;
}
</style>

View file

@ -5,8 +5,8 @@ import { isDesktop } from '../lib/desktopFlag';
const { t } = useI18n();
// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders an inline
// tag meant to sit next to the "Kimi Code" brand in the sidebar header.
// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders a small
// tag pinned to the app's bottom-right corner (positioned by App.vue).
const show = isDesktop;
</script>

View file

@ -254,7 +254,7 @@ defineExpose({ closeMenu });
:label="t('sidebar.options')"
@click.stop="toggleMenu($event)"
>
<Icon name="dots-horizontal" size="sm" />
<Icon name="dots-horizontal" />
</IconButton>
</span>
</div>
@ -287,22 +287,24 @@ defineExpose({ closeMenu });
.se {
/* --sb-* vars come from .side in Sidebar.vue: the title starts at
--sb-pad-x + --sb-gutter + --sb-gap, exactly under the workspace name.
The row is an inset pill: a 6px horizontal margin + 10px padding lands the
leading icon at --sb-pad-x (16px), aligned with the workspace header. */
The row is an inset pill: the .sessions container's --sb-inset padding +
the row's own padding land the leading slot at --sb-pad-x, aligned with
the workspace header. */
display: block;
margin: 0;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
padding: 8px var(--space-2);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
color: var(--color-text);
cursor: pointer;
position: relative;
}
.se:hover { background: var(--color-surface-sunken); color: var(--color-text); }
.se:hover { background: var(--sb-hover, var(--color-surface-sunken)); color: var(--color-text); }
/* Selected: neutral fill (NOT accent-tinted selection reads as "where I
am", the accent stays reserved for actions and status). */
.se.on {
background: var(--color-accent-soft);
color: var(--color-accent-hover);
box-shadow: inset 0 0 0 1px var(--color-accent-bd);
background: var(--color-selected);
color: var(--color-text);
}
.row {
@ -310,9 +312,9 @@ defineExpose({ closeMenu });
align-items: center;
gap: var(--sb-gap, 6px);
min-width: 0;
/* Floor the row at the hover-kebab height (IconButton sm = 26px) so swapping
the timestamp for the kebab on hover doesn't grow the row. */
min-height: 26px;
/* Row height is font-driven: title line-height (13×1.2516px) + 2×5px
.se padding 26px. The hover kebab is absolutely positioned (see .act)
so it never contributes to row height and can't cause hover jitter. */
}
.left {
@ -341,8 +343,9 @@ defineExpose({ closeMenu });
.t {
color: inherit;
font-size: var(--ui-font-size);
font-weight: var(--weight-regular);
font-size: var(--ui-font-size-sm);
font-weight: 450;
line-height: var(--leading-tight);
flex: 1;
min-width: 0;
overflow: hidden;
@ -353,29 +356,39 @@ defineExpose({ closeMenu });
.ts {
color: var(--color-text-faint);
font-size: var(--text-xs);
font-family: var(--font-mono);
font-family: var(--font-ui);
font-weight: 475;
line-height: var(--leading-tight);
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Trailing action slot: time and kebab share one grid cell (grid-area:1/1).
Both stay in the layout and swap via `visibility` (never display:none), so
the slot width = max(time width, IconButton sm 26px) is identical in hover
and rest the badges and title don't reflow, eliminating hover jitter.
`.act .kebab` out-specificities IconButton's own display so the hidden
default wins. */
/* Trailing action slot: the relative time (in flow) sets the slot size; the
kebab is absolutely positioned over it and swapped via `visibility`, so it
contributes neither height (the row stays font-driven) nor width changes
(min-width reserves the kebab's footprint, the title doesn't reflow). */
.act {
display: inline-grid;
position: relative;
flex: none;
display: inline-flex;
align-items: center;
justify-items: center;
justify-content: flex-end;
/* Reserve the kebab's width so the trailing slot (and thus the title) never
shifts between the time and the kebab, even for short times like "2m". */
min-width: 26px;
}
.act .kebab {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
visibility: hidden;
}
.act .ts,
.act .kebab { grid-area: 1 / 1; }
.act .kebab { visibility: hidden; }
.se:hover .act .kebab,
.act:has(.kebab.open) .kebab { visibility: visible; }
.se:hover .act .ts,
.act:has(.kebab.open) .ts { visibility: hidden; }
.kebab.open { color: var(--color-text); background: var(--color-surface-sunken); }
.kebab.open { color: var(--color-text); background: var(--sb-hover, var(--color-surface-sunken)); }
/* Fixed + anchored to the button via inline style (see positionMenu); the menu
is teleported to <body> so the collapsing list's `overflow: hidden` can't clip it. */
@ -409,15 +422,10 @@ defineExpose({ closeMenu });
.sessions .se {
margin: 0;
border-radius: var(--radius-md);
/* Trim the row padding by the inset margin so the title still starts at the
same x as the workspace name (whose header has no inset). */
padding: var(--space-1) calc(var(--sb-pad-x, 12px) - var(--space-2));
}
.sessions .se:hover { background: var(--panel2); }
.sessions .se.on {
background: var(--color-accent-soft);
box-shadow: inset 0 0 0 1px var(--color-accent-bd);
border-radius: var(--radius-sm);
/* Trim the row padding by the container inset so the title still starts at
the same x as the workspace name (whose header has no inset). */
padding: 8px calc(var(--sb-pad-x, 20px) - var(--sb-inset, 12px));
}
.sessions .se .rename-input { border-radius: var(--radius-sm); font-family: var(--sans); }
.sessions .se .kebab { border-radius: var(--radius-sm); }

View file

@ -9,18 +9,16 @@ import { serverEndpointLabel } from '../api/config';
import { copyTextToClipboard } from '../lib/clipboard';
import {
loadCollapsedWorkspaces,
loadShowWorkspacePaths,
saveCollapsedWorkspaces,
saveShowWorkspacePaths,
} from '../lib/storage';
import { moveInOrder, type DropPosition, type WorkspaceSortMode } from '../lib/workspaceOrder';
import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types';
import SearchSessionsDialog from './dialogs/SearchSessionsDialog.vue';
import WorkspaceGroup from './WorkspaceGroup.vue';
import InternalBuildBanner from './InternalBuildBanner.vue';
import { isMacosDesktop } from '../lib/desktopFlag';
import IconButton from './ui/IconButton.vue';
import Icon from './ui/Icon.vue';
import Kbd from './ui/Kbd.vue';
import Menu from './ui/Menu.vue';
import MenuItem from './ui/MenuItem.vue';
import { useConfirmDialog } from '../composables/useConfirmDialog';
@ -49,6 +47,12 @@ const props = withDefaults(
unreadBySession?: Record<string, boolean>;
/** Width (px) of the session column, driven by the App resize handle. */
colWidth?: number;
/** True when the sidebar is collapsed: the container animates to width 0
* (content keeps `colWidth` and is clipped), then hides itself. */
collapsed?: boolean;
/** True while the resize handle is dragged disables the width transition
* so the sidebar follows the pointer 1:1. */
dragging?: boolean;
}>(),
{
activeWorkspace: null,
@ -57,6 +61,8 @@ const props = withDefaults(
pendingBySession: () => ({}),
unreadBySession: () => ({}),
colWidth: 220,
collapsed: false,
dragging: false,
},
);
@ -83,7 +89,7 @@ const emit = defineEmits<{
// Session search dialog (Spotlight-style; filters title + last prompt)
// ---------------------------------------------------------------------------
const showSearch = ref(false);
const sessionSearchShortcut = isAppleShortcutPlatform() ? '⌘K' : 'Ctrl K';
const sessionSearchKeys = isAppleShortcutPlatform() ? ['⌘', 'K'] : ['Ctrl', 'K'];
function openSearch(): void {
// Sessions are loaded per-workspace (first page only); lazily drain the rest
@ -110,7 +116,7 @@ function isAppleShortcutPlatform(): boolean {
return userAgentData?.platform === 'macOS' || userAgentData?.platform === 'iOS';
}
// Scroll-linked header seam: the .btn-wrap bottom border/shadow only appears
// Scroll-linked header seam: the .search-wrap bottom border/shadow only appears
// once the session list has actually scrolled, so an unscrolled list shows no
// abrupt boundary.
const sessionsScrolled = ref(false);
@ -185,18 +191,6 @@ function onLoadMore(id: string): void {
emit('loadMoreSessions', id);
}
// ---------------------------------------------------------------------------
// Workspace path display (toggle in the Workspaces section header)
// ---------------------------------------------------------------------------
// Off by default so the list stays compact; turning it on reveals every
// workspace's root path as a stable subtitle (no hover-induced layout shift).
const showWorkspacePaths = ref<boolean>(loadShowWorkspacePaths());
function toggleShowWorkspacePaths(): void {
showWorkspacePaths.value = !showWorkspacePaths.value;
saveShowWorkspacePaths(showWorkspacePaths.value);
}
// ---------------------------------------------------------------------------
// Workspace drag-to-reorder
// ---------------------------------------------------------------------------
@ -486,11 +480,6 @@ function chooseSortMode(mode: WorkspaceSortMode): void {
closeSectionMenu();
}
function toggleShowWorkspacePathsFromMenu(): void {
toggleShowWorkspacePaths();
closeSectionMenu();
}
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onGhMenuDocClick, true);
document.removeEventListener('mousedown', onWsMenuDocClick);
@ -559,51 +548,49 @@ onBeforeUnmount(() => {
</script>
<template>
<aside class="side" :class="{ 'macos-desktop': isMacosDesktop }">
<aside
class="side"
:class="{ 'macos-desktop': isMacosDesktop, collapsed, 'no-anim': dragging }"
:style="{ width: collapsed ? '0px' : colWidth + 'px' }"
>
<!-- Session column -->
<div class="col" :style="{ width: colWidth + 'px' }">
<!-- Header: logo + settings (no hard border flows into workspace list) -->
<!-- Header: brand + collapse. The collapse button lives INSIDE the header
on non-mac platforms (right-aligned); on macOS desktop the brand is
hidden (traffic lights own that corner) and the header is just a
window-drag strip there the toggle is App.vue's resident floating
button beside the traffic lights. -->
<div class="ch">
<div class="ch-brand">
<svg ref="logoRef" class="ch-logo" :class="{ 'is-dev': isDev }" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
<defs>
<mask id="kimiEyes" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g class="ch-eyes" fill="#000">
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
</svg>
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
<InternalBuildBanner />
<template v-if="!isMacosDesktop">
<svg ref="logoRef" class="ch-logo" :class="{ 'is-dev': isDev }" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
<defs>
<mask id="kimiEyes" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g class="ch-eyes" fill="#000">
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
</svg>
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
</template>
</div>
<IconButton
v-if="!isMacosDesktop"
class="ch-collapse"
size="sm"
:label="t('sidebar.collapseSidebar')"
@click.stop="emit('collapse')"
>
<Icon name="panel-collapse" />
</IconButton>
<IconButton
size="sm"
:label="t('settings.title')"
@click.stop="emit('openSettings')"
>
<Icon name="settings" />
</IconButton>
</div>
<!-- Session search opens the Spotlight-style search dialog -->
<button class="search" type="button" @click="openSearch">
<Icon class="search-icon" name="search" />
<span class="search-input">{{ t('sidebar.searchShortcut', { shortcut: sessionSearchShortcut }) }}</span>
</button>
<!-- New chat + new workspace buttons -->
<div class="btn-wrap" :class="{ 'btn-wrap--scrolled': sessionsScrolled }">
<div class="btn-wrap">
<button class="btn-new-chat" type="button" @click.stop="emit('create')">
<Icon name="chat-new" />
<span>{{ t('sidebar.newChat') }}</span>
@ -618,6 +605,16 @@ onBeforeUnmount(() => {
</IconButton>
</div>
<!-- Session search opens the Spotlight-style search dialog. Last fixed
row above the list, so it carries the scroll-linked seam. -->
<div class="search-wrap" :class="{ 'search-wrap--scrolled': sessionsScrolled }">
<button class="search" type="button" @click="openSearch">
<Icon class="search-icon" name="search" />
<span class="search-input">{{ t('sidebar.search') }}</span>
<Kbd :keys="sessionSearchKeys" />
</button>
</div>
<!-- Session list grouped by workspace -->
<div class="sessions" @scroll="onSessionsScroll">
<!-- Empty state only when no workspace is registered at all; empty
@ -675,7 +672,6 @@ onBeforeUnmount(() => {
:dragging="draggingWsId === g.workspace.id"
:is-collapsed="isCollapsed"
:is-expanded="isExpanded"
:show-path="showWorkspacePaths"
@group-click="handleGhClick"
@group-contextmenu="openGhMenu"
@toggle-ws-menu="toggleWsMenu"
@ -695,6 +691,14 @@ onBeforeUnmount(() => {
</div>
</template>
</div>
<!-- Footer: settings entry pinned under the session list -->
<div class="side-footer">
<button class="btn-settings" type="button" @click.stop="emit('openSettings')">
<Icon name="settings" />
<span>{{ t('settings.title') }}</span>
</button>
</div>
</div>
<!-- Workspace right-click menu (position:fixed) -->
@ -745,13 +749,6 @@ onBeforeUnmount(() => {
</span>
{{ t('sidebar.sortRecent') }}
</MenuItem>
<MenuItem separator />
<MenuItem @click="toggleShowWorkspacePathsFromMenu()">
<span class="section-menu-check">
<Icon v-if="showWorkspacePaths" name="check" size="sm" />
</span>
{{ t('sidebar.showWorkspacePaths') }}
</MenuItem>
</Menu>
<!-- Session search dialog (Cmd/Ctrl+K) -->
<SearchSessionsDialog
@ -772,24 +769,48 @@ onBeforeUnmount(() => {
<style scoped>
.side {
border-right: 1px solid var(--line);
background: var(--panel);
/* Sidebar sits on its own surface (--color-sidebar-bg, one step off --bg);
the 1px hairline on .col still separates it from the conversation pane. */
background: var(--color-sidebar-bg);
display: flex;
flex-direction: row;
/* Anchor content to the right edge: while the container width animates to 0
the fixed-width column slides out to the left and is clipped, instead of
reflowing. Mirrors the right-side preview panel (App.vue .global-preview). */
justify-content: flex-end;
overflow: hidden;
min-width: 0;
height: 100%;
/* Alignment contract, inherited by SessionRow and the de-terminalization
rules in style.css: text in the workspace header, the path line and session
rows all starts at --sb-pad-x + --sb-gutter + --sb-gap from the sidebar edge. */
--sb-pad-x: var(--space-4); /* row horizontal padding */
--sb-gutter: 20px; /* leading icon slot (14px folder icon + 6px margin) */
transition:
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
visibility 0.28s;
/* Alignment contract, inherited by SessionRow and WorkspaceGroup:
- row boxes (hover/selected pills) sit --sb-inset from the sidebar edges;
- text/icons start at --sb-pad-x = --sb-inset + 8px row padding;
- row titles start at --sb-pad-x + --sb-gutter + --sb-gap. */
--sb-inset: var(--space-3); /* row box inset from the sidebar edge */
--sb-pad-x: var(--space-5); /* content start x (inset + row padding) */
--sb-gutter: 16px; /* leading icon slot (matches the 16px folder icon, so the session title aligns under the workspace name) */
--sb-gap: var(--space-2); /* gap between the icon slot and the text */
/* Sidebar stays one step above compact UI chrome, but still follows the
user-controlled font-size preference. */
--ui-font-size: var(--sidebar-ui-font-size);
/* Row hover wash global --color-hover (lighter than the selected fill;
both translucent, so they sit on any surface). */
--sb-hover: var(--color-hover);
}
/* While dragging the resize handle, follow the pointer 1:1 (same pattern as
.global-preview.no-anim in App.vue). */
.side.no-anim {
transition: none;
}
/* Fully collapsed: width 0 (animated), then drop out of hit-testing / tab
order once the transition ends (visibility interpolates to hidden at the
end when collapsing, and back to visible immediately when expanding). */
.side.collapsed {
visibility: hidden;
}
/* Session column. Width is set inline from the App resize handle. */
/* Session column. Width is set inline from the App resize handle; it stays
fixed while the collapsing container clips it. Carries the sidebar's right
hairline so the border is clipped away together with the content. */
.col {
flex: none;
min-width: 0;
@ -797,35 +818,38 @@ onBeforeUnmount(() => {
flex-direction: column;
min-height: 0;
width: 100%;
box-sizing: border-box;
border-right: 1px solid var(--line);
container-type: inline-size;
container-name: sidebar-col;
}
/* Header: logo + settings (no border — flows into the workspace list). */
/* Header: brand strip (no border flows into the workspace list). On non-mac
platforms the brand sits on the left and the collapse button on the right
(justify-content: space-between); on macOS desktop the brand is hidden and
the header is a window-drag strip (see below). min-height keeps the 26px
control row (50px total with padding) so the list below starts at a stable
y. */
.ch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: var(--space-3) var(--space-3) var(--space-2);
padding: var(--space-3);
min-height: calc(26px + 2 * var(--space-3));
width: 100%;
box-sizing: border-box;
}
/* macOS desktop: the window uses a hidden title bar, so the traffic lights float
over the top-left of the sidebar. Push the header content right to clear them,
and turn the whole header into the window-drag region matching the chat
header. The action buttons and the logo opt out with no-drag so they stay
clickable: this is the same no-drag-inside-drag pattern ChatHeader.vue relies
on (the previous "drag only the brand area" approach still captured the
sibling buttons, because Electron treats a flex-grown drag item's hit area as
covering the whole flex line). */
/* macOS desktop: the window uses a hidden title bar, so the traffic lights
float over the top-left of the sidebar and the resident toggle sits beside
them. The header renders no content here (brand hidden) it is purely a
window-drag strip. */
.side.macos-desktop .ch {
padding-left: 80px;
-webkit-app-region: drag;
}
.side.macos-desktop .ch button,
.side.macos-desktop .ch-logo {
-webkit-app-region: no-drag;
.side.macos-desktop .ch-brand {
display: none;
}
.ch-logo {
height: 22px;
@ -880,22 +904,14 @@ onBeforeUnmount(() => {
.ch-name { display: none; }
}
/* Action buttons */
.btn-wrap {
/* Action buttons first row of the actions group (New chat + search): rows
inside the group stack flush (0 gap, same rhythm as the session list rows);
the group's bottom gap lives on .search-wrap. */
.btn-wrap {
display: flex;
align-items: center;
gap: 8px;
padding: 0 var(--space-2) var(--space-2);
position: relative;
z-index: 1;
background: var(--panel);
border-bottom: 1px solid transparent;
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
}
.btn-wrap--scrolled {
border-bottom-color: var(--line);
box-shadow: var(--shadow-sm);
padding: 0 var(--sb-inset);
}
.btn-new-chat {
display: flex;
@ -903,45 +919,61 @@ onBeforeUnmount(() => {
gap: 12px;
flex: 1;
min-width: 0;
min-height: 26px;
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--ui-font-size);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
cursor: pointer;
text-align: left;
}
.btn-new-chat:hover { background: var(--color-surface-sunken); }
.btn-new-chat:hover { background: var(--sb-hover); }
.btn-new-chat:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.btn-new-chat svg { flex: none; width: 16px; height: 16px; }
.btn-new-chat svg { flex: none; }
.btn-new-chat span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Session search */
/* Session search the wrapper is the last fixed row above the list and
carries the scroll-linked seam: its bottom border/shadow only appear once
the session list has actually scrolled, so an unscrolled list shows no
abrupt boundary. */
.search-wrap {
padding: 0 var(--sb-inset);
position: relative;
z-index: 1;
background: var(--color-sidebar-bg);
border-bottom: 1px solid transparent;
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
}
.search-wrap--scrolled {
border-bottom-color: var(--line);
box-shadow: var(--shadow-sm);
}
.search {
display: flex;
align-items: center;
gap: 12px;
min-height: 26px;
margin: 0 var(--space-2) var(--space-2);
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
width: 100%;
margin: 0;
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.search:hover { background: var(--color-surface-sunken); }
.search:hover { background: var(--sb-hover); }
.search:focus-visible {
background: var(--color-surface-sunken);
background: var(--sb-hover);
color: var(--color-text);
outline: 2px solid var(--color-accent-bd);
outline-offset: -2px;
@ -953,29 +985,68 @@ onBeforeUnmount(() => {
flex: 1;
min-width: 0;
color: var(--color-text);
font-family: var(--mono);
font-size: var(--ui-font-size);
font-family: var(--font-ui);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Sessions */
/* Sessions owns the vertical padding around the list (the 12px gap to the
search row above and the bottom breathing room). Scrolled content passes
through the top padding and clips at the .search-wrap seam. Scrollbar: the
4px ::-webkit-scrollbar below; standard scrollbar-width would kill it on
Chromium (see the global scrollbar block in style.css). */
.sessions {
flex: 1;
overflow-y: auto;
padding: 0 var(--space-2) var(--space-2);
padding: var(--space-3) var(--sb-inset);
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
}
.sessions::-webkit-scrollbar { width: 4px; }
.sessions::-webkit-scrollbar-track { background: transparent; }
.sessions::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: var(--radius-xs);
/* Neutral, text-derived translucency adapts to both schemes and sits
quietly on the sidebar surface (no accent tint on hover). */
background: color-mix(in srgb, var(--color-text) 12%, transparent);
border-radius: var(--radius-full);
}
.sessions::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--color-text) 25%, transparent); }
/* Footer settings entry pinned under the session list. Same list-style
control family as search / New chat (full-width, left-aligned, hover
sunken not a Button). */
.side-footer {
flex: none;
padding: var(--space-2) var(--sb-inset);
border-top: 1px solid var(--line);
}
.btn-settings {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
cursor: pointer;
text-align: left;
}
.btn-settings:hover { background: var(--sb-hover); }
.btn-settings:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.btn-settings svg { flex: none; }
.btn-settings span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessions::-webkit-scrollbar-thumb:hover { background: var(--color-accent-bd); }
/* Section label heads the workspace list below the action buttons. Aligns
with the rows' leading inset (--sb-pad-x) so it reads as the list's title. */
@ -985,9 +1056,9 @@ onBeforeUnmount(() => {
justify-content: space-between;
gap: 8px;
padding: 0 var(--space-3) var(--space-1) var(--space-2);
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: .08em;
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-regular);
text-transform: uppercase;
color: var(--faint);
user-select: none;

View file

@ -27,8 +27,6 @@ const props = defineProps<{
wsMenuOpenId: string | null;
/** True while this group is the active drag source (drag-to-reorder). */
dragging: boolean;
/** When true, render the workspace root path as a stable subtitle line. */
showPath: boolean;
isCollapsed: (id: string) => boolean;
/** When true, render all loaded sessions; otherwise only the first page
* (`group.initialCount`). Drives the in-group show-more / show-less toggle. */
@ -109,7 +107,7 @@ function onHeaderDragStart(event: DragEvent): void {
<div class="group" :class="{ dragging }">
<div
class="gh"
:class="{ on: group.workspace.id === activeWorkspaceId, collapsed: isCollapsed(group.workspace.id), 'show-path': showPath }"
:class="{ on: group.workspace.id === activeWorkspaceId, collapsed: isCollapsed(group.workspace.id) }"
draggable="true"
@click.stop="emit('groupClick', group.workspace.id, $event)"
@contextmenu="emit('groupContextmenu', group.workspace, $event)"
@ -118,14 +116,13 @@ function onHeaderDragStart(event: DragEvent): void {
>
<div class="gh-top">
<!-- Folder icon -->
<Icon v-if="isCollapsed(group.workspace.id)" class="gh-folder" name="folder-closed" size="sm" />
<Icon v-else class="gh-folder" name="folder" size="sm" />
<Icon v-if="isCollapsed(group.workspace.id)" class="gh-folder" name="folder-closed" />
<Icon v-else class="gh-folder" name="folder" />
<!-- Workspace name -->
<span
v-if="renamingId !== group.workspace.id"
class="gh-name"
>{{ group.workspace.name }}</span>
<!-- Workspace name hover reveals the full root path -->
<Tooltip v-if="renamingId !== group.workspace.id" :text="group.workspace.root">
<span class="gh-name">{{ group.workspace.name }}</span>
</Tooltip>
<input
v-else
:ref="setRenameInputRef"
@ -138,31 +135,36 @@ function onHeaderDragStart(event: DragEvent): void {
@click.stop
/>
<IconButton
class="gh-more"
<!-- Hover actions float over the row's right edge (no reserved
layout space, the name gets the full row width when idle). Hidden
while renaming so the floating buttons can't cover the input. -->
<div
v-if="renamingId !== group.workspace.id"
class="gh-actions"
:class="{ open: wsMenuOpenId === group.workspace.id }"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="wsMenuOpenId === group.workspace.id"
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
>
<Icon name="dots-horizontal" size="sm" />
</IconButton>
<IconButton
class="gh-more"
:class="{ open: wsMenuOpenId === group.workspace.id }"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="wsMenuOpenId === group.workspace.id"
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
>
<Icon name="dots-horizontal" />
</IconButton>
<IconButton
class="gh-add"
size="sm"
:label="t('workspace.newInGroup')"
@click.stop="emit('createInWorkspace', group.workspace.id)"
>
<Icon name="chat-new" />
</IconButton>
<IconButton
class="gh-add"
size="sm"
:label="t('workspace.newInGroup')"
@click.stop="emit('createInWorkspace', group.workspace.id)"
>
<Icon name="chat-new" />
</IconButton>
</div>
</div>
<Tooltip :text="group.workspace.root">
<div class="gh-path">{{ group.workspace.shortPath || group.workspace.root }}</div>
</Tooltip>
</div>
<div
class="group-sessions"
@ -212,8 +214,8 @@ function onHeaderDragStart(event: DragEvent): void {
<style scoped>
/* Workspace group. The --sb-* custom properties are inherited from .side in
Sidebar.vue, so they don't need to be redeclared here. */
.group { padding-bottom: var(--space-2); }
Sidebar.vue, so they don't need to be redeclared here. Groups stack flush
no bottom gap. */
.group.dragging { opacity: 0.45; }
/* Session list: collapses/expands via a height transition. `interpolate-size:
@ -230,15 +232,14 @@ function onHeaderDragStart(event: DragEvent): void {
}
/* Workspace header an inset rounded row that mirrors the session-row inset
(6px margin + 10px padding), so the folder icon lands at --sb-pad-x and the
name lines up with the session titles below. Hover washes the whole header
(name row + path) in the sunken surface. */
(container --sb-inset + row padding), so the folder icon lands at --sb-pad-x
and the name lines up with the session titles below. Hover washes the whole
header in the row hover fill. */
.gh {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: var(--space-1) var(--space-2);
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--text-xs);
@ -249,24 +250,28 @@ function onHeaderDragStart(event: DragEvent): void {
cursor: grab;
}
.gh:active { cursor: grabbing; }
.gh:hover { background: var(--color-surface-sunken); }
.gh:hover { background: var(--sb-hover, var(--color-surface-sunken)); }
.gh-top {
position: relative;
display: flex;
align-items: center;
gap: var(--sb-gap);
/* Header height is font-driven: name line-height (13×1.2516px) + 2×5px
.gh padding 26px. The floating .gh-actions never contribute to height. */
}
.gh-folder {
flex: none;
color: var(--color-text-muted);
/* 14px icon + margin fills the --sb-gutter icon slot */
margin-right: calc(var(--sb-gutter) - 14px);
}
/* Group title quiet by design: regular weight (no bold), muted color (one
step lighter than the session titles), so group heads read as grouping
labels rather than list content. */
.gh-name {
font-size: var(--ui-font-size-lg);
font-weight: var(--weight-medium);
color: var(--color-text);
font-size: var(--ui-font-size-sm);
line-height: var(--leading-tight);
color: var(--color-text-muted);
flex: 1;
min-width: 0;
overflow: hidden;
@ -274,77 +279,86 @@ function onHeaderDragStart(event: DragEvent): void {
white-space: nowrap;
cursor: pointer;
}
.gh-path {
color: var(--color-text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: calc(var(--sb-gutter) + var(--sb-gap));
font-size: var(--ui-font-size-xs);
max-height: 0;
opacity: 0;
transition: max-height var(--duration-base) var(--ease-out),
opacity var(--duration-base) var(--ease-out);
}
/* Path subtitle revealed only when the section-header "show paths" toggle is
on (`.show-path`) or the group is collapsed. Driven by explicit toggle state
rather than hover/focus, so the header height never shifts under the pointer
(a11y: stable layout) and the path stays reachable for touch/keyboard users. */
.gh.show-path .gh-path,
.gh.collapsed .gh-path {
max-height: 1.4em;
opacity: 1;
}
/* More + add buttons — hidden until hover (or while the more menu is open /
focused). `.gh .gh-more` / `.gh .gh-add` out-specificity IconButton's display
so the hidden default wins. */
.gh .gh-more,
.gh .gh-add {
/* More + add buttons float over the row's right edge instead of reserving
layout space, so the name can use the full row width when idle (no
truncation caused by invisible buttons). Revealed on hover / keyboard focus
/ while the more menu is open; the backing stacks the row hover wash on the
sidebar surface so the overlapped title tail doesn't bleed through. */
.gh-actions {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: var(--space-1);
padding-left: var(--space-1);
border-radius: var(--radius-sm);
isolation: isolate;
/* Opaque sidebar surface hides the overlapped name tail. The ::after
hover wash sits above this (still behind the buttons) so the layer reads
seamless with the row. */
background: var(--color-sidebar-bg);
opacity: 0;
pointer-events: none;
}
.gh:hover .gh-more,
.gh:hover .gh-add,
.gh:focus-within .gh-more,
.gh:focus-within .gh-add,
.gh-more.open,
.gh-more:focus-visible,
.gh-add:focus-visible {
/* Row hover wash only while the row is actually hovered. Painted above the
element background (z-index 0) but below the buttons (z-index 1). */
.gh-actions::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
border-radius: var(--radius-sm);
background: transparent;
}
.gh:hover .gh-actions::after {
background: var(--sb-hover, var(--color-surface-sunken));
}
.gh-actions > * {
position: relative;
z-index: 1;
}
.gh:hover .gh-actions,
.gh:focus-within .gh-actions,
.gh-actions.open {
opacity: 1;
pointer-events: auto;
}
.gh-more.open { color: var(--color-text); background: var(--color-line); }
.group-empty {
padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) + var(--sb-gutter) + var(--sb-gap));
/* Left padding lands the text at the same x as session titles / the
show-more label: (pad-x inset) row padding + gutter + gap. */
padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) - var(--sb-inset) + var(--sb-gutter) + var(--sb-gap));
font-size: var(--text-xs);
color: var(--color-text-faint);
font-family: var(--font-mono);
font-family: var(--font-ui);
}
/* Show-more / show-less a session-row-shaped compact list control (§07). The
empty lead slot mirrors a session row's status gutter, so the label text lands
at the exact same x as the session titles (--sb-pad-x + --sb-gutter + --sb-gap
from the sidebar edge). Hover washes the row in the sunken surface, matching
New chat / session rows; no text recolor. */
from the sidebar edge). Hover washes the row in the shared row hover fill,
matching New chat / session rows; no text recolor. */
.show-more {
display: flex;
align-items: center;
gap: var(--sb-gap);
width: 100%;
min-height: 26px;
margin: 0;
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
line-height: var(--leading-tight);
text-align: left;
cursor: pointer;
}
.show-more:hover { background: var(--color-surface-sunken); }
.show-more:hover { background: var(--sb-hover, var(--color-surface-sunken)); }
.show-more:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.show-more-lead { width: var(--sb-gutter); flex: none; }
.show-more-label {

View file

@ -10,6 +10,7 @@ import Badge from '../ui/Badge.vue';
import Button from '../ui/Button.vue';
import IconButton from '../ui/IconButton.vue';
import Icon from '../ui/Icon.vue';
import Kbd from '../ui/Kbd.vue';
import Tooltip from '../ui/Tooltip.vue';
const props = defineProps<{
@ -314,20 +315,20 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
:loading="pendingAction === `option:${opt.label}`"
:disabled="busy"
@click="approveOption(opt.label)"
>{{ opt.label }}<span class="k">[{{ i + 1 }}]</span></Button>
>{{ opt.label }}<Kbd class="k" :keys="[String(i + 1)]" /></Button>
</Tooltip>
</template>
<Button v-else class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approvePlan'" :disabled="busy" @click="approvePlan">{{ t('approval.approvePlan') }}<span class="k">[1]</span></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="revisePlan">{{ t('approval.revise') }}<span v-if="planReview.options.length === 0" class="k">[2]</span></Button>
<Button class="kbtn" size="sm" variant="danger-soft" :loading="pendingAction === 'rejectAndExit'" :disabled="busy" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<span v-if="planReview.options.length === 0" class="k">[3]</span></Button>
<Button v-else class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approvePlan'" :disabled="busy" @click="approvePlan">{{ t('approval.approvePlan') }}<Kbd class="k" :keys="['1']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="revisePlan">{{ t('approval.revise') }}<Kbd v-if="planReview.options.length === 0" class="k" :keys="['2']" /></Button>
<Button class="kbtn" size="sm" variant="danger-soft" :loading="pendingAction === 'rejectAndExit'" :disabled="busy" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<Kbd v-if="planReview.options.length === 0" class="k" :keys="['3']" /></Button>
</div>
<!-- default actions row -->
<div v-else class="abtn">
<Button class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approve'" :disabled="busy" @click="approve">{{ t('approval.approve') }}<span class="k">[1]</span></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'approveSession'" :disabled="busy" @click="approveSession">{{ t('approval.approveSession') }}<span class="k">[2]</span></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'reject'" :disabled="busy" @click="reject">{{ t('approval.reject') }}<span class="k">[3]</span></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="openFeedback">{{ t('approval.feedback') }}<span class="k">[4]</span></Button>
<Button class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approve'" :disabled="busy" @click="approve">{{ t('approval.approve') }}<Kbd class="k" :keys="['1']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'approveSession'" :disabled="busy" @click="approveSession">{{ t('approval.approveSession') }}<Kbd class="k" :keys="['2']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'reject'" :disabled="busy" @click="reject">{{ t('approval.reject') }}<Kbd class="k" :keys="['3']" /></Button>
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="openFeedback">{{ t('approval.feedback') }}<Kbd class="k" :keys="['4']" /></Button>
</div>
</template>
</Card>
@ -554,7 +555,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
width: 100%;
}
.plan-actions { flex-wrap: wrap; }
.k { margin-left: var(--space-2); font: var(--text-xs) var(--font-mono); opacity: .7; }
.k { opacity: .75; }
/* =========================================================================
MOBILE (640px): the card spans the full chat column, inner previews scroll

View file

@ -251,6 +251,7 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
:plan-mode="planMode"
:swarm-mode="swarmMode"
:goal-mode="goalMode"
:goal="goal"
:activation-badges="activationBadges"
:models="models"
:starred-ids="starredIds"

View file

@ -51,6 +51,25 @@ const behind = computed(() => props.behind ?? 0);
const adds = computed(() => props.gitDiffStats?.totalAdditions ?? 0);
const dels = computed(() => props.gitDiffStats?.totalDeletions ?? 0);
const hasLineStats = computed(() => adds.value > 0 || dels.value > 0);
const PR_STATE_LABEL_KEYS: Record<string, string> = {
open: 'header.prStatusOpen',
closed: 'header.prStatusClosed',
merged: 'header.prStatusMerged',
draft: 'header.prStatusDraft',
};
function normalizedPrState(state: string): string {
return state.trim().toLowerCase().replaceAll('_', '-');
}
function prStateClass(state: string): string {
const stateClass = normalizedPrState(state);
return PR_STATE_LABEL_KEYS[stateClass] ? `pr-${stateClass}` : 'pr-unknown';
}
function prStateLabel(state: string): string {
return t(PR_STATE_LABEL_KEYS[normalizedPrState(state)] ?? 'header.prStatusUnknown');
}
// ---------------------------------------------------------------------------
// More-menu (kebab dropdown)
@ -295,11 +314,11 @@ async function startArchive(): Promise<void> {
v-if="pr"
type="button"
class="ch-pill ch-pr"
:class="`pr-${pr.state}`"
:class="prStateClass(pr.state)"
@click="pr && emit('openPr', pr.url)"
>
<Icon name="git-pull-request" size="sm" />
<span>PR #{{ pr.number }} · {{ pr.state }}</span>
<span>PR #{{ pr.number }} · {{ prStateLabel(pr.state) }}</span>
</button>
</header>
@ -420,6 +439,7 @@ async function startArchive(): Promise<void> {
.ch-pr.pr-merged { color: var(--color-done); border-color: var(--color-done-bd); background: var(--color-done-soft); }
.ch-pr.pr-closed { color: var(--color-danger); border-color: var(--color-danger-bd); background: var(--color-danger-soft); }
.ch-pr.pr-draft { color: var(--color-text-muted); border-color: var(--color-line-strong); background: var(--color-surface-sunken); }
.ch-pr.pr-unknown { color: var(--color-text-muted); border-color: var(--color-line-strong); background: var(--color-surface-sunken); }
.ch-pr:hover { border-color: var(--color-line-strong); }
/* Fixed more-menu, anchored to the kebab trigger. Surface / items come from

View file

@ -1,5 +1,6 @@
<!-- apps/kimi-web/src/components/chat/Composer.vue -->
<script setup lang="ts">
import { measureNaturalWidth, prepareWithSegments } from '@chenglou/pretext';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import SlashMenu from './SlashMenu.vue';
@ -7,7 +8,7 @@ import MentionMenu from './MentionMenu.vue';
import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands';
import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
import type { AppModel, AppSkill, ThinkingLevel } from '../../api/types';
import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types';
import {
coerceThinkingForModel,
commitLevel,
@ -22,6 +23,7 @@ import { useMentionMenu } from '../../composables/useMentionMenu';
import { useComposerDraft } from '../../composables/useComposerDraft';
import { useAttachmentUpload } from '../../composables/useAttachmentUpload';
import Spinner from '../ui/Spinner.vue';
import Button from '../ui/Button.vue';
import IconButton from '../ui/IconButton.vue';
import Icon from '../ui/Icon.vue';
import ContextRing from '../ui/ContextRing.vue';
@ -45,6 +47,7 @@ const props = withDefaults(defineProps<{
planMode?: boolean;
swarmMode?: boolean;
goalMode?: boolean;
goal?: AppGoal | null;
activationBadges?: ActivationBadges;
/** Available models for the quick-switch dropdown. */
models?: AppModel[];
@ -94,7 +97,7 @@ const emit = defineEmits<{
selectModel: [modelId: string];
}>();
const { t } = useI18n();
const { t, locale } = useI18n();
// ---------------------------------------------------------------------------
// Textarea + per-session draft persistence see useComposerDraft.
@ -646,12 +649,20 @@ function setThinkingSegment(draft: string): void {
if (thinkingReadonly.value) return;
emit('setThinking', commitLevel(currentModel.value, draft));
}
function thinkingSegmentLabel(segment: string): string {
if (segment === 'on') return t('status.thinkingOn');
if (segment === 'off') return t('status.thinkingOff');
return effortLabel(segment);
}
// Plan toggle
const planOn = computed(() => props.planMode === true);
const swarmOn = computed(() => props.swarmMode === true);
const goalActive = computed(() => props.activationBadges?.goal !== null);
const goalStatus = computed(() => props.goal?.status ?? props.activationBadges?.goal?.status ?? null);
const goalActive = computed(() => goalStatus.value !== null && goalStatus.value !== 'complete');
const goalArmed = computed(() => goalActive.value || props.goalMode === true);
const goalCanPause = computed(() => goalStatus.value === 'active');
const goalCanResume = computed(() => goalStatus.value === 'paused' || goalStatus.value === 'blocked');
// Modes selector (plan / goal / swarm) the popover that replaces the bare
// "plan" pill. Plan/Swarm are real client toggles; goal reflects agent-driven
@ -696,6 +707,87 @@ const PERM_MODES: { mode: PermissionMode; color: string; labelKey: string; descK
{ mode: 'yolo', color: 'var(--color-warning)', labelKey: 'status.permissionYolo', descKey: 'status.permissionYoloDesc' },
{ mode: 'auto', color: 'var(--color-danger)', labelKey: 'status.permissionAuto', descKey: 'status.permissionAutoDesc' },
];
const MODE_DESC_KEYS = ['status.planDesc', 'status.swarmDesc', 'status.goalDesc'] as const;
const menuMeasureRef = ref<HTMLElement | null>(null);
const permissionDescriptionWidth = ref('');
const modeDescriptionWidth = ref('');
function menuDescStyle(width: string): Record<string, string> {
const style: Record<string, string> = {};
if (width) style['--composer-menu-desc-width'] = width;
return style;
}
const permissionMenuStyle = computed<Record<string, string>>(() => menuDescStyle(permissionDescriptionWidth.value));
const modeMenuMeasureStyle = computed<Record<string, string>>(() => menuDescStyle(modeDescriptionWidth.value));
const modesMenuInlineStyle = computed<Record<string, string>>(() => ({
...modesMenuStyle.value,
...modeMenuMeasureStyle.value,
}));
let menuMeasureFrame: number | null = null;
function cssPx(value: string): number {
const n = Number.parseFloat(value);
return Number.isFinite(n) ? n : 0;
}
function canvasFont(style: CSSStyleDeclaration): string {
return `${style.fontStyle || 'normal'} ${style.fontWeight || '400'} ${style.fontSize} ${style.fontFamily}`;
}
function letterSpacingPx(style: CSSStyleDeclaration): number {
return style.letterSpacing === 'normal' ? 0 : cssPx(style.letterSpacing);
}
function measureTextWidth(text: string, style: CSSStyleDeclaration): number {
if (!text) return 0;
const prepared = prepareWithSegments(text, canvasFont(style), {
letterSpacing: letterSpacingPx(style),
});
return measureNaturalWidth(prepared);
}
function measureMenuDescriptions(): void {
const probe = menuMeasureRef.value?.querySelector<HTMLElement>('.pd-desc');
if (!probe) return;
const style = getComputedStyle(probe);
const permissionWidth = Math.max(
0,
...PERM_MODES.map((opt) => measureTextWidth(t(opt.descKey), style)),
);
const modeWidth = Math.max(
0,
...MODE_DESC_KEYS.map((key) => measureTextWidth(t(key), style)),
);
permissionDescriptionWidth.value = permissionWidth > 0 ? `${Math.ceil(permissionWidth)}px` : '';
modeDescriptionWidth.value = modeWidth > 0 ? `${Math.ceil(modeWidth)}px` : '';
}
function scheduleMenuDescriptionMeasure(): void {
if (typeof window === 'undefined') return;
if (menuMeasureFrame !== null) {
window.cancelAnimationFrame(menuMeasureFrame);
}
void nextTick(() => {
menuMeasureFrame = window.requestAnimationFrame(() => {
menuMeasureFrame = null;
measureMenuDescriptions();
});
});
}
watch(locale, scheduleMenuDescriptionMeasure, { immediate: true });
onMounted(() => {
scheduleMenuDescriptionMeasure();
void document.fonts?.ready.then(scheduleMenuDescriptionMeasure);
});
onUnmounted(() => {
if (menuMeasureFrame !== null) {
window.cancelAnimationFrame(menuMeasureFrame);
menuMeasureFrame = null;
}
});
function choosePermission(mode: PermissionMode): void {
emit('setPermission', mode);
@ -853,6 +945,10 @@ function selectModel(modelId: string): void {
<!-- Bottom toolbar split into individual controls -->
<div ref="toolbarRef" class="toolbar">
<div ref="menuMeasureRef" class="menu-measure" aria-hidden="true">
<span class="pd-desc" />
</div>
<!-- Left: attach + permission + plan -->
<div class="toolbar-left">
<IconButton
@ -877,7 +973,13 @@ function selectModel(modelId: string): void {
>{{ permLabel }}</span>
<!-- Permission dropdown anchored to the toolbar left side -->
<div v-if="permDropdownOpen && status" class="perm-dropdown" role="menu" @click.stop>
<div
v-if="permDropdownOpen && status"
class="perm-dropdown"
:style="permissionMenuStyle"
role="menu"
@click.stop
>
<button
v-for="opt in PERM_MODES"
:key="opt.mode"
@ -908,15 +1010,23 @@ function selectModel(modelId: string): void {
<span v-if="goalArmed" class="mode-tag">{{ t('status.goalLabel') }}</span>
</button>
<div v-if="modesOpen" ref="modesMenuRef" class="modes-menu" :style="modesMenuStyle">
<div v-if="modesOpen" ref="modesMenuRef" class="modes-menu" :style="modesMenuInlineStyle" role="menu">
<!-- Plan functional client toggle -->
<button type="button" class="mode-row" :class="{ on: planOn }" @click="emit('togglePlan')">
<span class="mode-row-name">{{ t('status.planLabel') }}</span>
<button type="button" class="mode-row" :class="{ on: planOn }" role="menuitem" @click="emit('togglePlan')">
<span class="mode-row-icon"><Icon name="file-edit" size="sm" /></span>
<span class="mode-row-info">
<span class="mode-row-name">{{ t('status.planLabel') }}</span>
<span class="mode-row-desc">{{ t('status.planDesc') }}</span>
</span>
<span class="mode-switch" :class="{ on: planOn }"><span class="mode-knob" /></span>
</button>
<!-- Swarm functional client toggle -->
<button type="button" class="mode-row" :class="{ on: swarmOn }" @click="emit('toggleSwarm')">
<span class="mode-row-name">{{ t('status.swarmLabel') }}</span>
<button type="button" class="mode-row" :class="{ on: swarmOn }" role="menuitem" @click="emit('toggleSwarm')">
<span class="mode-row-icon"><Icon name="sparkles" size="sm" /></span>
<span class="mode-row-info">
<span class="mode-row-name">{{ t('status.swarmLabel') }}</span>
<span class="mode-row-desc">{{ t('status.swarmDesc') }}</span>
</span>
<span class="mode-switch" :class="{ on: swarmOn }"><span class="mode-knob" /></span>
</button>
<!-- Goal lifecycle controls when active; switch is on when active or armed. -->
@ -924,22 +1034,46 @@ function selectModel(modelId: string): void {
<button
type="button"
class="mode-row-main"
@click="goalActive ? emit('controlGoal', 'cancel') : emit('toggleGoal')"
role="menuitem"
@click="goalActive ? emit('focusGoal') : emit('toggleGoal')"
>
<span class="mode-row-name">{{ t('status.goalLabel') }}</span>
<span class="mode-row-icon"><Icon name="target" size="sm" /></span>
<span class="mode-row-info">
<span class="mode-row-name">{{ t('status.goalLabel') }}</span>
<span class="mode-row-desc">{{ t('status.goalDesc') }}</span>
</span>
<span v-if="!goalActive" class="mode-switch" :class="{ on: props.goalMode }"><span class="mode-knob" /></span>
</button>
<div v-if="goalActive" class="mode-row-actions">
<button
type="button"
<Button
v-if="goalCanPause"
size="sm"
variant="secondary"
class="mode-row-action"
@click="emit('controlGoal', 'pause')"
>{{ t('status.goalPause') }}</button>
<button
type="button"
>
<Icon name="pause" size="sm" />
<span>{{ t('status.goalPause') }}</span>
</Button>
<Button
v-if="goalCanResume"
size="sm"
variant="primary"
class="mode-row-action"
@click="emit('controlGoal', 'resume')"
>{{ t('status.goalResume') }}</button>
>
<Icon name="play" size="sm" />
<span>{{ t('status.goalResume') }}</span>
</Button>
<Button
size="sm"
variant="danger-soft"
class="mode-row-action"
@click="emit('controlGoal', 'cancel')"
>
<Icon name="close" size="sm" />
<span>{{ t('status.goalCancel') }}</span>
</Button>
</div>
</div>
</div>
@ -1063,7 +1197,7 @@ function selectModel(modelId: string): void {
:class="{ 'is-active': seg === activeThinkingSegment }"
:disabled="thinkingReadonly"
@click="setThinkingSegment(seg)"
>{{ effortLabel(seg) }}</button>
>{{ thinkingSegmentLabel(seg) }}</button>
</div>
</div>
@ -1092,9 +1226,11 @@ function selectModel(modelId: string): void {
/* Main composer card */
.composer-card {
--composer-send-size: 32px;
--composer-send-inset: var(--space-2);
position: relative;
border: 1px solid var(--line);
border-radius: 16px;
border-radius: calc((var(--composer-send-size) / 2) + var(--composer-send-inset));
background: var(--bg);
box-shadow: var(--shadow-md);
transition: border-color 0.15s, box-shadow 0.15s;
@ -1358,8 +1494,8 @@ function selectModel(modelId: string): void {
(handled upstream). Interrupt is a separate Stop button so the two are never
confused. */
.send {
width: 32px;
height: 32px;
width: var(--composer-send-size);
height: var(--composer-send-size);
border-radius: 50%;
background: var(--color-accent);
color: var(--color-text-on-accent); /* white on accent — readable in light and dark */
@ -1386,14 +1522,16 @@ function selectModel(modelId: string): void {
.send svg {
flex: none;
width: var(--p-ic-lg);
height: var(--p-ic-lg);
}
/* Stop button sibling of Send, shown only while running. Red at rest so the
destructive action is easy to spot; fills solid danger on hover. Kept softer
than the accent Send so Send stays the primary action. */
.stop {
width: 32px;
height: 32px;
width: var(--composer-send-size);
height: var(--composer-send-size);
border-radius: 50%;
background: var(--color-danger-soft);
color: var(--color-danger);
@ -1418,6 +1556,8 @@ function selectModel(modelId: string): void {
}
.stop svg {
flex: none;
width: var(--p-ic-lg);
height: var(--p-ic-lg);
}
/* Bottom toolbar */
@ -1425,10 +1565,19 @@ function selectModel(modelId: string): void {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px 8px;
padding: 6px var(--composer-send-inset) var(--composer-send-inset);
position: relative;
}
.menu-measure {
position: absolute;
width: max-content;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.toolbar-left,
.toolbar-right {
display: flex;
@ -1450,7 +1599,8 @@ function selectModel(modelId: string): void {
cursor: pointer;
user-select: none;
transition: background 0.1s, color 0.15s;
font-family: var(--sans);
font-family: var(--font-ui);
font-weight: var(--weight-medium);
}
.perm-pill:hover {
background: var(--color-surface-sunken);
@ -1486,7 +1636,10 @@ function selectModel(modelId: string): void {
.ctx-num {
font-size: var(--ui-font-size);
color: var(--muted);
font-family: var(--mono);
font-family: var(--font-ui);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
letter-spacing: 0;
line-height: 16px;
}
@ -1498,8 +1651,10 @@ function selectModel(modelId: string): void {
padding: 2px 7px;
border-radius: 6px;
font-size: var(--ui-font-size);
line-height: 16px;
line-height: var(--leading-normal);
color: var(--dim);
font-family: var(--font-ui);
font-weight: var(--weight-medium);
cursor: pointer;
user-select: none;
transition: background 0.1s;
@ -1551,15 +1706,16 @@ function selectModel(modelId: string): void {
display: flex;
flex-direction: column;
gap: 1px;
font-family: var(--font-ui);
}
.md-section {
padding: 4px 7px 2px;
font-size: var(--ui-font-size);
font-size: var(--text-xs);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
letter-spacing: 0;
font-weight: var(--weight-semibold);
}
.md-row {
@ -1570,7 +1726,7 @@ function selectModel(modelId: string): void {
background: none;
border: none;
cursor: pointer;
font-family: var(--mono);
font-family: var(--font-ui);
font-size: var(--ui-font-size);
color: var(--color-text);
padding: 5px 7px;
@ -1637,7 +1793,7 @@ function selectModel(modelId: string): void {
border-radius: var(--radius-sm);
}
.md-thinking .md-name {
font-family: var(--mono);
font-family: var(--font-ui);
font-size: var(--ui-font-size);
color: var(--color-text);
flex: none;
@ -1660,7 +1816,7 @@ function selectModel(modelId: string): void {
border: none;
background: none;
cursor: pointer;
font-family: var(--mono);
font-family: var(--font-ui);
font-size: var(--ui-font-size-xs);
line-height: 1;
color: var(--color-text-muted);
@ -1702,7 +1858,8 @@ function selectModel(modelId: string): void {
left: 10px;
z-index: var(--z-dropdown);
min-width: 220px;
max-width: 280px;
width: max-content;
max-width: calc(100vw - var(--space-8));
background: var(--color-surface-raised);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
@ -1714,9 +1871,11 @@ function selectModel(modelId: string): void {
}
.pd-row {
display: flex;
align-items: flex-start;
gap: 7px;
display: grid;
grid-template-columns: 14px var(--composer-menu-desc-width, max-content);
column-gap: 7px;
row-gap: 2px;
align-items: start;
width: 100%;
background: none;
border: none;
@ -1729,34 +1888,41 @@ function selectModel(modelId: string): void {
.pd-row.is-current { background: var(--color-accent-soft); }
.pd-check {
grid-column: 1;
grid-row: 1;
width: 14px;
flex: none;
min-height: 1lh;
color: var(--color-accent);
font-weight: 500;
font-size: var(--ui-font-size);
font-weight: var(--weight-medium);
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
line-height: var(--leading-normal);
}
.pd-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
display: contents;
}
.pd-name {
font-family: var(--sans);
grid-column: 2;
grid-row: 1;
font-family: var(--font-ui);
font-size: var(--ui-font-size);
font-weight: 500;
font-weight: var(--weight-medium);
line-height: var(--leading-normal);
}
.pd-desc {
font-family: var(--sans);
font-size: var(--ui-font-size);
grid-column: 2;
grid-row: 2;
width: var(--composer-menu-desc-width, auto);
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--muted);
line-height: 1.4;
line-height: var(--leading-normal);
}
/* Toggle pills (Thinking / Plan) */
@ -1773,7 +1939,8 @@ function selectModel(modelId: string): void {
background: none;
border-radius: 6px;
font-size: var(--ui-font-size);
font-family: var(--sans);
font-family: var(--font-ui);
font-weight: var(--weight-medium);
color: var(--color-text);
cursor: pointer;
user-select: none;
@ -1785,7 +1952,7 @@ function selectModel(modelId: string): void {
.mode-label { flex: none; }
.mode-tag {
flex: none;
font-family: var(--mono);
font-family: var(--font-ui);
font-size: calc(var(--ui-font-size) - 3px);
color: var(--color-accent-hover);
background: var(--bg);
@ -1800,39 +1967,82 @@ function selectModel(modelId: string): void {
position: fixed;
z-index: var(--z-dropdown);
min-width: 220px;
width: max-content;
max-width: calc(100vw - var(--space-8));
background: var(--color-surface-raised);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 4px;
padding: 5px;
display: flex;
flex-direction: column;
gap: 1px;
}
.mode-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
display: grid;
grid-template-columns: 14px var(--composer-menu-desc-width, max-content);
column-gap: 7px;
row-gap: 2px;
align-items: start;
width: 100%;
padding: 7px 10px;
padding: 6px 7px;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
font-family: var(--sans);
font-family: var(--font-ui);
text-align: left;
}
.mode-row:hover:not(:disabled) { background: var(--panel2); }
.mode-row:hover:not(:disabled) { background: var(--color-surface-sunken); }
.mode-row:disabled { cursor: not-allowed; opacity: 0.45; }
.mode-row-name { font-size: var(--ui-font-size-sm); color: var(--color-text); }
.mode-row-info {
display: contents;
}
.mode-row-icon {
grid-column: 1;
grid-row: 1;
width: 14px;
min-height: 1lh;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: var(--ui-font-size);
line-height: var(--leading-normal);
}
.mode-row-name {
grid-column: 2;
grid-row: 1;
font-size: var(--ui-font-size);
font-weight: var(--weight-medium);
color: var(--color-text);
line-height: var(--leading-normal);
}
.mode-row-desc {
grid-column: 2;
grid-row: 2;
width: var(--composer-menu-desc-width, auto);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--muted);
line-height: var(--leading-normal);
}
.mode-row-not-supported {
margin-left: auto;
font-size: var(--ui-font-size-xs);
color: var(--muted);
}
.mode-row.on .mode-row-name { color: var(--color-accent-hover); font-weight: 500; }
.mode-row.on {
background: var(--color-accent-soft);
}
.mode-row.on .mode-row-name { color: var(--color-accent-hover); }
.mode-row.on .mode-row-icon { color: var(--color-accent-hover); }
.mode-row-meta { font-family: var(--mono); font-size: calc(var(--ui-font-size) - 3px); color: var(--muted); }
.mode-row:disabled .mode-row-meta { color: var(--faint); }
.mode-switch {
flex: none;
grid-column: 2;
grid-row: 1;
justify-self: end;
width: 34px;
height: 19px;
border-radius: 999px;
@ -1856,45 +2066,49 @@ function selectModel(modelId: string): void {
.mode-switch.on .mode-knob { transform: translateX(15px); }
.mode-row-goal {
flex-wrap: wrap;
--mode-row-icon-col: 14px;
--mode-row-col-gap: 7px;
--mode-row-pad-x: 7px;
display: flex;
flex-direction: column;
align-items: stretch;
cursor: default;
padding: 0;
gap: 0;
}
.mode-row-goal:hover { background: transparent; }
.mode-row-goal.on {
background: var(--color-accent-soft);
}
.mode-row-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
display: grid;
grid-template-columns: var(--mode-row-icon-col) var(--composer-menu-desc-width, max-content);
column-gap: var(--mode-row-col-gap);
row-gap: 2px;
align-items: start;
width: 100%;
padding: 7px 10px;
padding: 6px var(--mode-row-pad-x);
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
font-family: var(--sans);
font-family: var(--font-ui);
text-align: left;
}
.mode-row-main:hover { background: var(--panel2); }
.mode-row-goal.on .mode-row-main .mode-row-name { color: var(--color-accent-hover); font-weight: 500; }
.mode-row-main:hover { background: var(--color-surface-sunken); }
.mode-row-goal.on .mode-row-main .mode-row-name { color: var(--color-accent-hover); }
.mode-row-actions {
display: flex;
gap: 6px;
flex: 1 1 100%;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2);
justify-content: flex-start;
padding: 0 var(--mode-row-pad-x) var(--mode-row-pad-x)
calc(var(--mode-row-pad-x) + var(--mode-row-icon-col) + var(--mode-row-col-gap));
}
.mode-row-action {
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: var(--panel);
color: var(--color-text);
font-size: calc(var(--ui-font-size) - 3px);
cursor: pointer;
flex: none;
}
.mode-row-action:hover:not(:disabled) { background: var(--panel2); }
.mode-row-action:disabled { opacity: 0.5; cursor: default; }
.mode-row-action :deep(.ui-button__content) { gap: var(--space-1); }
.mode-row-input {
flex: 1;
min-width: 0;
@ -1949,7 +2163,7 @@ function selectModel(modelId: string): void {
var(--dock-inline-left, max(12px, env(safe-area-inset-left)));
}
.composer-card {
border-radius: var(--radius-xl);
--composer-send-size: 36px;
max-width: 100%;
}
.input-row {
@ -1958,9 +2172,9 @@ function selectModel(modelId: string): void {
}
/* Send → 36px round (hide the SVG arrow, show only the ::after glyph) */
.send {
width: 36px;
height: 36px;
min-width: 36px;
width: var(--composer-send-size);
height: var(--composer-send-size);
min-width: var(--composer-send-size);
padding: 0;
border-radius: 50%;
font-size: 0;
@ -1979,9 +2193,9 @@ function selectModel(modelId: string): void {
}
/* Stop → 36px round "■" glyph to match the mobile Send sizing. */
.stop {
width: 36px;
height: 36px;
min-width: 36px;
width: var(--composer-send-size);
height: var(--composer-send-size);
min-width: var(--composer-send-size);
padding: 0;
border-radius: 50%;
font-size: 0;
@ -1994,7 +2208,7 @@ function selectModel(modelId: string): void {
.stop::after {
content: "■";
/* Fixed icon glyph size — not part of the UI font scale. */
font-size: 14px;
font-size: 17px;
line-height: 1;
}
@ -2065,7 +2279,7 @@ function selectModel(modelId: string): void {
font-size: var(--ui-font-size);
}
.pd-desc {
font-size: var(--ui-font-size);
font-size: var(--text-xs);
}
}

View file

@ -1,5 +1,6 @@
<!-- apps/kimi-web/src/components/chat/ConversationPane.vue -->
<script setup lang="ts">
import { measureNaturalWidth, prepareWithSegments } from '@chenglou/pretext';
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch, type ComponentPublicInstance } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoView, ToolMedia, UIQuestion, WorkspaceView } from '../../types';
@ -15,6 +16,8 @@ import Tooltip from '../ui/Tooltip.vue';
import { getVisibleWorkspaces } from '../../lib/workspacePicker';
import { safeRemove, STORAGE_KEYS } from '../../lib/storage';
const { t, locale } = useI18n();
const props = defineProps<{
turns: ChatTurn[];
sessionId?: string;
@ -134,6 +137,13 @@ const emit = defineEmits<{
// Empty-composer workspace picker.
const wsPickOpen = ref(false);
const wsPickExpanded = ref(false);
const contentWrapRef = ref<HTMLElement | null>(null);
const wsPickMeasureRef = ref<HTMLElement | null>(null);
const wsPickMenuWidth = ref<string>('');
const wsPickStyle = computed<Record<string, string> | undefined>(() =>
wsPickMenuWidth.value ? { '--ws-pick-menu-width': wsPickMenuWidth.value } : undefined,
);
let wsPickMeasureFrame: number | null = null;
const activeWorkspaceLabel = computed(() => {
const w = props.workspaces?.find((ws) => ws.id === props.activeWorkspaceId);
@ -161,7 +171,81 @@ function pickWorkspace(id: string): void {
if (id !== props.activeWorkspaceId) emit('selectWorkspace', id);
}
const { t } = useI18n();
function cssPx(value: string): number {
const n = Number.parseFloat(value);
return Number.isFinite(n) ? n : 0;
}
function canvasFont(style: CSSStyleDeclaration): string {
return `${style.fontStyle || 'normal'} ${style.fontWeight || '400'} ${style.fontSize} ${style.fontFamily}`;
}
function letterSpacingPx(style: CSSStyleDeclaration): number {
return style.letterSpacing === 'normal' ? 0 : cssPx(style.letterSpacing);
}
function measureText(text: string, style: CSSStyleDeclaration): number {
if (!text) return 0;
const prepared = prepareWithSegments(text, canvasFont(style), { letterSpacing: letterSpacingPx(style) });
return measureNaturalWidth(prepared);
}
function workspacePickerMaxWidth(): number {
const containerWidth = contentWrapRef.value?.getBoundingClientRect().width ?? window.innerWidth;
return Math.max(0, containerWidth * 0.75);
}
function updateWorkspacePickerWidth(): void {
const probe = wsPickMeasureRef.value;
if (!probe) return;
const itemPath = probe.querySelector<HTMLElement>('.ws-pick-item-path');
if (!itemPath) return;
const itemPathStyle = getComputedStyle(itemPath);
const measuredPathWidth = (props.workspaces ?? []).reduce(
(max, workspace) => Math.max(max, measureText(workspace.shortPath, itemPathStyle)),
0,
);
wsPickMenuWidth.value = measuredPathWidth
? `${Math.ceil(Math.min(measuredPathWidth, workspacePickerMaxWidth()))}px`
: '';
}
function scheduleWorkspacePickerMeasure(): void {
if (typeof window === 'undefined') return;
void nextTick(() => {
if (wsPickMeasureFrame !== null) window.cancelAnimationFrame(wsPickMeasureFrame);
wsPickMeasureFrame = window.requestAnimationFrame(() => {
wsPickMeasureFrame = null;
updateWorkspacePickerWidth();
});
});
}
watch(
() => [
props.activeWorkspaceId,
props.workspaceName,
props.workspaces?.map((w) => `${w.id}\u0000${w.name}\u0000${w.shortPath}`).join('\u0001') ?? '',
hiddenWorkspaceCount.value,
locale.value,
],
scheduleWorkspacePickerMeasure,
{ immediate: true },
);
onMounted(() => {
scheduleWorkspacePickerMeasure();
window.addEventListener('resize', scheduleWorkspacePickerMeasure);
void document.fonts?.ready.then(scheduleWorkspacePickerMeasure);
});
onUnmounted(() => {
if (wsPickMeasureFrame !== null) window.cancelAnimationFrame(wsPickMeasureFrame);
window.removeEventListener('resize', scheduleWorkspacePickerMeasure);
});
// The align toggle was removed with its UI (6e50cb7) reading layout is
// always centered now. Drop the old persisted preference so users who once
@ -1045,7 +1129,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
class="panes chat-scroll"
@scroll.passive="onPanesScroll"
>
<div class="content-wrap" :class="[mobile ? 'align-mobile' : 'align-center']">
<div ref="contentWrapRef" class="content-wrap" :class="[mobile ? 'align-mobile' : 'align-center']">
<template v-if="turns.length === 0 && !sessionLoading">
<!-- Empty session: Composer rendered in the centre of the pane -->
<div class="empty-spacer" />
@ -1053,7 +1137,27 @@ defineExpose({ loadComposerForEdit, focusComposer });
<span class="empty-hint-title">{{ t('composer.emptyConversationTitle') }}</span>
<span class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
<!-- Workspace picker: choose where this new conversation starts. -->
<div v-if="hasWorkspaces" class="ws-pick">
<div v-if="hasWorkspaces" class="ws-pick" :style="wsPickStyle">
<div ref="wsPickMeasureRef" class="ws-pick-measure" aria-hidden="true">
<button type="button" class="ws-pick-btn" tabindex="-1">
<Icon name="folder" size="sm" />
<span class="ws-pick-name">{{ activeWorkspaceLabel }}</span>
<Icon class="ws-pick-chev" name="chevron-down" size="sm" />
</button>
<div class="ws-pick-menu">
<button type="button" class="ws-pick-item" tabindex="-1">
<span class="ws-pick-item-name" />
<span class="ws-pick-item-path" />
</button>
<button type="button" class="ws-pick-item ws-pick-more" tabindex="-1">
<span>{{ t('conversation.moreWorkspaces', { count: hiddenWorkspaceCount }) }}</span>
</button>
<button type="button" class="ws-pick-action" tabindex="-1">
<Icon name="plus" size="sm" />
<span>{{ t('conversation.addWorkspace') }}</span>
</button>
</div>
</div>
<Tooltip :text="t('conversation.switchWorkspace')">
<button type="button" class="ws-pick-btn" @click.stop="wsPickOpen = !wsPickOpen">
<Icon name="folder" size="sm" />
@ -1116,6 +1220,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
:plan-mode="planMode"
:swarm-mode="swarmMode"
:goal-mode="goalMode"
:goal="goal"
:activation-badges="activationBadges"
:models="models"
:starred-ids="starredIds"
@ -1337,11 +1442,12 @@ defineExpose({ loadComposerForEdit, focusComposer });
text-align: center;
padding: 0 16px 16px;
color: var(--color-text);
font-family: var(--sans);
font-family: var(--font-ui);
}
.empty-hint-title {
font-size: calc(var(--ui-font-size) + 16px);
font-weight: 500;
font-optical-sizing: auto;
font-weight: 600;
}
.empty-hint-text {
display: inline-block;
@ -1382,13 +1488,31 @@ defineExpose({ loadComposerForEdit, focusComposer });
/* Empty-composer workspace picker */
.ws-pick {
position: relative;
font-family: var(--mono);
font-family: var(--font-ui);
}
.ws-pick-measure {
position: absolute;
visibility: hidden;
pointer-events: none;
width: max-content;
height: 0;
overflow: hidden;
}
.ws-pick-measure .ws-pick-menu {
position: static;
transform: none;
width: max-content;
min-width: 0;
max-width: none;
max-height: none;
overflow: visible;
}
.ws-pick-btn {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 320px;
width: max-content;
max-width: min(100%, calc(100vw - var(--space-8)));
padding: 5px 10px;
background: var(--panel);
border: 1px solid var(--line);
@ -1399,7 +1523,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
cursor: pointer;
}
.ws-pick-btn:hover { border-color: var(--color-accent-bd); color: var(--color-text); }
.ws-pick-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-pick-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-pick-chev { flex: none; color: var(--muted); transition: transform 0.15s; }
.ws-pick-chev.open { transform: rotate(180deg); }
.ws-pick-backdrop {
@ -1413,8 +1537,9 @@ defineExpose({ loadComposerForEdit, focusComposer });
transform: translateX(-50%);
top: calc(100% + 6px);
z-index: var(--z-dropdown);
min-width: 220px;
max-width: min(86vw, 340px);
width: var(--ws-pick-menu-width, max-content);
min-width: var(--ws-pick-menu-width, max-content);
max-width: calc(100vw - var(--space-8));
max-height: 50vh;
overflow-y: auto;
background: var(--color-surface-raised);
@ -1435,16 +1560,23 @@ defineExpose({ loadComposerForEdit, focusComposer });
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
font-family: var(--mono);
font-family: var(--font-ui);
}
.ws-pick-item:hover { background: var(--panel2); }
.ws-pick-item.on { background: var(--color-accent-soft); }
.ws-pick-item-name { font-size: var(--ui-font-size-sm); color: var(--color-text); }
.ws-pick-item.on .ws-pick-item-name { color: var(--color-accent-hover); font-weight: 500; }
.ws-pick-item-path { font-size: var(--text-base); color: var(--muted); }
.ws-pick-item-name {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--color-text);
}
.ws-pick-item.on .ws-pick-item-name { color: var(--color-accent-hover); }
.ws-pick-item-path { font-size: var(--text-xs); font-weight: 475; color: var(--muted); }
.ws-pick-item.ws-pick-more {
flex-direction: row;
justify-content: center;
align-items: center;
justify-content: flex-start;
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--dim);
}
.ws-pick-item.ws-pick-more:hover { color: var(--color-text); }
@ -1464,8 +1596,9 @@ defineExpose({ loadComposerForEdit, focusComposer });
border-radius: 6px;
padding: 7px 10px;
cursor: pointer;
font-family: var(--mono);
font-size: var(--ui-font-size-sm);
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--dim);
}
.ws-pick-action:hover { background: var(--panel2); color: var(--color-text); }
@ -1500,7 +1633,9 @@ defineExpose({ loadComposerForEdit, focusComposer });
font-size: var(--ui-font-size-sm);
cursor: pointer;
box-shadow: var(--shadow-sm);
z-index: var(--z-sticky);
/* Positioned after the message flow, so base z-index is enough to float above
content while staying below composer dropdowns. */
z-index: var(--z-base);
}
.newmsg-pill:hover { background: var(--panel2); }
.pill-chevron {

View file

@ -27,6 +27,15 @@ const tokenPct = computed(() => {
return Math.max(0, Math.min(100, Math.round((props.goal.tokensUsed / budget) * 100)));
});
function goalStatusLabel(status: AppGoal['status']): string {
switch (status) {
case 'active': return t('status.goalStatusActive');
case 'paused': return t('status.goalStatusPaused');
case 'blocked': return t('status.goalStatusBlocked');
case 'complete': return t('status.goalStatusComplete');
}
}
function formatMs(ms: number): string {
const sec = Math.max(0, Math.round(ms / 1000));
const min = Math.floor(sec / 60);
@ -42,13 +51,13 @@ function formatMs(ms: number): string {
<Card class="goal-strip" :class="{ expanded }">
<template #head>
<button class="goal-row" type="button" @click="expanded = !expanded">
<span class="goal-kicker">Goal</span>
<span class="goal-kicker">{{ t('status.goalLabel') }}</span>
<span class="goal-objective" :class="{ 'expanded-hidden': expanded }">{{ goal.objective }}</span>
<Badge
:variant="goal.status === 'active' ? 'success' : goal.status === 'blocked' ? 'danger' : goal.status === 'paused' ? 'warning' : 'neutral'"
size="sm"
class="goal-status"
>{{ goal.status }}</Badge>
>{{ goalStatusLabel(goal.status) }}</Badge>
<span class="goal-progress" aria-hidden="true">
<span class="goal-progress-fill" :style="{ width: `${tokenPct}%` }"></span>
</span>
@ -62,36 +71,47 @@ function formatMs(ms: number): string {
<span>Done when</span>
<p>{{ goal.completionCriterion }}</p>
</div>
<div class="goal-stats">
<Badge variant="neutral" size="sm">{{ goal.turnsUsed }} turns</Badge>
<Badge variant="neutral" size="sm">{{ goal.tokensUsed.toLocaleString() }} tokens</Badge>
<Badge variant="neutral" size="sm">{{ formatMs(goal.wallClockMs) }}</Badge>
<Badge v-if="goal.budget.tokenBudget !== null" variant="neutral" size="sm">{{ tokenPct }}% token budget</Badge>
</div>
</template>
<template v-if="expanded" #foot>
<div class="goal-actions">
<Button
v-if="goal.status !== 'paused'"
size="sm"
variant="secondary"
class="goal-action"
@click.stop="emit('controlGoal', 'pause')"
>{{ t('status.goalPause') }}</Button>
<Button
v-if="goal.status === 'paused'"
size="sm"
variant="primary"
class="goal-action"
@click.stop="emit('controlGoal', 'resume')"
>{{ t('status.goalResume') }}</Button>
<Button
size="sm"
variant="danger-soft"
class="goal-action"
@click.stop="emit('controlGoal', 'cancel')"
>{{ t('status.goalCancel') }}</Button>
<div class="goal-footer">
<div class="goal-meta">
<span>{{ goal.turnsUsed }} turns</span>
<span>{{ goal.tokensUsed.toLocaleString() }} tokens</span>
<span>{{ formatMs(goal.wallClockMs) }}</span>
<span v-if="goal.budget.tokenBudget !== null">{{ tokenPct }}% token budget</span>
</div>
<div class="goal-actions">
<Button
v-if="goal.status === 'active'"
size="sm"
variant="secondary"
class="goal-action"
@click.stop="emit('controlGoal', 'pause')"
>
<Icon name="pause" size="md" />
<span>{{ t('status.goalPause') }}</span>
</Button>
<Button
v-if="goal.status === 'paused' || goal.status === 'blocked'"
size="sm"
variant="primary"
class="goal-action"
@click.stop="emit('controlGoal', 'resume')"
>
<Icon name="play" size="md" />
<span>{{ t('status.goalResume') }}</span>
</Button>
<Button
size="sm"
variant="danger-soft"
class="goal-action"
@click.stop="emit('controlGoal', 'cancel')"
>
<Icon name="close" size="md" />
<span>{{ t('status.goalCancel') }}</span>
</Button>
</div>
</div>
</template>
</Card>
@ -99,8 +119,21 @@ function formatMs(ms: number): string {
<style scoped>
.goal-strip {
--composer-send-size: 32px;
--composer-send-inset: var(--space-2);
margin: var(--space-2) var(--space-4) 0;
}
.goal-strip.ui-card {
border-radius: calc((var(--composer-send-size) / 2) + var(--composer-send-inset));
}
.goal-strip :deep(.ui-card__foot) {
padding: var(--composer-send-inset);
}
.goal-strip :deep(.ui-card__head),
.goal-strip :deep(.ui-card__body),
.goal-strip :deep(.ui-card__foot) {
padding-left: calc((var(--composer-send-inset) + var(--composer-send-size)) / 2);
}
/* When collapsed the body/foot slots are not rendered; collapse the (always-
rendered) Card body and drop the head border so the strip is a single row. */
.goal-strip:not(.expanded) :deep(.ui-card__body) { display: none; }
@ -116,13 +149,14 @@ function formatMs(ms: number): string {
background: transparent;
color: var(--color-text);
font: var(--text-base)/var(--leading-normal) var(--font-ui);
text-align: left;
cursor: pointer;
}
.goal-kicker {
flex: none;
color: var(--color-success);
font: var(--weight-bold) var(--text-xs) var(--font-mono);
text-transform: uppercase;
font: var(--text-base)/var(--leading-normal) var(--font-ui);
font-weight: var(--weight-semibold);
}
.goal-objective {
min-width: 0;
@ -132,6 +166,7 @@ function formatMs(ms: number): string {
white-space: nowrap;
color: var(--color-text);
font-size: var(--text-base);
text-align: left;
}
.goal-objective.expanded-hidden {
visibility: hidden;
@ -183,25 +218,43 @@ function formatMs(ms: number): string {
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
text-transform: none;
}
.goal-stats {
.goal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
min-width: 0;
}
.goal-meta {
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
font: 12px/var(--leading-normal) var(--font-ui);
font-weight: 450;
font-variant-numeric: tabular-nums;
}
.goal-actions {
display: flex;
gap: var(--space-2);
width: 100%;
justify-content: flex-end;
flex: none;
}
.goal-action {
flex: 1;
flex: none;
min-width: 0;
height: var(--composer-send-size);
border-radius: calc(var(--composer-send-size) / 2);
padding-inline: var(--space-4);
}
.goal-action :deep(.ui-button__content) {
gap: var(--space-1);
}
@media (max-width: 640px) {
.goal-strip {
--composer-send-size: 36px;
margin: var(--space-2) var(--space-3) 0;
}
.goal-progress {

View file

@ -474,12 +474,12 @@ function copyDiff(code: string, idx: number) {
/* Base prose — assistant message text. */
.md {
font: 500 15px/1.6 var(--font-ui);
font: 400 15px/1.6 var(--font-ui);
color: var(--color-text);
word-break: break-word;
}
.md :deep(.markdown-renderer) {
font: 500 15px/1.6 var(--font-ui);
font: 400 15px/1.6 var(--font-ui);
color: var(--color-text);
}
.md :deep(.markstream-vue),
@ -523,8 +523,9 @@ function copyDiff(code: string, idx: number) {
font-size: var(--content-font-size);
}
/* Emphasis — bold steps up from the body (medium/500) to semibold (700). */
/* Emphasis — keep the weight strong, but soften the ink slightly. */
.md :deep(strong) {
color: color-mix(in srgb, var(--color-text) 86%, var(--color-text-muted));
font-weight: var(--weight-semibold);
}
@ -534,7 +535,8 @@ function copyDiff(code: string, idx: number) {
.md :deep(h3),
.md :deep(h4) {
color: var(--color-text);
font-weight: var(--weight-medium);
font-optical-sizing: auto;
font-weight: 600;
margin: 0.85em 0 0.35em;
line-height: var(--leading-tight);
}
@ -573,9 +575,15 @@ function copyDiff(code: string, idx: number) {
font: .9em var(--font-mono);
background: var(--color-surface-sunken);
color: var(--color-fg);
padding: 0 5px;
padding: 0 4px;
border-radius: var(--radius-sm);
}
.md :deep(strong code),
.md :deep(strong .inline-code),
.md :deep(b code),
.md :deep(b .inline-code) {
font-weight: var(--weight-semibold);
}
/* ---------------------------------------------------------------------------
Code blocks sunken surface, 1px line border, radius md, soft shadow, plus
@ -602,6 +610,9 @@ function copyDiff(code: string, idx: number) {
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
}
.md :deep(.code-block-header .code-header-main) {
font-family: var(--font-ui);
}
/* Copy button mirrors the §03 IconButton: muted glyph, sunken hover, soft
radius, and the shared focus ring. markstream renders its own button, so we
restyle it in place instead of swapping in the IconButton primitive. */

View file

@ -452,7 +452,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
}
.qstep {
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
font: var(--text-xs) var(--font-ui);
margin-left: var(--space-1);
}
/* Minimize toggle — pinned to the right of the header row. */
@ -482,6 +482,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
font-family: var(--font-ui);
}
.qstep-dot {
display: inline-flex;
@ -493,7 +494,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
border: 1px solid var(--color-line);
background: var(--color-surface);
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
font: var(--text-xs) var(--font-ui);
cursor: pointer;
padding: 0;
transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
@ -545,7 +546,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
.qopt-key {
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
font: var(--text-xs) var(--font-ui);
font-weight: var(--weight-medium);
width: 12px;
flex: none;
text-align: center;
@ -560,8 +562,16 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
flex-direction: column;
gap: 2px;
}
.qopt-label { color: var(--color-text); }
.qopt-desc { color: var(--color-text-muted); font: var(--text-xs)/var(--leading-normal) var(--font-ui); }
.qopt-label {
color: var(--color-text);
font-size: var(--text-base);
font-weight: var(--weight-medium);
}
.qopt-desc {
color: var(--color-text-muted);
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
font-weight: var(--weight-medium);
}
.chk, .rad { font: var(--text-base) var(--font-mono); }
@ -603,7 +613,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
.qstep-dot {
width: 28px;
height: 28px;
font: var(--text-xs) var(--font-mono);
font: var(--text-xs) var(--font-ui);
}
/* Options taller, finger-friendly rows. Label + description already stack

View file

@ -118,14 +118,16 @@ watch(
.prev {
color: var(--color-text-faint);
font: var(--text-base)/var(--leading-relaxed) var(--font-mono);
font: var(--text-base)/var(--leading-relaxed) var(--font-ui);
font-weight: 425;
white-space: pre-wrap;
word-break: break-word;
display: block;
}
.tc {
font: var(--text-base)/var(--leading-relaxed) var(--font-mono);
font: var(--text-base)/var(--leading-relaxed) var(--font-ui);
font-weight: 425;
color: var(--color-text-muted);
white-space: pre-wrap;
word-break: break-word;

View file

@ -64,7 +64,8 @@ watch(
overflow-y: auto;
margin: 0;
padding: 12px 14px;
font: var(--text-base)/var(--leading-relaxed) var(--font-mono);
font: var(--text-base)/var(--leading-relaxed) var(--font-ui);
font-weight: 425;
color: var(--color-text-muted);
white-space: pre-wrap;
word-break: break-word;

View file

@ -55,10 +55,12 @@ function onHeadClick(): void {
>
<div class="bh" ref="bhEl" @click="onHeadClick">
<span v-if="icon" class="gl" v-html="icon" aria-hidden="true" />
<span class="a">{{ name }}</span>
<Tooltip :text="arg">
<span v-if="arg" class="p">{{ arg }}</span>
</Tooltip>
<span class="bh-text">
<span class="a">{{ name }}</span>
<Tooltip :text="arg">
<span v-if="arg" class="p">{{ arg }}</span>
</Tooltip>
</span>
<span class="rt">
<span class="status" :class="status" role="status" :aria-label="status">
<Icon v-if="status === 'ok'" name="check" size="sm" />
@ -133,6 +135,13 @@ function onHeadClick(): void {
color: var(--color-text-faint);
flex: none;
}
.bh-text {
display: flex;
align-items: baseline;
gap: inherit;
flex: 1;
min-width: 0;
}
.a {
color: var(--color-text);
font-weight: var(--weight-medium);
@ -140,6 +149,7 @@ function onHeadClick(): void {
}
.p {
color: var(--color-text-muted);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -160,6 +170,7 @@ function onHeadClick(): void {
}
:slotted(.chip) {
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
flex: none;
}
@ -196,7 +207,7 @@ function onHeadClick(): void {
.bb-pad {
min-height: 0;
overflow: hidden;
padding: 0 11px 11px;
padding: var(--space-2) var(--space-3) var(--space-3);
background: var(--color-surface-sunken);
border-top: 1px solid var(--color-line);
color: var(--color-text);

View file

@ -6,6 +6,7 @@ import { diffStats } from '../../../lib/diffLines';
import { buildEditDiffLines } from '../../../lib/toolDiff';
import { toolGlyph, toolLabel, toolSummary } from '../../../lib/toolMeta';
import ToolRow from '../ToolRow.vue';
import ToolOutputBlock from './ToolOutputBlock.vue';
const props = withDefaults(
defineProps<{
@ -69,10 +70,7 @@ function toggle(): void {
<span v-if="chip" class="chip">{{ chip }}</span>
</template>
<div v-if="summaryFull" class="bb-summary">{{ summaryFull }}</div>
<div class="bb-code">
<div v-if="!hasOutput" class="bb-empty">Waiting for output</div>
<div v-for="(line, i) in tool.output ?? []" :key="i">{{ line }}</div>
</div>
<ToolOutputBlock :lines="tool.output" empty-text="Waiting for output" />
</ToolRow>
</template>
@ -89,14 +87,4 @@ function toggle(): void {
margin-bottom: 6px;
word-break: break-all;
}
.bb-empty {
color: var(--color-text-muted);
font-style: italic;
}
.bb-code {
margin-top: 10px;
padding: 11px 13px;
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
}
</style>

View file

@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue';
import type { FilePreviewRequest, ToolCall, ToolMedia } from '../../../types';
import { toolChip, toolGlyph, toolLabel, toolSummary } from '../../../lib/toolMeta';
import ToolRow from '../ToolRow.vue';
import ToolOutputBlock from './ToolOutputBlock.vue';
const props = withDefaults(
defineProps<{
@ -72,10 +73,7 @@ watch(
<span v-if="chip" class="chip">{{ chip }}</span>
</template>
<div v-if="summaryFull" class="bb-summary">{{ summaryFull }}</div>
<div class="bb-code">
<div v-if="!hasOutput" class="bb-empty">Waiting for output</div>
<div v-for="(line, i) in tool.output ?? []" :key="i">{{ line }}</div>
</div>
<ToolOutputBlock :lines="tool.output" empty-text="Waiting for output" />
</ToolRow>
</template>
@ -92,14 +90,4 @@ watch(
font-size: var(--text-xs);
flex: none;
}
.bb-empty {
color: var(--color-text-muted);
font-style: italic;
}
.bb-code {
margin-top: 10px;
padding: 11px 13px;
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
}
</style>

View file

@ -0,0 +1,42 @@
<!-- Shared line-oriented tool output block. Keeps long outputs to a readable
viewport while preserving the tool card's normal typography. -->
<script setup lang="ts">
import { computed } from 'vue';
const OUTPUT_SCROLL_LINE_COUNT = 50;
const props = defineProps<{
lines?: string[];
emptyText?: string;
}>();
const outputLines = computed(() => props.lines ?? []);
const isScrollable = computed(() => outputLines.value.length > OUTPUT_SCROLL_LINE_COUNT);
const outputStyle = { '--tool-output-visible-lines': String(OUTPUT_SCROLL_LINE_COUNT) };
</script>
<template>
<div class="bb-code tool-output-block" :class="{ scroll: isScrollable }" :style="outputStyle">
<div v-if="outputLines.length === 0 && emptyText" class="bb-empty">{{ emptyText }}</div>
<div v-for="(line, i) in outputLines" :key="i">{{ line }}</div>
</div>
</template>
<style scoped>
.tool-output-block {
margin-top: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
}
.tool-output-block.scroll {
max-height: calc(var(--tool-output-visible-lines) * 1lh);
overflow-y: auto;
scrollbar-gutter: stable;
}
.bb-empty {
color: var(--color-text-muted);
font-style: italic;
}
</style>

View file

@ -394,7 +394,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
}
.mgh-name {
font-size: var(--ui-font-size-lg);
font-weight: 500;
font-weight: 550;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
@ -402,6 +402,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
}
.mgh-path {
font-size: var(--text-base);
font-weight: 425;
color: var(--color-text-faint);
overflow: hidden;
text-overflow: ellipsis;
@ -430,12 +431,14 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
.srow .m { flex: 1; min-width: 0; }
.srow .m .t {
font-size: var(--text-base);
font-weight: 450;
line-height: var(--leading-tight);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.srow.cur .m .t { font-weight: 500; color: var(--color-accent-hover); }
.srow.cur .m .t { color: var(--color-accent-hover); }
/* Running indicator pulse dot in the indent gutter left of the title,
mirroring the desktop SessionRow (.t.run::before). */
@ -471,6 +474,8 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
}
.srow .m .s {
font-size: var(--text-base);
font-weight: 475;
font-variant-numeric: tabular-nums;
color: var(--color-text-faint);
margin-top: 1px;
overflow: hidden;

View file

@ -49,7 +49,11 @@ defineExpose({ el });
transition: background var(--duration-base) var(--ease-out),
color var(--duration-base) var(--ease-out);
}
.ui-icon-button:hover:not(:disabled) { background: var(--color-surface-sunken); color: var(--color-text); }
/* Translucent text-mix instead of the sunken surface: stays visible on ANY
backdrop the sunken token equals the page bg in dark mode, which made
hover feedback vanish for icon buttons sitting directly on --color-bg
(chat header, flat sidebar). */
.ui-icon-button:hover:not(:disabled) { background: color-mix(in srgb, var(--color-text) 8%, transparent); color: var(--color-text); }
.ui-icon-button:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.ui-icon-button:disabled { opacity: 0.5; cursor: not-allowed; }

View file

@ -0,0 +1,40 @@
<!-- apps/kimi-web/src/components/ui/Kbd.vue -->
<!-- Design-system §03 Kbd: keyboard shortcut rendered as keycaps one <kbd>
block per key (e.g. ['⌘', 'K'] renders two caps). Keycap look: sunken
surface + 2px bottom border, 18px tall to match Badge sm. -->
<script setup lang="ts">
defineProps<{
keys: string[];
}>();
</script>
<template>
<span class="ui-kbd">
<kbd v-for="key in keys" :key="key" class="ui-kbd__key">{{ key }}</kbd>
</span>
</template>
<style scoped>
.ui-kbd {
display: inline-flex;
align-items: center;
gap: 3px;
flex: none;
}
.ui-kbd__key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 1px solid var(--color-line);
border-bottom-width: 2px;
border-radius: var(--radius-xs);
background: var(--color-surface-sunken);
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: 11px;
line-height: 1;
}
</style>

View file

@ -38,9 +38,9 @@ defineEmits<{ click: [event: MouseEvent] }>();
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-size: var(--text-base);
text-align: left;
cursor: pointer;
transition: background var(--duration-base), color var(--duration-base);

View file

@ -1380,7 +1380,7 @@ function formatTime(iso: string, _status: string): string {
const diffD = diffMs / 86400000;
if (diffD < 7) return `${Math.round(diffD)}d`;
if (diffD < 30) return `${Math.round(diffD / 7)}w`;
if (diffD < 365) return `${Math.round(diffD / 30)}m`;
if (diffD < 365) return `${Math.round(diffD / 30)}mo`;
return `${Math.round(diffD / 365)}y`;
} catch {
return iso;

View file

@ -11,7 +11,9 @@ const SIDEBAR_WIDTH_KEY = STORAGE_KEYS.sidebarWidth;
const SIDEBAR_COLLAPSED_KEY = STORAGE_KEYS.sidebarCollapsed;
const SIDEBAR_DEFAULT = 270;
const SIDEBAR_MIN = 170;
const SIDEBAR_COLLAPSED_WIDTH = 36;
// Hard cap on how wide the sidebar can be dragged, regardless of viewport.
// Below this, the conversation-reserve rule still wins (narrow windows).
const SIDEBAR_MAX = 480;
// Minimum width kept for the conversation pane. The sidebar is capped so the
// conversation keeps at least this much room, which also guarantees the sidebar
// resize handle and collapse button stay inside the viewport even when a width
@ -28,19 +30,25 @@ export function useSidebarLayout(options: UseSidebarLayoutOptions = {}) {
const { viewportWidth } = useViewportWidth();
const sessionColWidth = ref(SIDEBAR_DEFAULT);
const sidebarCollapsed = ref(false);
// True while the sidebar ResizeHandle is being dragged — the sidebar disables
// its width transition so it follows the pointer 1:1 (mirrors panelDragging
// in useDetailPanel).
const sidebarDragging = ref(false);
// Largest sidebar width that still leaves the conversation pane usable. When
// the right-side panel is open, also reserves its minimum width so the
// conversation column can never be squeezed to nothing.
// Largest sidebar width that still leaves the conversation pane usable, then
// clamped to SIDEBAR_MAX so it can never be dragged absurdly wide on large
// displays. When the right-side panel is open, also reserves its minimum
// width so the conversation column can never be squeezed to nothing.
const sidebarMax = computed(() => {
const reserve = CONVERSATION_MIN + (toValue(options.previewOpen) ? PREVIEW_MIN : 0);
return panelMaxWidth(viewportWidth.value, SIDEBAR_MIN, reserve);
return Math.min(SIDEBAR_MAX, panelMaxWidth(viewportWidth.value, SIDEBAR_MIN, reserve));
});
// Expanded width of the sidebar. Collapsing does NOT change this value: the
// sidebar keeps its content at this fixed width and animates its container
// width to 0 (clip, not reflow), mirroring the right-side preview panel.
const sideWidth = computed(() =>
sidebarCollapsed.value
? SIDEBAR_COLLAPSED_WIDTH
: clampPanelWidth(sessionColWidth.value, SIDEBAR_MIN, sidebarMax.value),
clampPanelWidth(sessionColWidth.value, SIDEBAR_MIN, sidebarMax.value),
);
function loadSidebarCollapsed(): void {
@ -71,6 +79,7 @@ export function useSidebarLayout(options: UseSidebarLayoutOptions = {}) {
sidebarMax,
sessionColWidth,
sidebarCollapsed,
sidebarDragging,
sideWidth,
loadSidebarCollapsed,
toggleSidebarCollapse,

View file

@ -10,6 +10,11 @@ export default {
gitTooltip: 'Open Files > Changed',
detached: 'detached',
openPr: 'Open pull request',
prStatusOpen: 'open',
prStatusClosed: 'closed',
prStatusMerged: 'merged',
prStatusDraft: 'draft',
prStatusUnknown: 'unknown',
options: 'Options',
copySessionId: 'Copy Session ID',
renameSession: 'Rename',

View file

@ -7,7 +7,6 @@ export default {
sortRecent: 'Last edited',
collapseAll: 'Collapse all workspaces',
expandAll: 'Expand all workspaces',
showWorkspacePaths: 'Show workspace paths',
newSession: 'New Session',
newChat: 'New Chat',
newWorkspace: 'New Workspace',
@ -31,14 +30,14 @@ export default {
language: 'Language',
daemon: 'Daemon',
noSessions: 'No conversations yet',
showMore: 'Load more ({count})',
showMore: 'Load {count} more conversations',
showLess: 'Show less',
showAll: 'Show all ({count})',
showAll: 'Show {count} more conversations',
loadingMore: 'Loading…',
collapseSidebar: 'Collapse sidebar',
expandSidebar: 'Expand sidebar',
searchPlaceholder: 'Search sessions',
searchShortcut: 'Search sessions ({shortcut})',
search: 'Search',
searchHint: '↑↓ navigate · ↵ open · Esc close',
searchNoResults: 'No matching sessions',
} as const;

View file

@ -12,23 +12,32 @@ export default {
permissionYoloDesc: 'Auto-approve tool actions, but agent may still ask questions',
// Plan mode pill
planLabel: 'Plan',
planDesc: 'Have the agent make a plan before changing files',
planOn: 'on',
planOff: 'off',
planTooltip: 'Toggle plan mode (research before editing)',
// Mode selector (Plan / Goal / Swarm)
modesLabel: 'Mode',
goalLabel: 'Goal',
goalDesc: 'Track one objective until it is complete',
swarmLabel: 'Swarm',
swarmDesc: 'Run parallel agents for broader exploration',
modeOff: 'Off',
goalPlaceholder: 'What should the agent achieve?',
goalStart: 'Start',
goalPause: 'Pause',
goalResume: 'Resume',
goalCancel: 'Cancel',
goalStatusActive: 'Active',
goalStatusPaused: 'Paused',
goalStatusBlocked: 'Blocked',
goalStatusComplete: 'Complete',
modeNotSupported: 'Not supported',
// Thinking selector
thinkingLabel: 'thinking',
thinkingTooltip: 'Toggle thinking mode',
thinkingOn: 'On',
thinkingOff: 'Off',
starredModels: 'Starred',
moreModels: 'More models…',
// Status panel

View file

@ -13,6 +13,10 @@ export default {
task: 'Task',
swarm: 'Swarm',
ask_user: 'Question',
goal_create: 'Start Goal',
goal_get: 'Read Goal',
goal_budget: 'Set Goal Budget',
goal_update: 'Update Goal',
},
swarm: {
progress: '{done} / {total}',
@ -32,6 +36,17 @@ export default {
created: 'created',
todos: '{count} items',
},
goal: {
objectiveWithCriterion: '{objective} · {criterion}',
status: 'Status: {status}',
budget: '{value} {unit}',
turns: '{value} turns',
tokens: '{value} tokens',
milliseconds: '{value} ms',
seconds: '{value} sec',
minutes: '{value} min',
hours: '{value} hr',
},
group: {
title: '{count} tool call | {count} tool calls',
running: 'running',

View file

@ -22,7 +22,7 @@ export default {
emptyConversationTitle: 'Kimi Code',
emptyConversation: '还没有消息 —— 在下方输入开始对话',
quickStartPlaceholder: '输入消息开始新对话…',
thinkingSuffix: ' · thinking',
thinkingSuffixEffort: ' · thinking: {level}',
thinkingSuffix: ' · 思考',
thinkingSuffixEffort: ' · 思考: {level}',
} as const;

View file

@ -10,6 +10,11 @@ export default {
gitTooltip: '打开「文件 > 改动」',
detached: '游离',
openPr: '打开 Pull Request',
prStatusOpen: '已打开',
prStatusClosed: '已关闭',
prStatusMerged: '已合并',
prStatusDraft: '草稿',
prStatusUnknown: '未知',
options: '选项',
copySessionId: '复制 Session ID',
renameSession: '重命名',

View file

@ -7,7 +7,6 @@ export default {
sortRecent: '按最后编辑时间',
collapseAll: '折叠全部工作区',
expandAll: '展开全部工作区',
showWorkspacePaths: '显示工作区路径',
newSession: '新建会话',
newChat: '新建对话',
newWorkspace: '新建工作区',
@ -31,14 +30,14 @@ export default {
language: '语言',
daemon: '后台',
noSessions: '暂无对话',
showMore: '加载更多 ({count})',
showMore: '加载更多 {count} 个对话',
showLess: '收起',
showAll: '展开 ({count})',
showAll: '展开剩余 {count} 个对话',
loadingMore: '加载中…',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
searchPlaceholder: '搜索会话',
searchShortcut: '搜索会话 ({shortcut})',
search: '搜索',
searchHint: '↑↓ 选择 · ↵ 打开 · Esc 关闭',
searchNoResults: '没有匹配的会话',
};

View file

@ -8,27 +8,36 @@ export default {
permissionAuto: '完全自主',
permissionYolo: '自动通过',
permissionManualDesc: '每个工具操作都需要你手动确认',
permissionAutoDesc: '完全自主运行,agent 自己做决定,不再询问',
permissionAutoDesc: '完全自主运行,智能体自己做决定,不再询问',
permissionYoloDesc: '自动批准工具操作,但遇到关键问题仍会询问',
// 计划模式
planLabel: '计划',
planDesc: '先让智能体梳理计划,再修改文件',
planOn: '开',
planOff: '关',
planTooltip: '切换计划模式(先调研再修改)',
// 模式选择器(计划 / 目标 / Swarm
modesLabel: '模式',
goalLabel: '目标',
goalDesc: '持续跟踪一个目标,直到任务完成',
swarmLabel: 'Swarm',
swarmDesc: '并行运行多个智能体,适合大范围探索',
modeOff: '未启用',
goalPlaceholder: '让 Agent 完成什么目标?',
goalPlaceholder: '让智能体完成什么目标?',
goalStart: '开始',
goalPause: '暂停',
goalResume: '继续',
goalCancel: '取消',
goalStatusActive: '进行中',
goalStatusPaused: '已暂停',
goalStatusBlocked: '已阻塞',
goalStatusComplete: '已完成',
modeNotSupported: '暂不支持',
// 思考强度选择
thinkingLabel: '思考',
thinkingTooltip: '切换思考模式',
thinkingOn: '开',
thinkingOff: '关',
starredModels: '收藏',
moreModels: '更多模型…',
// 状态面板

View file

@ -13,6 +13,10 @@ export default {
task: '任务',
swarm: 'Swarm',
ask_user: '提问',
goal_create: '启动目标',
goal_get: '读取目标',
goal_budget: '设置目标预算',
goal_update: '更新目标',
},
swarm: {
progress: '{done} / {total}',
@ -32,6 +36,17 @@ export default {
created: '已创建',
todos: '{count} 项',
},
goal: {
objectiveWithCriterion: '{objective} · {criterion}',
status: '状态:{status}',
budget: '{value} {unit}',
turns: '{value} 轮',
tokens: '{value} token',
milliseconds: '{value} 毫秒',
seconds: '{value} 秒',
minutes: '{value} 分钟',
hours: '{value} 小时',
},
group: {
title: '{count} 个工具调用',
running: '运行中',

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.1 12.9001V15.0909C11.1 15.593 11.5029 16 12 16C12.4971 16 12.9 15.593 12.9 15.0909V12.9001H15.0909C15.593 12.9001 16 12.4972 16 12.0001C16 11.5031 15.593 11.1001 15.0909 11.1001H12.9V8.90909C12.9 8.40701 12.4971 8 12 8C11.5029 8 11.1 8.40701 11.1 8.90909V11.1001H8.90909C8.40701 11.1001 8 11.5031 8 12.0001C8 12.4972 8.40701 12.9001 8.90909 12.9001H11.1Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9996 2.1001C6.53199 2.1001 2.09961 6.53248 2.09961 12.0001C2.09961 13.9226 2.64847 15.7192 3.59804 17.2391L2.517 19.8207C2.10313 20.8091 2.82908 21.9001 3.90059 21.9001H11.9996C17.4672 21.9001 21.8996 17.4677 21.8996 12.0001C21.8996 6.53248 17.4672 2.1001 11.9996 2.1001ZM3.89961 12.0001C3.89961 7.52659 7.5261 3.9001 11.9996 3.9001C16.4731 3.9001 20.0996 7.52659 20.0996 12.0001C20.0996 16.4736 16.4724 20.1001 11.9989 20.1001H4.35146L5.63494 17.0351L5.35165 16.6291C4.43632 15.3172 3.89961 13.7227 3.89961 12.0001Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4626_2033)">
<path d="M18.3623 9.99976L18.209 8.48999C18.2031 8.43161 18.2004 8.37289 18.2002 8.31421C18.1988 8.31196 18.1956 8.30842 18.1904 8.30347C18.1718 8.28559 18.1302 8.26245 18.0713 8.26245H11C10.261 8.26245 9.59753 7.81016 9.32617 7.1228L8.9082 6.06421C8.88101 5.9953 8.85737 5.92501 8.83887 5.85327C8.83778 5.85099 8.833 5.84268 8.81836 5.83179C8.79454 5.81475 8.7549 5.79939 8.70605 5.80054H3.92871C3.86986 5.80054 3.82825 5.82368 3.80957 5.84155C3.80816 5.8429 3.80675 5.84428 3.80566 5.84546L4.47559 14.0955L3.62109 17.5154L5.12109 11.5154C5.34367 10.6251 6.1438 9.99977 7.06152 9.99976H18.3623ZM7.06152 11.7996C6.96976 11.7996 6.88944 11.8629 6.86719 11.9519L5.36719 17.9519C5.33598 18.078 5.43158 18.1999 5.56152 18.2H19.4385C19.5302 18.1999 19.6106 18.1376 19.6328 18.0486L21.1328 12.0486C21.1644 11.9224 21.0686 11.7996 20.9385 11.7996H7.06152ZM20.9385 9.99976C22.2396 9.99977 23.1945 11.2228 22.8789 12.4851L21.3789 18.4851C21.1563 19.3754 20.3562 19.9997 19.4385 19.9998H4.92871C4.41722 19.9998 3.92613 19.8059 3.56445 19.4597C3.20281 19.1135 3.00004 18.6436 3 18.1541L2 5.84644C2.00006 5.35711 2.20311 4.88786 2.56445 4.54175C2.92613 4.19554 3.41722 4.00073 3.92871 4.00073H8.66406C9.10133 3.99051 9.5296 4.1225 9.87793 4.37573C10.2285 4.63118 10.4767 4.99457 10.582 5.40405L11 6.46167H18.0713C18.5828 6.46167 19.0739 6.65648 19.4355 7.00269C19.7971 7.34888 20 7.81883 20 8.30835L20.1719 9.99976H20.9385Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_4626_2033">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.2373 3.7002C10.4169 3.7002 11.5297 4.24779 12.249 5.18262L12.4424 5.43359H18C20.0987 5.43359 21.7998 7.13472 21.7998 9.2334V16.5C21.7998 18.5987 20.0987 20.2998 18 20.2998H6C3.90132 20.2998 2.2002 18.5987 2.2002 16.5V7.5C2.2002 5.40132 3.90132 3.7002 6 3.7002H9.2373ZM6 5.5C4.89543 5.5 4 6.39543 4 7.5V16.5C4 17.6046 4.89543 18.5 6 18.5H18C19.0357 18.5 19.887 17.7128 19.9893 16.7041L20 16.5V9.2334C20 8.19775 19.2128 7.34641 18.2041 7.24414L18 7.2334H12.0479L11.9326 7.22656C11.666 7.19561 11.4205 7.05812 11.2549 6.84277L10.8223 6.28027C10.4437 5.78834 9.85808 5.5 9.2373 5.5H6ZM16 9.59961C16.4971 9.59961 16.9004 10.0029 16.9004 10.5C16.9004 10.9971 16.4971 11.4004 16 11.4004H8C7.50294 11.4004 7.09961 10.9971 7.09961 10.5C7.09961 10.0029 7.50294 9.59961 8 9.59961H16Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 911 B

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12C6 12.8283 5.32834 13.5 4.5 13.5C3.67166 13.5 3 12.8283 3 12C3 11.1717 3.67166 10.5 4.5 10.5C5.32834 10.5 6 11.1717 6 12Z" fill="currentColor"/>
<path d="M13.5 12C13.5 12.8283 12.8283 13.5 12 13.5C11.1717 13.5 10.5 12.8283 10.5 12C10.5 11.1717 11.1717 10.5 12 10.5C12.8283 10.5 13.5 11.1717 13.5 12Z" fill="currentColor"/>
<path d="M19.5002 13.5C20.3287 13.5 21 12.8287 21 12.0002C21 11.1718 20.3287 10.5 19.5002 10.5C18.6718 10.5 18 11.1718 18 12.0002C18 12.8287 18.6718 13.5 19.5002 13.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 3C16.1944 3 20 6.80558 20 11.5C20 13.523 19.2933 15.381 18.1132 16.8404L21.1364 19.8636C21.4879 20.2151 21.4879 20.7849 21.1364 21.1364C20.7849 21.4879 20.2151 21.4879 19.8636 21.1364L16.8404 18.1132C15.381 19.2933 13.523 20 11.5 20C6.80558 20 3 16.1944 3 11.5C3 6.80558 6.80558 3 11.5 3ZM11.5 18.2C15.2003 18.2 18.2 15.2003 18.2 11.5C18.2 7.79969 15.2003 4.8 11.5 4.8C7.79969 4.8 4.8 7.79969 4.8 11.5C4.8 15.2003 7.79969 18.2 11.5 18.2Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 579 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0404 12C16.0404 9.76874 14.2313 7.9596 12.0001 7.9596C9.76883 7.9596 7.95972 9.76874 7.95972 12C7.95972 14.2313 9.76883 16.0404 12.0001 16.0404C14.2313 16.0404 16.0404 14.2313 16.0404 12ZM14.2222 12C14.2222 13.2271 13.2271 14.2222 12 14.2222C10.7729 14.2222 9.77783 13.2271 9.77783 12C9.77783 10.7729 10.7729 9.77778 12 9.77778C13.2271 9.77778 14.2222 10.7729 14.2222 12Z" fill="currentColor"/>
<path d="M9.91145 21.8009C9.29001 21.6797 8.76914 21.2612 8.50632 20.6922L8.07372 19.7556C7.88838 19.3544 7.43553 19.1048 6.95371 19.1549L5.89572 19.2647C5.2733 19.3293 4.64823 19.114 4.22298 18.6611C3.74343 18.1504 3.32454 17.6037 2.97033 17.0181C2.61571 16.4318 2.32839 15.8106 2.10407 15.1566C1.89769 14.5549 2.02148 13.8954 2.4089 13.3902L3.0376 12.5704C3.30043 12.2277 3.30042 11.7722 3.03758 11.4295L2.40413 10.6035C2.01474 10.0958 1.891 9.43198 2.10208 8.82826C2.55037 7.54612 3.27017 6.35997 4.22 5.34259C4.64518 4.8872 5.27275 4.67067 5.89701 4.73544L6.95383 4.84514C7.43561 4.89515 7.88844 4.6456 8.07377 4.24441L8.50266 3.31593C8.76494 2.74818 9.28448 2.33019 9.90423 2.20761C11.2916 1.9332 12.7148 1.93127 14.0885 2.19913C14.7099 2.32029 15.2308 2.73881 15.4937 3.3078L15.9263 4.24441C16.1116 4.6456 16.5644 4.89514 17.0462 4.84514L18.1043 4.73532C18.7267 4.67072 19.3518 4.88603 19.777 5.33886C20.2566 5.84953 20.6755 6.3963 21.0297 6.98193C21.3843 7.56823 21.6716 8.18942 21.8959 8.84339C22.1023 9.44509 21.9785 10.1046 21.5911 10.6098L20.9624 11.4295C20.6996 11.7722 20.6996 12.2278 20.9624 12.5705L21.5959 13.3964C21.9853 13.9042 22.109 14.568 21.8979 15.1717C21.4497 16.4538 20.7299 17.6399 19.7801 18.6573C19.3549 19.1128 18.7273 19.3294 18.103 19.2646L17.0462 19.1549C16.5645 19.1049 16.1116 19.3544 15.9263 19.7556L15.4974 20.6841C15.2351 21.2518 14.7156 21.6698 14.0958 21.7924C12.7083 22.0668 11.2852 22.0687 9.91145 21.8009ZM13.7432 20.0088C13.7844 20.0006 13.8259 19.9673 13.847 19.9216L14.2758 18.9931C14.7915 17.8768 15.9886 17.2171 17.2341 17.3464L18.2909 17.4561C18.3649 17.4638 18.4272 17.4423 18.4512 17.4166C19.2296 16.5828 19.8171 15.6146 20.1817 14.5716C20.1845 14.5636 20.1796 14.5373 20.1532 14.5029L19.5198 13.677C18.7564 12.6815 18.7564 11.3185 19.5198 10.323L20.1485 9.5033C20.1746 9.46927 20.1795 9.4429 20.1762 9.43327C19.9932 8.89965 19.7603 8.39623 19.4741 7.92293C19.1873 7.4489 18.846 7.00333 18.4517 6.58351C18.4272 6.55739 18.3656 6.53616 18.2921 6.54378L17.234 6.65361C15.9886 6.78287 14.7915 6.12317 14.2758 5.00689L13.8432 4.07027C13.822 4.02448 13.7811 3.9916 13.7406 3.98371C12.5983 3.76097 11.4132 3.76258 10.2571 3.99124C10.2158 3.99941 10.1744 4.03271 10.1533 4.07842L9.72441 5.00689C9.20875 6.12317 8.01164 6.7829 6.76619 6.6536L5.70942 6.54391C5.63535 6.53623 5.573 6.55774 5.54905 6.5834C4.77067 7.41713 4.18312 8.38534 3.81845 9.42835C3.81564 9.43637 3.82054 9.46265 3.84693 9.49706L4.48038 10.323C5.24381 11.3185 5.24383 12.6815 4.48041 13.6769L3.85171 14.4967C3.82561 14.5307 3.82066 14.5571 3.82396 14.5667C4.00701 15.1004 4.23986 15.6038 4.52613 16.0771C4.81284 16.5511 5.15421 16.9967 5.54845 17.4165C5.57298 17.4426 5.63461 17.4638 5.70811 17.4562L6.76608 17.3464C8.01157 17.2171 9.20871 17.8768 9.72438 18.9932L10.157 19.9297C10.1781 19.9755 10.2191 20.0084 10.2595 20.0163C11.4018 20.239 12.587 20.2374 13.7432 20.0088Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,13 +1,22 @@
// apps/kimi-web/src/lib/icons.ts
// Single source of truth for apps/kimi-web icons (design-system §02).
//
// Icons come from Remix Icon (https://remixicon.com/, Apache-2.0) and are
// bundled by unplugin-icons at build time — only the icons listed below end up
// in the production bundle. Each icon is imported twice: once as a Vue
// component (for <Icon name=... />) and once as a `?raw` SVG string (for
// iconSvg() in v-html contexts such as lib/toolMeta.ts).
// Icons come from three collections, all bundled by unplugin-icons at build
// time — only the icons listed below end up in the production bundle:
// - `~icons/kimi/*` — Kimi Design System icons (24×24 outlined,
// fill="currentColor"), local SVGs under src/icons/kimi/ registered as a
// custom collection in vite.config.ts. Preferred when a Kimi icon exists
// for the intent.
// - `~icons/tabler/*` — Tabler Icons (https://tabler.io/icons, MIT),
// 24×24 stroke-based (stroke="currentColor"); used for the sidebar
// panel toggle, which neither pack above covers well.
// - `~icons/ri/*` — Remix Icon (https://remixicon.com/, Apache-2.0) for
// the remaining intents.
// Each icon is imported twice: once as a Vue component (for <Icon name=... />)
// and once as a `?raw` SVG string (for iconSvg() in v-html contexts such as
// lib/toolMeta.ts).
//
// Remix icons are fill-based (fill="currentColor") on a 24x24 source grid; the
// All collections share the 24x24 source grid and follow currentColor; the
// rendered size comes from the size token prop. Colour follows text.
//
// Two consumers share this registry:
@ -16,7 +25,19 @@
import type { Component } from 'vue';
// Components (Vue) ---------------------------------------------------------
// Components (Kimi collection) ----------------------------------------------
import KimiAddConversation from '~icons/kimi/add-conversation';
import KimiFolder from '~icons/kimi/folder';
import KimiFolderOpen from '~icons/kimi/folder-open';
import KimiMore from '~icons/kimi/more';
import KimiSearch from '~icons/kimi/search';
import KimiSetting from '~icons/kimi/setting';
// Components (Tabler) ---------------------------------------------------------
import TablerSidebarLeftCollapse from '~icons/tabler/layout-sidebar-left-collapse';
import TablerSidebarLeftExpand from '~icons/tabler/layout-sidebar-left-expand';
// Components (Remix) ---------------------------------------------------------
import RiAddLine from '~icons/ri/add-line';
import RiAlertLine from '~icons/ri/alert-line';
import RiArrowDownLine from '~icons/ri/arrow-down-line';
@ -29,27 +50,23 @@ import RiBracesLine from '~icons/ri/braces-line';
import RiCalendarCloseLine from '~icons/ri/calendar-close-line';
import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line';
import RiCalendarTodoLine from '~icons/ri/calendar-todo-line';
import RiChatNewLine from '~icons/ri/chat-new-line';
import RiCheckLine from '~icons/ri/check-line';
import RiCloseLine from '~icons/ri/close-line';
import RiCodeLine from '~icons/ri/code-line';
import RiCollapseDiagonalLine from '~icons/ri/collapse-diagonal-line';
import RiContractLeftLine from '~icons/ri/contract-left-line';
import RiDownloadLine from '~icons/ri/download-line';
import RiDraggable from '~icons/ri/draggable';
import RiEqualizerLine from '~icons/ri/equalizer-line';
import RiExpandDiagonalLine from '~icons/ri/expand-diagonal-line';
import RiExpandRightLine from '~icons/ri/expand-right-line';
import RiExternalLinkLine from '~icons/ri/external-link-line';
import RiFileAddLine from '~icons/ri/file-add-line';
import RiFileCopyLine from '~icons/ri/file-copy-line';
import RiFileEditLine from '~icons/ri/file-edit-line';
import RiFileLine from '~icons/ri/file-line';
import RiFileTextLine from '~icons/ri/file-text-line';
import RiFlashlightLine from '~icons/ri/flashlight-line';
import RiFolderAddLine from '~icons/ri/folder-add-line';
import RiFolderFill from '~icons/ri/folder-fill';
import RiFolderLine from '~icons/ri/folder-line';
import RiFolderOpenLine from '~icons/ri/folder-open-line';
import RiGitPullRequestLine from '~icons/ri/git-pull-request-line';
import RiGlobalLine from '~icons/ri/global-line';
import RiImageLine from '~icons/ri/image-line';
@ -60,24 +77,35 @@ import RiListUnordered from '~icons/ri/list-unordered';
import RiLoginBoxLine from '~icons/ri/login-box-line';
import RiMailLine from '~icons/ri/mail-line';
import RiMessageLine from '~icons/ri/message-line';
import RiMoreLine from '~icons/ri/more-line';
import RiPauseFill from '~icons/ri/pause-fill';
import RiPencilLine from '~icons/ri/pencil-line';
import RiPlayFill from '~icons/ri/play-fill';
import RiQuestionLine from '~icons/ri/question-line';
import RiSearchLine from '~icons/ri/search-line';
import RiSettings3Line from '~icons/ri/settings-3-line';
import RiSortDesc from '~icons/ri/sort-desc';
import RiSparklingLine from '~icons/ri/sparkling-line';
import RiStarFill from '~icons/ri/star-fill';
import RiStarLine from '~icons/ri/star-line';
import RiStopFill from '~icons/ri/stop-fill';
import RiSubtractLine from '~icons/ri/subtract-line';
import RiTargetLine from '~icons/ri/target-line';
import RiTerminalBoxLine from '~icons/ri/terminal-box-line';
import RiTimeLine from '~icons/ri/time-line';
import RiToolsLine from '~icons/ri/tools-line';
import RiUserLine from '~icons/ri/user-line';
// Raw SVG strings ----------------------------------------------------------
// Raw SVG strings (Kimi collection) -----------------------------------------
import RawKimiAddConversation from '~icons/kimi/add-conversation?raw';
import RawKimiFolder from '~icons/kimi/folder?raw';
import RawKimiFolderOpen from '~icons/kimi/folder-open?raw';
import RawKimiMore from '~icons/kimi/more?raw';
import RawKimiSearch from '~icons/kimi/search?raw';
import RawKimiSetting from '~icons/kimi/setting?raw';
// Raw SVG strings (Tabler) ----------------------------------------------------
import RawTablerSidebarLeftCollapse from '~icons/tabler/layout-sidebar-left-collapse?raw';
import RawTablerSidebarLeftExpand from '~icons/tabler/layout-sidebar-left-expand?raw';
// Raw SVG strings (Remix) ----------------------------------------------------
import RawAddLine from '~icons/ri/add-line?raw';
import RawAlertLine from '~icons/ri/alert-line?raw';
import RawArrowDownLine from '~icons/ri/arrow-down-line?raw';
@ -90,27 +118,23 @@ import RawBracesLine from '~icons/ri/braces-line?raw';
import RawCalendarCloseLine from '~icons/ri/calendar-close-line?raw';
import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw';
import RawCalendarTodoLine from '~icons/ri/calendar-todo-line?raw';
import RawChatNewLine from '~icons/ri/chat-new-line?raw';
import RawCheckLine from '~icons/ri/check-line?raw';
import RawCloseLine from '~icons/ri/close-line?raw';
import RawCodeLine from '~icons/ri/code-line?raw';
import RawCollapseDiagonalLine from '~icons/ri/collapse-diagonal-line?raw';
import RawContractLeftLine from '~icons/ri/contract-left-line?raw';
import RawDownloadLine from '~icons/ri/download-line?raw';
import RawDraggable from '~icons/ri/draggable?raw';
import RawEqualizerLine from '~icons/ri/equalizer-line?raw';
import RawExpandDiagonalLine from '~icons/ri/expand-diagonal-line?raw';
import RawExpandRightLine from '~icons/ri/expand-right-line?raw';
import RawExternalLinkLine from '~icons/ri/external-link-line?raw';
import RawFileAddLine from '~icons/ri/file-add-line?raw';
import RawFileCopyLine from '~icons/ri/file-copy-line?raw';
import RawFileEditLine from '~icons/ri/file-edit-line?raw';
import RawFileLine from '~icons/ri/file-line?raw';
import RawFileTextLine from '~icons/ri/file-text-line?raw';
import RawFlashlightLine from '~icons/ri/flashlight-line?raw';
import RawFolderAddLine from '~icons/ri/folder-add-line?raw';
import RawFolderFill from '~icons/ri/folder-fill?raw';
import RawFolderLine from '~icons/ri/folder-line?raw';
import RawFolderOpenLine from '~icons/ri/folder-open-line?raw';
import RawGitPullRequestLine from '~icons/ri/git-pull-request-line?raw';
import RawGlobalLine from '~icons/ri/global-line?raw';
import RawImageLine from '~icons/ri/image-line?raw';
@ -121,18 +145,17 @@ import RawListUnordered from '~icons/ri/list-unordered?raw';
import RawLoginBoxLine from '~icons/ri/login-box-line?raw';
import RawMailLine from '~icons/ri/mail-line?raw';
import RawMessageLine from '~icons/ri/message-line?raw';
import RawMoreLine from '~icons/ri/more-line?raw';
import RawPauseFill from '~icons/ri/pause-fill?raw';
import RawPencilLine from '~icons/ri/pencil-line?raw';
import RawPlayFill from '~icons/ri/play-fill?raw';
import RawQuestionLine from '~icons/ri/question-line?raw';
import RawSearchLine from '~icons/ri/search-line?raw';
import RawSettings3Line from '~icons/ri/settings-3-line?raw';
import RawSortDesc from '~icons/ri/sort-desc?raw';
import RawSparklingLine from '~icons/ri/sparkling-line?raw';
import RawStarFill from '~icons/ri/star-fill?raw';
import RawStarLine from '~icons/ri/star-line?raw';
import RawStopFill from '~icons/ri/stop-fill?raw';
import RawSubtractLine from '~icons/ri/subtract-line?raw';
import RawTargetLine from '~icons/ri/target-line?raw';
import RawTerminalBoxLine from '~icons/ri/terminal-box-line?raw';
import RawTimeLine from '~icons/ri/time-line?raw';
import RawToolsLine from '~icons/ri/tools-line?raw';
@ -177,6 +200,7 @@ export type IconName =
| 'folder-solid'
| 'file'
| 'file-text'
| 'file-edit'
| 'file-plus'
| 'file-off'
| 'image-off'
@ -197,6 +221,8 @@ export type IconName =
| 'alert-triangle'
| 'clock'
| 'sparkles'
| 'target'
| 'pause'
| 'play'
| 'stop'
| 'star'
@ -220,13 +246,13 @@ function entry(component: Component, svg: string): IconEntry {
export const ICONS: Record<IconName, IconEntry> = {
plus: entry(RiAddLine, RawAddLine),
'chat-new': entry(RiChatNewLine, RawChatNewLine),
'chat-new': entry(KimiAddConversation, RawKimiAddConversation),
'calendar-close': entry(RiCalendarCloseLine, RawCalendarCloseLine),
'calendar-schedule': entry(RiCalendarScheduleLine, RawCalendarScheduleLine),
'calendar-todo': entry(RiCalendarTodoLine, RawCalendarTodoLine),
close: entry(RiCloseLine, RawCloseLine),
check: entry(RiCheckLine, RawCheckLine),
search: entry(RiSearchLine, RawSearchLine),
search: entry(KimiSearch, RawKimiSearch),
copy: entry(RiFileCopyLine, RawFileCopyLine),
link: entry(RiLinksLine, RawLinksLine),
'external-link': entry(RiExternalLinkLine, RawExternalLinkLine),
@ -234,7 +260,7 @@ export const ICONS: Record<IconName, IconEntry> = {
undo: entry(RiArrowGoBackLine, RawArrowGoBackLine),
send: entry(RiArrowUpLine, RawArrowUpLine),
image: entry(RiImageLine, RawImageLine),
settings: entry(RiSettings3Line, RawSettings3Line),
settings: entry(KimiSetting, RawKimiSetting),
sliders: entry(RiEqualizerLine, RawEqualizerLine),
'log-in': entry(RiLoginBoxLine, RawLoginBoxLine),
'chevron-down': entry(RiArrowDownSLine, RawArrowDownSLine),
@ -243,19 +269,20 @@ export const ICONS: Record<IconName, IconEntry> = {
'arrow-down': entry(RiArrowDownLine, RawArrowDownLine),
'arrow-right': entry(RiArrowRightLine, RawArrowRightLine),
minus: entry(RiSubtractLine, RawSubtractLine),
'panel-collapse': entry(RiContractLeftLine, RawContractLeftLine),
'panel-expand': entry(RiExpandRightLine, RawExpandRightLine),
'panel-collapse': entry(TablerSidebarLeftCollapse, RawTablerSidebarLeftCollapse),
'panel-expand': entry(TablerSidebarLeftExpand, RawTablerSidebarLeftExpand),
expand: entry(RiExpandDiagonalLine, RawExpandDiagonalLine),
collapse: entry(RiCollapseDiagonalLine, RawCollapseDiagonalLine),
list: entry(RiListUnordered, RawListUnordered),
sort: entry(RiSortDesc, RawSortDesc),
grip: entry(RiDraggable, RawDraggable),
folder: entry(RiFolderOpenLine, RawFolderOpenLine),
'folder-closed': entry(RiFolderLine, RawFolderLine),
folder: entry(KimiFolderOpen, RawKimiFolderOpen),
'folder-closed': entry(KimiFolder, RawKimiFolder),
'folder-plus': entry(RiFolderAddLine, RawFolderAddLine),
'folder-solid': entry(RiFolderFill, RawFolderFill),
file: entry(RiFileLine, RawFileLine),
'file-text': entry(RiFileTextLine, RawFileTextLine),
'file-edit': entry(RiFileEditLine, RawFileEditLine),
'file-plus': entry(RiFileAddLine, RawFileAddLine),
'file-off': entry(RiFileLine, RawFileLine),
'image-off': entry(RiImageLine, RawImageLine),
@ -276,11 +303,13 @@ export const ICONS: Record<IconName, IconEntry> = {
'alert-triangle': entry(RiAlertLine, RawAlertLine),
clock: entry(RiTimeLine, RawTimeLine),
sparkles: entry(RiSparklingLine, RawSparklingLine),
target: entry(RiTargetLine, RawTargetLine),
pause: entry(RiPauseFill, RawPauseFill),
play: entry(RiPlayFill, RawPlayFill),
stop: entry(RiStopFill, RawStopFill),
star: entry(RiStarFill, RawStarFill),
'star-outline': entry(RiStarLine, RawStarLine),
'dots-horizontal': entry(RiMoreLine, RawMoreLine),
'dots-horizontal': entry(KimiMore, RawKimiMore),
};
export function getIcon(name: IconName): IconEntry {
@ -353,6 +382,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
'folder-solid',
'file',
'file-text',
'file-edit',
'file-plus',
'file-off',
'image-off',
@ -365,6 +395,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
'check-list',
'bolt',
'git-pull-request',
'target',
'calendar-schedule',
'calendar-todo',
'calendar-close',
@ -379,6 +410,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
'alert-triangle',
'clock',
'sparkles',
'pause',
'play',
'stop',
'star',

View file

@ -22,7 +22,6 @@ export const STORAGE_KEYS = {
colorScheme: 'kimi-web.color-scheme',
hiddenWorkspaces: 'kimi-web.hidden-workspaces',
collapsedWorkspaces: 'kimi-web.collapsed-workspaces',
showWorkspacePaths: 'kimi-web.show-workspace-paths',
workspaceOrder: 'kimi-web.workspace-order',
workspaceNameOverrides: 'kimi-web.workspace-name-overrides',
workspaceSort: 'kimi-web.workspace-sort',
@ -149,20 +148,6 @@ export function saveCollapsedWorkspaces(ids: Iterable<string>): void {
safeSetJson(STORAGE_KEYS.collapsedWorkspaces, Array.from(ids));
}
/**
* Whether the sidebar shows each workspace's root path under its name. Off by
* default to keep the list compact; toggled from the Workspaces section header.
* Stored as a JSON boolean; any missing/unset value reads as false. UI-only
* state with no server-side source of truth.
*/
export function loadShowWorkspacePaths(): boolean {
return safeGetJson<boolean>(STORAGE_KEYS.showWorkspacePaths) === true;
}
export function saveShowWorkspacePaths(show: boolean): void {
safeSetJson(STORAGE_KEYS.showWorkspacePaths, show);
}
/**
* Display order of workspace ids in the sidebar. Persisted as a JSON array so
* the user can drag workspaces into a custom order that survives a page

View file

@ -25,6 +25,10 @@ const TOOL_LABEL_KEYS: Record<string, string> = {
task: 'tools.label.task',
agentswarm: 'tools.label.swarm',
askuserquestion: 'tools.label.ask_user',
creategoal: 'tools.label.goal_create',
getgoal: 'tools.label.goal_get',
setgoalbudget: 'tools.label.goal_budget',
updategoal: 'tools.label.goal_update',
};
// ---------------------------------------------------------------------------
@ -60,6 +64,10 @@ const NAME_ALIASES: Record<string, string> = {
subagent: 'task',
websearch: 'search',
web_search: 'search',
create_goal: 'creategoal',
get_goal: 'getgoal',
set_goal_budget: 'setgoalbudget',
update_goal: 'updategoal',
};
export function normalizeToolName(name: string): string {
@ -93,6 +101,10 @@ const TOOL_GLYPH: Record<string, IconName> = {
task: 'sparkles',
agentswarm: 'git-pull-request',
askuserquestion: 'help-circle',
creategoal: 'target',
getgoal: 'target',
setgoalbudget: 'target',
updategoal: 'target',
// Cron scheduling tools share a calendar motif: schedule / list / cancel.
croncreate: 'calendar-schedule',
cronlist: 'calendar-todo',
@ -182,6 +194,41 @@ function filePath(d: Record<string, unknown>): string | undefined {
return str(d.path) ?? str(d.file_path) ?? str(d.filePath) ?? str(d.filename);
}
const GOAL_STATUS_KEYS: Record<string, string> = {
active: 'status.goalStatusActive',
blocked: 'status.goalStatusBlocked',
complete: 'status.goalStatusComplete',
};
function goalStatusLabel(value: unknown): string | undefined {
const status = str(value);
if (!status) return undefined;
const key = GOAL_STATUS_KEYS[status];
return key ? t(key) : status;
}
function goalBudgetSummary(d: Record<string, unknown>): string | undefined {
const value = num(d.value);
const unit = str(d.unit);
if (value === undefined || !unit) return undefined;
switch (unit) {
case 'turns':
return t('tools.goal.turns', { value });
case 'tokens':
return t('tools.goal.tokens', { value });
case 'milliseconds':
return t('tools.goal.milliseconds', { value });
case 'seconds':
return t('tools.goal.seconds', { value });
case 'minutes':
return t('tools.goal.minutes', { value });
case 'hours':
return t('tools.goal.hours', { value });
default:
return t('tools.goal.budget', { value, unit });
}
}
const BASH_MAX = 64;
/**
@ -255,6 +302,27 @@ export function toolSummary(name: string, arg: string, full = false): string {
if (items) return c(t('tools.chip.todos', { count: items.length }));
return fallback();
}
case 'creategoal': {
if (full) return fallback();
const objective = str(d.objective);
const criterion = str(d.completionCriterion);
if (objective && criterion) return c(t('tools.goal.objectiveWithCriterion', { objective, criterion }));
return objective ? c(objective) : fallback();
}
case 'getgoal': {
if (full) return fallback();
return '';
}
case 'setgoalbudget': {
if (full) return fallback();
const summary = goalBudgetSummary(d);
return summary ? c(summary) : fallback();
}
case 'updategoal': {
if (full) return fallback();
const status = goalStatusLabel(d.status);
return status ? c(t('tools.goal.status', { status })) : fallback();
}
default:
return fallback();
}

View file

@ -2,7 +2,8 @@ import { createApp } from 'vue';
import App from './App.vue';
import i18n from './i18n';
import { installClientErrorCapture } from './debug/trace';
import '@fontsource-variable/inter/wght.css';
import '@fontsource-variable/inter/opsz.css';
import '@fontsource-variable/inter/opsz-italic.css';
import '@fontsource-variable/jetbrains-mono/wght.css';
import './style.css';

View file

@ -204,11 +204,9 @@ summary {
--ui-font-size-lg: calc(var(--ui-font-size) + 1px);
--ui-font-size-xl: calc(var(--ui-font-size) + 2px);
--content-font-size: calc(var(--base-ui-font-size) + 1px);
--sidebar-ui-font-size: calc(var(--base-ui-font-size) + 1px);
--mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
/* Body/UI font follows the design-system canonical token (system UI font
first; Inter intentionally NOT in the body chain it stays reserved for
--font-display headings). Mirrors --font-ui so the two can never drift. */
/* Body/UI font follows the design-system canonical token. Mirrors --font-ui
so the legacy alias can never drift from the current text face. */
--sans: var(--font-ui);
color-scheme: light dark;
}
@ -388,6 +386,15 @@ html[data-color-scheme="dark"][data-accent="mono"] {
--color-text-on-accent: #ffffff;
--color-line: #e7eaee;
--color-line-strong: #d4d9e0;
/* Neutral selected fill (sidebar rows, list pickers) deliberately NOT
accent-tinted, so selection reads as "where I am", not as an action. */
--color-selected: #00000014;
/* Row hover wash lighter than the selected fill (hover < selected); both
are translucent black so they sit naturally on any light surface. */
--color-hover: #0000000d;
/* Sidebar surface one step off --color-bg (warm off-white / near-black)
so the session column reads as its own plane. */
--color-sidebar-bg: #fbfaf9;
--color-accent: var(--accent-primary);
--color-accent-hover: #0f6fe0;
--color-accent-soft: #e8f3ff;
@ -454,14 +461,15 @@ html[data-color-scheme="dark"][data-accent="mono"] {
--duration-slow: 260ms;
/* -- type families ------------------------------------------------------- */
/* UI/body use the platform's native UI font first (design-system §02);
Inter is intentionally NOT in this chain it is reserved for
--font-display (optional headings / brand wordmarks) below. */
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans SC", "Noto Sans SC",
Roboto, Ubuntu, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-display: "Inter Variable", "Inter", var(--font-ui);
/* UI/body use self-hosted Inter first. CJK and platform system UI families
stay late in the fallback chain so Latin glyphs resolve to Inter while
Chinese text can still fall through to native CJK fonts. */
--font-ui: "Inter Variable", "Inter", "Helvetica Neue", Arial,
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans SC",
"Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Ubuntu, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-display: var(--font-ui);
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace,
"SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
@ -511,6 +519,9 @@ html[data-color-scheme="dark"] {
--color-text-faint: #6b7280;
--color-line: #2d333b;
--color-line-strong: #3d444d;
--color-selected: #ffffff14;
--color-hover: #ffffff0d;
--color-sidebar-bg: #181817;
--color-accent: #58a6ff;
--color-accent-hover: #79b8ff;
--color-accent-soft: rgba(88, 166, 255, 0.14);
@ -550,6 +561,9 @@ html[data-color-scheme="dark"] {
--color-text-faint: #6b7280;
--color-line: #2d333b;
--color-line-strong: #3d444d;
--color-selected: #ffffff14;
--color-hover: #ffffff0d;
--color-sidebar-bg: #181817;
--color-accent: #58a6ff;
--color-accent-hover: #79b8ff;
--color-accent-soft: rgba(88, 166, 255, 0.14);
@ -614,9 +628,16 @@ body {
A single mid-gray works on either background, so no per-theme tokens needed.
Components may still override locally (e.g. the sidebar's even-thinner rule).
--------------------------------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
/* Firefox-only fallback: the standard scrollbar properties DISABLE the whole
::-webkit-scrollbar customisation in Chromium 121+ (any non-auto value wins
over the pseudo-element styles), silently replacing the 6px/4px custom bars
with the ~11px native thin gutter. Scope them to engines that don't know
the webkit pseudo-element instead. */
@supports not selector(::-webkit-scrollbar) {
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
}
*::-webkit-scrollbar {
width: 6px;
@ -643,6 +664,7 @@ body {
font-size: var(--ui-font-size);
font-weight: 400;
line-height: 1.6;
font-optical-sizing: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: auto;

View file

@ -176,6 +176,7 @@ onUnmounted(() => {
<div class="color-card"><div class="color-chip" style="background:#ffffff"></div><div class="color-meta"><div class="cn">bg</div><div class="cv">#ffffff / #0d1117</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#fafbfc"></div><div class="color-meta"><div class="cn">surface</div><div class="cv">#fafbfc / #161b22</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#f3f5f8"></div><div class="color-meta"><div class="cn">surface-sunken</div><div class="cv">#f3f5f8 / #0d1117</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#eceff3"></div><div class="color-meta"><div class="cn">selected</div><div class="cv">#eceff3 / #2d333b</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#14171c"></div><div class="color-meta"><div class="cn">fg</div><div class="cv">#14171c / #e8eaed</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#6b7280"></div><div class="color-meta"><div class="cn">fg-muted</div><div class="cv">#6b7280 / #9aa0a8</div></div></div>
<div class="color-card"><div class="color-chip" style="background:#e7eaee"></div><div class="color-meta"><div class="cn">line</div><div class="cv">#e7eaee / #2d333b</div></div></div>
@ -191,6 +192,9 @@ onUnmounted(() => {
<tr><td class="tk">--color-text</td><td class="val"><span class="swatch" style="background:#14171c"></span>#14171c</td><td class="val"><span class="swatch" style="background:#e8eaed"></span>#e8eaed</td><td>Body text / headings</td></tr>
<tr><td class="tk">--color-text-muted</td><td class="val"><span class="swatch" style="background:#6b7280"></span>#6b7280</td><td class="val"><span class="swatch" style="background:#9aa0a8"></span>#9aa0a8</td><td>Secondary text / placeholder</td></tr>
<tr><td class="tk">--color-line</td><td class="val"><span class="swatch" style="background:#e7eaee"></span>#e7eaee</td><td class="val"><span class="swatch" style="background:#2d333b"></span>#2d333b</td><td>Divider / card border</td></tr>
<tr><td class="tk">--color-selected</td><td class="val"><span class="swatch" style="background:#00000014"></span>#00000014</td><td class="val"><span class="swatch" style="background:#ffffff14"></span>#ffffff14</td><td>Neutral selected fill (sidebar rows, list pickers) translucent, never accent-tinted</td></tr>
<tr><td class="tk">--color-hover</td><td class="val"><span class="swatch" style="background:#0000000d"></span>#0000000d</td><td class="val"><span class="swatch" style="background:#ffffff0d"></span>#ffffff0d</td><td>Row hover wash lighter than the selected fill (hover &lt; selected); translucent, sits on any surface</td></tr>
<tr><td class="tk">--color-sidebar-bg</td><td class="val"><span class="swatch" style="background:#fbfaf9"></span>#fbfaf9</td><td class="val"><span class="swatch" style="background:#181817"></span>#181817</td><td>Sidebar surface one step off <code>--color-bg</code> so the session column reads as its own plane</td></tr>
<tr><td class="tk">--color-accent</td><td class="val"><span class="swatch" style="background:#1783ff"></span>#1783ff</td><td class="val"><span class="swatch" style="background:#58a6ff"></span>#58a6ff</td><td>Primary action / link / focus</td></tr>
<tr><td class="tk">--color-success</td><td class="val"><span class="swatch" style="background:#0e7a38"></span>#0e7a38</td><td class="val"><span class="swatch" style="background:#3fb950"></span>#3fb950</td><td>Success / pass</td></tr>
<tr><td class="tk">--color-warning</td><td class="val"><span class="swatch" style="background:#a9610a"></span>#a9610a</td><td class="val"><span class="swatch" style="background:#d29922"></span>#d29922</td><td>Warning / pending</td></tr>
@ -227,18 +231,19 @@ onUnmounted(() => {
<p>All disabled controls use <code>opacity:.5</code> + <code>cursor:not-allowed</code> uniformly; do not separately grey out or recolor.</p>
<h3 class="sub">Font families</h3>
<p>Kimi Web uses two font families: <b>--font-ui</b> (UI and body, system fonts first) and <b>--font-mono</b> (code and monospace). Components always reference the variables; do not hard-code font names.</p>
<p>Kimi Web uses two font families: <b>--font-ui</b> (UI and body, Inter first) and <b>--font-mono</b> (code and monospace). Components always reference the variables; do not hard-code font names.</p>
<h4 class="mini">--font-ui · UI &amp; body (system fonts first)</h4>
<p>Body and UI use each platform's native UI font close to the system feel, comfortable for long text and CJK. Fallback chain:</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">--font-ui</span></div><pre>--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI",
<h4 class="mini">--font-ui · UI &amp; body (Inter first)</h4>
<p>Body and UI use self-hosted Inter as the primary face. CJK and platform system UI fonts sit late in the fallback chain so Latin glyphs resolve to Inter while Chinese text can fall through to native CJK fonts:</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">--font-ui</span></div><pre>--font-ui: "Inter Variable", "Inter", "Helvetica Neue", Arial,
"PingFang SC", "Microsoft YaHei", "Noto Sans SC",
"Helvetica Neue", Arial, sans-serif,
-apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Ubuntu, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";</pre></div>
<ul class="clean">
<li>System UI fonts first: SF Pro on macOS / iOS, Segoe UI on Windows.</li>
<li>CJK next: PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans SC (Linux).</li>
<li>Helvetica Neue / Arial / sans-serif as generic fallbacks; emoji fonts at the end.</li>
<li>Inter first: self-hosted Latin UI and body text, loaded through the optical-size normal and italic variable faces.</li>
<li>Western fallbacks next: Helvetica Neue / Arial for environments where Inter cannot load.</li>
<li>CJK and system UI fallbacks late: PingFang SC / Microsoft YaHei / Noto Sans SC, then platform UI fonts and emoji fonts.</li>
</ul>
<h4 class="mini">--font-mono · Code &amp; monospace</h4>
@ -251,8 +256,8 @@ onUnmounted(() => {
<thead><tr><th>Font</th><th>Source</th><th>Bundled</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">JetBrains Mono</td><td class="val">@fontsource-variable/jetbrains-mono</td><td class="val"> self-hosted</td><td>monospace / code (--font-mono)</td></tr>
<tr><td class="tk">Inter</td><td class="val">@fontsource-variable/inter</td><td class="val"> self-hosted</td><td>optional: page titles / brand wordmark (--font-display)</td></tr>
<tr><td class="tk">System UI / CJK fonts</td><td class="val">operating system</td><td class="val"></td><td>body / UI (--font-ui), not bundled</td></tr>
<tr><td class="tk">Inter</td><td class="val">@fontsource-variable/inter/opsz.css + opsz-italic.css</td><td class="val"> self-hosted</td><td>UI / body / display (--font-ui, --font-display), wght 100-900, opsz 14-32, normal + italic</td></tr>
<tr><td class="tk">System UI / CJK fonts</td><td class="val">operating system</td><td class="val"></td><td>late fallback for UI / body, not bundled</td></tr>
</tbody>
</table>
<div class="callout good"><span class="ico"></span><div>
@ -262,12 +267,13 @@ onUnmounted(() => {
<h4 class="mini">Usage rules</h4>
<ul class="clean check">
<li>Components always use <code>var(--font-ui)</code> / <code>var(--font-mono)</code>; do not hard-code font names like <code>'Inter'</code> / <code>'JetBrains Mono'</code>.</li>
<li>Body / UI use <code>--font-ui</code> (system fonts first); code / monospace use <code>--font-mono</code> (JetBrains Mono).</li>
<li>Inter is used only for headings / brand scenarios that need a unified look (optional <code>--font-display</code>); it is no longer the body default.</li>
<li>Body / UI use <code>--font-ui</code> (Inter first); code / monospace use <code>--font-mono</code> (JetBrains Mono).</li>
<li>Inter is loaded from the complete optical-size variable faces, including normal and italic styles; <code>font-optical-sizing: auto</code> is enabled globally.</li>
<li>CJK and platform system UI fonts stay late in the <code>--font-ui</code> fallback chain, after Inter and Western fallbacks.</li>
</ul>
<h3 class="sub">Type scale &amp; weight</h3>
<p>The user font-size preference writes <code>--base-ui-font-size</code>. Compact UI chrome follows it through <code>--ui-font-size</code>, while chat reading surfaces and the sidebar derive one readable step above it through <code>--content-font-size</code> and <code>--sidebar-ui-font-size</code>.</p>
<p>The user font-size preference writes <code>--base-ui-font-size</code>. Compact UI chrome and the sidebar follow it through <code>--ui-font-size</code>, while chat reading surfaces derive one readable step above it through <code>--content-font-size</code>.</p>
<p>The fixed product type tokens still define component defaults: <b>UI controls / buttons / forms</b> use <code>--text-base</code> (14px); <b>reading body including chat Markdown, message bubbles, etc.</b> stays one step larger than compact chrome for readability; the <b>sidebar session list</b> follows that same readable step while keeping list density.
Drop stray <code>font-weight: 650 / 750</code>; converge on two weights, 400 / 500 (regular / emphasis).</p>
<div class="panel panel-pad" style="margin:16px 0">
@ -281,11 +287,10 @@ onUnmounted(() => {
<table class="dt">
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">--font-ui</td><td class="val">-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC"</td><td>UI &amp; body (system fonts first)</td></tr>
<tr><td class="tk">--font-ui</td><td class="val">"Inter Variable", "Inter", "Helvetica Neue", Arial</td><td>UI &amp; body (Inter first)</td></tr>
<tr><td class="tk">--font-mono</td><td class="val">JetBrains Mono</td><td>code, tool names, line numbers, diffs</td></tr>
<tr><td class="tk">--base-ui-font-size</td><td class="val">14px user preference</td><td>root setting that drives UI, reading body, and sidebar font sizes</td></tr>
<tr><td class="tk">--content-font-size</td><td class="val">calc(base + 1px)</td><td>chat Markdown, message bubbles, composer</td></tr>
<tr><td class="tk">--sidebar-ui-font-size</td><td class="val">calc(base + 1px)</td><td>sidebar brand, search, workspace and session rows</td></tr>
<tr><td class="tk">--leading-tight/normal/relaxed</td><td class="val">1.25 / 1.5 / 1.7</td><td>headings / UI / long text</td></tr>
<tr><td class="tk">--weight-regular/medium</td><td class="val">400 / 500</td><td>body / emphasis</td></tr>
</tbody>
@ -565,6 +570,18 @@ onUnmounted(() => {
</div>
</div>
<!-- ===== Kbd ===== -->
<h3 class="sub">Kbd · keyboard shortcut</h3>
<p><b>Kbd</b> renders a shortcut as keycaps one block per key, never inline text like <code>(K)</code>. Caps are 18px tall (Badge sm rhythm): sunken surface, 1px border with a 2px bottom edge, 11px UI font, muted text. Typical placement: pushed to the row's trailing edge, opposite the label (e.g. the sidebar search row).</p>
<div class="stage-wrap">
<div class="stage-bar"><span class="st">Kbd · keycaps</span></div>
<div class="stage p">
<span class="p-kbd"><kbd></kbd><kbd>K</kbd></span>
<span class="p-kbd"><kbd>Ctrl</kbd><kbd>K</kbd></span>
<span class="p-kbd"><kbd></kbd><kbd></kbd><kbd>P</kbd></span>
</div>
</div>
<!-- ===== Card / Surface ===== -->
<h3 class="sub">Card / Surface</h3>
<p>All cards across the site share <b>one shell</b>: flat, <code>1px</code> border, <code>--radius-md</code> radius, <b>no shadow</b>. The structure is split into three parts <code>head / body / foot</code>. Cards differ <b>only in the head</b> in two tiers by visual weight, while the shell stays consistent:</p>
@ -1336,13 +1353,13 @@ onUnmounted(() => {
</p>
<h3 class="sub">Layout grid</h3>
<p>On desktop it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles.</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">App.vue · .app</span></div><pre>grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
/* sidebar ↑ ↑handle ↑conversation ↑handle ↑right panel (auto) */</pre></div>
<p>On desktop it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent <code>auto</code> track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles.</p>
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">App.vue · .app</span></div><pre>grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
/* sidebar ↑ ↑handle ↑conversation ↑handle ↑right panel (auto) */</pre></div>
<table class="dt">
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">--side-w</td><td class="val">248px (adjustable)</td><td>left conversation column width, changed by dragging the ResizeHandle; should approach §02's <code>--p-sidebar-w</code> (264px)</td></tr>
<tr><td class="tk">sidebar width</td><td class="val">270px default (adjustable)</td><td>expanded sidebar width, changed by dragging the ResizeHandle; should approach §02's <code>--p-sidebar-w</code> (264px)</td></tr>
<tr><td class="tk">--preview-w</td><td class="val">460px</td><td>width of the right preview panel when open</td></tr>
<tr><td class="tk">--panel-head-h</td><td class="val">48px</td><td>unified height for all right panel heads + the conversation column head, so the hairline runs as one line</td></tr>
<tr><td class="tk">--p-bp-sm</td><td class="val">640px</td><td>640 switches to a mobile single column (top bar + conversation), no sidebar / handle / right panel</td></tr>
@ -1350,17 +1367,18 @@ onUnmounted(() => {
</table>
<ul class="clean">
<li>The right panel track exists permanently, with its width transitioning between <code>0 var(--preview-w)</code> (when open it squeezes the conversation column, rather than switching templates).</li>
<li>When the sidebar is collapsed, track 1 becomes a thin "rail" holding only an expand IconButton, avoiding crushing the conversation column head.</li>
<li>The sidebar collapses SYMMETRICALLY to the right panel: its container width animates to 0 while the content keeps its fixed width anchored to the right edge (clipped, sliding out left no reflow, hairline stays on the clipped content). No rail remains. The collapse control differs by platform: on <b>macOS desktop</b> the toggle is a single resident floating IconButton pinned beside the traffic lights (rendered in both states, only the glyph swaps the sidebar slides underneath it, never moves or flashes); on <b>Windows / web</b> the collapse button lives inside the sidebar header (right-aligned), and a floating expand button appears at the top-left only while collapsed. The conversation header pads left in step with the transition while collapsed.</li>
<li>All grid children must have <code>min-height:0; min-width:0</code>, so only the inner scroll containers scroll and the page itself does not scroll.</li>
</ul>
<h3 class="sub">Sidebar alignment system (<code>--sb-*</code>)</h3>
<p>All sidebar rows (group head, session row, New chat button) share 3 custom properties, so the "session title" aligns precisely under the "workspace name".</p>
<p>All sidebar rows (group head, session row, New chat button) share 4 custom properties, so the "session title" aligns precisely under the "workspace name".</p>
<table class="dt">
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">--sb-pad-x</td><td class="val">16px</td><td>row horizontal padding</td></tr>
<tr><td class="tk">--sb-gutter</td><td class="val">20px</td><td>leading icon slot width (14px icon + 6px whitespace)</td></tr>
<tr><td class="tk">--sb-inset</td><td class="val">12px</td><td>row box (hover/selected pill) inset from the sidebar edges matches the brand header's 12px padding</td></tr>
<tr><td class="tk">--sb-pad-x</td><td class="val">20px</td><td>content start x (= --sb-inset + 8px row padding)</td></tr>
<tr><td class="tk">--sb-gutter</td><td class="val">16px</td><td>leading icon slot width matches the workspace folder icon so the session title aligns under the workspace name</td></tr>
<tr><td class="tk">--sb-gap</td><td class="val">6px</td><td>gap between the icon slot and the text</td></tr>
</tbody>
</table>
@ -1369,15 +1387,16 @@ onUnmounted(() => {
</div></div>
<h3 class="sub">Sidebar structure</h3>
<p>The sidebar from top to bottom: brand header search New chat grouped list (workspace head + session rows). Controls reuse the §03 primitives as much as possible.</p>
<p>The sidebar from top to bottom: brand header New chat search grouped list (workspace head + session rows) settings footer. Controls reuse the §03 primitives as much as possible. The sidebar sits on <code>--color-sidebar-bg</code> (one step off <code>--color-bg</code>: warm off-white in light, near-black in dark the session column reads as its own plane; the hairline still separates it from the conversation pane). Vertical rhythm: the brand header keeps 12px padding (on macOS desktop the left padding grows to 80px to clear the traffic lights); rows inside the actions group (New chat + search) stack flush (0 gap, same rhythm as the list rows); adjacent groups are separated by 12px. Row hover uses <code>--sb-hover</code> (= the global <code>--color-hover</code> wash); the selected row uses <code>--color-selected</code> neutral, never the accent.</p>
<table class="dt">
<thead><tr><th>Block</th><th>Use</th><th>Note</th></tr></thead>
<tbody>
<tr><td>Brand header</td><td>logo + name + IconButton</td><td>collapse / settings use IconButton sm; the logo is animated (a blinking eye)</td></tr>
<tr><td>Search</td><td>bare search row (custom)</td><td>no border, hover/focus shows a sunken background; icon + input + clear IconButton. <b>Do not</b> use Input (the 38px bordered version is too heavy)</td></tr>
<tr><td>New chat</td><td>full-width left-aligned button (custom)</td><td>same rhythm as the session rows in the list (left-aligned, hover sunken). <b>Do not</b> use Button (centered, breaks the rhythm)</td></tr>
<tr><td>Brand header</td><td>logo + name + collapse IconButton (right-aligned)</td><td>on Windows / web the brand is left and the collapse IconButton sm is right-aligned inside the header; the logo is animated (a blinking eye). On macOS desktop the header is a bare drag strip (brand hidden, traffic lights + resident floating toggle over it)</td></tr>
<tr><td>New chat</td><td>full-width left-aligned button (custom)</td><td>same rhythm as the session rows in the list (left-aligned, hover = <code>--sb-hover</code>). <b>Do not</b> use Button (centered, breaks the rhythm)</td></tr>
<tr><td>Search</td><td>bare search row (custom)</td><td>no border, hover/focus shows a sunken background; icon + label, with the <code>Kbd</code> keycaps (K / Ctrl K) pushed to the trailing edge label and shortcut are justified apart. <b>Do not</b> use Input (the 38px bordered version is too heavy). Last fixed row above the list its wrapper carries the scroll-linked seam</td></tr>
<tr><td>Section label</td><td><code>.p-section-label</code></td><td>uppercase muted small titles like "Workspaces"</td></tr>
<tr><td>Workspace head / session row</td><td>see next two sections</td><td>share <code>--sb-*</code> alignment</td></tr>
<tr><td>Settings footer</td><td>full-width left-aligned button (custom)</td><td>pinned row under the session list, separated by a 1px <code>--line</code> top border; icon + label, same list-style family as New chat</td></tr>
</tbody>
</table>
<div class="callout warn"><span class="ico">!</span><div>
@ -1389,7 +1408,7 @@ onUnmounted(() => {
<table class="dt">
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
<tbody>
<tr><td>Container</td><td><code>margin: 1px 6px; padding: 7px 10px; radius-md</code>; hover = <code>surface-sunken</code>; active = <code>accent-soft</code> + <code>inset 0 0 0 1px accent-bd</code></td></tr>
<tr><td>Container</td><td><code>padding: 8px 8px</code> inside the list's <code>--sb-inset</code> gutter, <code>radius-sm</code>; <b>no fixed/min height</b> row height is font-driven (title <code>line-height: --leading-tight</code>, 16px) 32px total, the sidebar-wide row rhythm. The hover kebab is absolutely positioned so it never forces the row taller (no hover jitter). hover = <code>--sb-hover</code> (the global <code>--color-hover</code> wash); active = <code>--color-selected</code> neutral, no accent tint, no border, no weight change</td></tr>
<tr><td>Status slot (lead)</td><td>fixed <code>--sb-gutter</code> width; running = <code>Spinner</code> sm, otherwise unread = 7px accent dot</td></tr>
<tr><td>Title</td><td>flex:1 with truncation; double-click enters inline rename (compact input, not Input)</td></tr>
<tr><td>Time</td><td>mono xs, <code>fg-faint</code>; yields to the kebab on hover</td></tr>
@ -1400,11 +1419,11 @@ onUnmounted(() => {
</table>
<h3 class="sub">Workspace group</h3>
<p>The group head and session rows share <code>--sb-*</code>: folder icon (open/closed) name path subtitle, with the kebab and "+" revealed on hover.</p>
<p>The group head and session rows share <code>--sb-*</code>: folder icon (open/closed) name, with the kebab and "+" revealed on hover.</p>
<ul class="clean">
<li>The folder icon sits in the <code>--sb-gutter</code> slot, switching icons between open and closed states.</li>
<li>A small <code>fg-muted</code> path line sits below the name.</li>
<li>The kebab (menu) and "+" (new chat in this workspace) both use <code>IconButton</code> sm, shown on hover or keyboard focus (when not hovered they stay in the tab order via <code>opacity:0</code>, keeping them keyboard-reachable).</li>
<li>The folder icon leads the row (switching icons between open and closed states) with the plain <code>--sb-gap</code> before the name it does not pad out the <code>--sb-gutter</code> slot.</li>
<li>The name is quiet by design regular weight, muted color (<code>--color-text-muted</code>, one step lighter than session titles), so group heads read as grouping labels. No path subtitle; hovering the name shows the full root path in a <code>Tooltip</code>.</li>
<li>The kebab (menu) and "+" (new chat in this workspace) both use <code>IconButton</code> sm inside a floating actions layer anchored to the row's right edge — no reserved layout space, so the name uses the full row width when idle. Shown on hover, keyboard focus, or while the menu is open; the layer backs itself with the sidebar surface (container background) plus the row hover wash (an <code>::after</code> shown only while the row is hovered), so its color exactly equals the row's current background and the overlapped name tail doesn't bleed through (hidden via <code>opacity:0</code>, staying in the tab order).</li>
<li>The group is collapsible; when collapsed its session list is hidden.</li>
</ul>
@ -1413,7 +1432,7 @@ onUnmounted(() => {
<table class="dt">
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
<tbody>
<tr><td class="tk">Container</td><td>session-row pill: <code>display:flex; gap:--sb-gap; min-height:26px</code>, same padding as a session row, <code>radius-md</code>; hover = <code>surface-sunken</code> (no text recolor); <code>:focus-visible</code> uses <code>--p-focus-ring</code></td></tr>
<tr><td class="tk">Container</td><td>session-row pill: <code>display:flex; gap:--sb-gap; padding:8px </code>, <b>no fixed/min height</b> (font-driven, 32px like a session row), same padding as a session row, <code>radius-sm</code>; hover = <code>--sb-hover</code> (no text recolor); <code>:focus-visible</code> uses <code>--p-focus-ring</code></td></tr>
<tr><td class="tk">Lead slot</td><td>empty, <code>--sb-gutter</code> wide, so the label's start x aligns with the session titles (<code>--sb-pad-x + --sb-gutter + --sb-gap</code>)</td></tr>
<tr><td class="tk">Label</td><td><code>font-ui</code>, <code>text-xs</code>, <code>--color-text</code>; flex:1, truncated</td></tr>
<tr><td class="tk">Behavior</td><td>"Load more" fetches the next page and auto-expands; once more than the first page is loaded, "Show less" appears and collapses back to the first page (view-layer trim data is kept, no refetch); "Show all" re-expands</td></tr>
@ -1442,7 +1461,7 @@ onUnmounted(() => {
</ul>
<div class="callout info"><span class="ico">i</span><div>
<b>One-sentence principle:</b> the sidebar / shell is a "list + grid" skeleton that reuses the §02 tokens and §03 primitives (Button / IconButton / Badge / Menu / Spinner / PanelHeader); compact list controls that don't fit a primitive (search, New chat, inline rename, show-more) keep their custom form, governed by this section.
<b>One-sentence principle:</b> the sidebar / shell is a "list + grid" skeleton that reuses the §02 tokens and §03 primitives (Button / IconButton / Badge / Kbd / Menu / Spinner / PanelHeader); compact list controls that don't fit a primitive (search, New chat, inline rename, show-more) keep their custom form, governed by this section.
</div></div>
</section>
@ -1600,7 +1619,7 @@ onUnmounted(() => {
.brand-name { font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
.brand-sub { font-size: 12px; color: var(--d-fg-faint); margin-bottom: 26px; padding-left: 36px; }
.nav-group { margin: 22px 0 8px; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--d-fg-faint); }
.p-section-label { font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--d-fg-faint); }
.p-section-label { font-size: 12px; font-weight: 400; text-transform: uppercase; color: var(--d-fg-faint); }
.nav a {
display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-radius: 7px;
font-size: 13.5px; font-weight: 500; color: var(--d-fg-soft); margin: 1px 0;
@ -1932,6 +1951,16 @@ onUnmounted(() => {
.p-badge.solid { background: var(--p-text); color: var(--p-bg); border-color: var(--p-text); }
.p-badge .p-ic { width: 12px; height: 12px; }
/* Kbd — shortcut keycaps (one <kbd> block per key) */
.p-kbd { display: inline-flex; align-items: center; gap: 3px; }
.p-kbd kbd {
display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; padding: 0 5px;
border: 1px solid var(--p-line); border-bottom-width: 2px; border-radius: var(--p-r-xs);
background: var(--p-surface-sunken); color: var(--p-text-muted);
font-family: var(--p-font-sans); font-size: 11px; line-height: 1;
}
/* model / mode pill (composer toolbar) */
.p-pill {
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px;

View file

@ -1,7 +1,9 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Icons from 'unplugin-icons/vite';
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const webPort = Number(process.env.WEB_PORT) || 5175;
// Where the dev proxy forwards server traffic. Defaults to the local server
@ -12,7 +14,19 @@ const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url),
};
export default defineConfig({
plugins: [vue(), Icons({ compiler: 'vue3' })],
plugins: [
vue(),
Icons({
compiler: 'vue3',
// Local Kimi Design System icons (24×24 outlined, fill="currentColor"),
// copied from the design-system icon pack into src/icons/kimi/ and
// imported as `~icons/kimi/<file-name>` (plus `?raw`), same as the ri
// collection. Registered in src/lib/icons.ts only.
customCollections: {
kimi: FileSystemIconLoader(fileURLToPath(new URL('./src/icons/kimi', import.meta.url))),
},
}),
],
// Expose the dev proxy's upstream server target to the client so the UI can
// show which server it is connected to (the browser otherwise only sees its
// own same-origin URL). Unused by the same-origin production build.

View file

@ -87,11 +87,12 @@ Fields in the config file fall into two categories: **top-level scalars** that d
| `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) |
| `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) |
| `background` | `table` | — | Background task runtime parameters → [`background`](#background) |
| `image` | `table` | — | Image compression parameters → [`image`](#image) |
| `services` | `table` | — | Built-in external service configuration → [`services`](#services) |
| `permission` | `table` | — | Initial permission rules → [`permission`](#permission) |
| `hooks` | `array<table>` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) |
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `services`, and `permission`.
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `image`, `services`, and `permission`.
## `providers`
@ -202,6 +203,17 @@ You can also switch models temporarily without touching the config file — by s
In print mode (`kimi -p "<prompt>"`), Kimi Code runs a single non-interactive turn and exits as soon as the main agent finishes. If you launch background tasks (for example, concurrent subagents via `Agent(run_in_background=true)`) and need them to run to completion, set `keep_alive_on_exit = true`: the process then waits for every background task to reach a terminal state before exiting, bounded by `print_wait_ceiling_s`. Without it, the single turn ending tears background tasks down with the process.
## `image`
`image` controls how images are compressed before being sent to the model, across every ingestion point (pasted images, `ReadMediaFile` reads, images in MCP tool results, and so on).
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `max_edge_px` | `integer` | `2000` | Longest-edge ceiling in pixels. Larger images are scaled down proportionally to fit; raising it preserves more detail at the cost of larger request bodies |
| `read_byte_budget` | `integer` | `262144` (256 KB) | Per-image byte budget for images the model reads for itself (`ReadMediaFile` default reads). It bounds the accumulated request-body size when the model keeps screenshotting and reading images; fine detail stays reachable through the `region` parameter, which reads a crop back at full fidelity (`region` and `full_resolution` are not subject to this budget) |
`max_edge_px` can be overridden by the `KIMI_IMAGE_MAX_EDGE_PX` environment variable and `read_byte_budget` by `KIMI_IMAGE_READ_BYTE_BUDGET`; both take higher priority than `config.toml`.
<!--
## `experimental`

View file

@ -122,6 +122,8 @@ Switches that control the behavior of subsystems such as telemetry, background t
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_IMAGE_MAX_EDGE_PX` | Longest-edge ceiling (px) for image compression; takes higher priority than `[image] max_edge_px` in `config.toml` (default `2000`) | Positive integer; invalid values are ignored |
| `KIMI_IMAGE_READ_BYTE_BUDGET` | Per-image byte budget for model-initiated image reads (`ReadMediaFile` default reads); takes higher priority than `[image] read_byte_budget` in `config.toml` (default `262144`, i.e. 256 KB) | Positive integer; invalid values are ignored |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` |

View file

@ -87,11 +87,12 @@ timeout = 5
| `thinking` | `table` | — | Thinking 模式默认参数 → [`thinking`](#thinking) |
| `loop_control` | `table` | — | Agent 循环控制参数 → [`loop_control`](#loop_control) |
| `background` | `table` | — | 后台任务运行参数 → [`background`](#background) |
| `image` | `table` | — | 图片压缩参数 → [`image`](#image) |
| `services` | `table` | — | 内置外部服务配置 → [`services`](#services) |
| `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) |
| `hooks` | `array<table>` | — | 生命周期 hook详见 [Hooks](../customization/hooks.md) |
以下各节对 `providers``models``thinking``loop_control``background``services`、`permission` 等嵌套表逐一展开。
以下各节对 `providers``models``thinking``loop_control``background``image`、`services`、`permission` 等嵌套表逐一展开。
## `providers`
@ -202,6 +203,17 @@ display_name = "Kimi for Coding (custom)"
在 print 模式(`kimi -p "<prompt>"`Kimi Code 只跑一个非交互的单轮 turn主 agent 一结束就退出。如果你启动了后台任务(例如通过 `Agent(run_in_background=true)` 并发子代理)并希望它们跑完,请设置 `keep_alive_on_exit = true`:进程会在退出前等待所有后台任务进入终态,最长不超过 `print_wait_ceiling_s`。否则,单轮 turn 结束时后台任务会随进程一起被清理。
## `image`
`image` 控制图片发送给模型前的压缩行为,对所有图片入口生效(粘贴图片、`ReadMediaFile` 读图、MCP 工具结果里的图片等)。
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `max_edge_px` | `integer` | `2000` | 图片最长边上限(像素)。超过时按比例缩小到该值以内;调大可保留更多细节,代价是更大的请求体积 |
| `read_byte_budget` | `integer` | `262144`256 KB | 模型自行读取的图片(`ReadMediaFile` 默认读取)的单图字节预算。会话中模型反复截图、读图时,累计请求体大小由它控制;细节可通过 `region` 参数按原图坐标全保真回读(`region``full_resolution` 不受此预算限制) |
`max_edge_px` 可被环境变量 `KIMI_IMAGE_MAX_EDGE_PX` 覆盖,`read_byte_budget` 可被 `KIMI_IMAGE_READ_BYTE_BUDGET` 覆盖,优先级均高于配置文件。
<!--
## `experimental`

View file

@ -122,6 +122,8 @@ kimi
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1``true``yes``y`(不区分大小写) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_IMAGE_MAX_EDGE_PX` | 图片压缩的最长边上限(像素),优先级高于 `config.toml``[image] max_edge_px`(默认 `2000` | 正整数;非法值被忽略 |
| `KIMI_IMAGE_READ_BYTE_BUDGET` | 模型自行读图(`ReadMediaFile` 默认读取)的单图字节预算,优先级高于 `config.toml``[image] read_byte_budget`(默认 `262144`,即 256 KB | 正整数;非法值被忽略 |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://``file://` URL 和本地路径 |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1``true``yes``on` |

View file

@ -158,7 +158,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-8WMw4UzsP4dZHd+0JrIrIAkWeFjE5yV5m7Hp/trjras=";
hash = "sha256-iBk+TV+rIhmd7bYnVFbW3kTGltojJl3pL2hhmsGO+Fk=";
};
nativeBuildInputs = [

View file

@ -59,6 +59,7 @@
},
"dependencies": {
"@antfu/utils": "^9.3.0",
"@jsquash/webp": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@moonshot-ai/kaos": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",

View file

@ -0,0 +1,36 @@
/**
* Regenerate `src/tools/support/webp-dec-wasm.ts` from the installed
* `@jsquash/webp` package.
*
* The WebP decoder wasm is committed as a base64 string module because the
* published CLI bundles every dependency into a single file with no runtime
* node_modules a file-path lookup for the .wasm would break there, while a
* string constant survives every packaging (vitest on sources, tsdown
* bundling, nix builds) unchanged. Run this after bumping @jsquash/webp:
*
* node scripts/generate-webp-dec-wasm.mjs
*/
import { createRequire } from 'node:module';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const packageRoot = resolve(import.meta.dirname, '..');
const require = createRequire(resolve(packageRoot, 'package.json'));
const wasmPath = require.resolve('@jsquash/webp/codec/dec/webp_dec.wasm');
const version = require('@jsquash/webp/package.json').version;
const wasm = readFileSync(wasmPath);
const target = resolve(packageRoot, 'src/tools/support/webp-dec-wasm.ts');
writeFileSync(
target,
`// GENERATED FILE — do not edit by hand.
// WebP decoder wasm from @jsquash/webp@${version} (codec/dec/webp_dec.wasm),
// base64-encoded so the bundled CLI needs no on-disk wasm asset.
// Regenerate with: node scripts/generate-webp-dec-wasm.mjs
export const WEBP_DECODER_WASM_BASE64 =
'${wasm.toString('base64')}';
`,
);
console.log(`Wrote ${target} (${wasm.length} bytes of wasm)`);

View file

@ -8,10 +8,12 @@ import {
APIEmptyResponseError,
inputTotal,
isRetryableGenerateError,
type ContentPart,
type GenerateResult,
type Message,
type TokenUsage,
APIContextOverflowError,
APIRequestTooLargeError,
APIStatusError,
createUserMessage,
} from '@moonshot-ai/kosong';
@ -430,6 +432,7 @@ export class FullCompaction {
// prefix-race check and `compactedCount`.
let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory);
let droppedCount = 0;
let mediaStripAttempted = false;
let overflowShrinkCount = 0;
let emptyOrTruncatedShrinkCount = 0;
while (true) {
@ -465,6 +468,24 @@ export class FullCompaction {
summary = extractCompactionSummary(response);
break;
} catch (error) {
// A request-body-size rejection (HTTP 413) is first retried with
// media parts replaced by text markers: accumulated base64 payloads
// are the usual culprit, and a text summary does not need them —
// the conversation already narrates what was seen, and the
// ReadMediaFile `<image path="...">` text wrapper survives. Only
// the summarizer input copy is rewritten; the real history keeps
// its media. A 413 after the strip (or with no media to strip)
// falls through to the overflow shrink below — dropping oldest
// messages shrinks the body too.
if (error instanceof APIRequestTooLargeError && !mediaStripAttempted) {
mediaStripAttempted = true;
const stripped = replaceMediaPartsWithMarkers(historyForModel);
if (stripped !== historyForModel) {
historyForModel = stripped;
retryCount = 0;
continue;
}
}
const isContextOverflow = this.shouldRecoverFromContextOverflow(
error,
estimatedCompactionRequestTokens,
@ -472,7 +493,9 @@ export class FullCompaction {
if (isContextOverflow) {
this.observeContextOverflow(estimatedCompactionRequestTokens);
}
if (isContextOverflow && historyForModel.length > 1) {
const shouldShrinkAfterOverflow =
isContextOverflow || error instanceof APIRequestTooLargeError;
if (shouldShrinkAfterOverflow && historyForModel.length > 1) {
overflowShrinkCount += 1;
if (overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS) {
throw error;
@ -639,6 +662,40 @@ export class FullCompaction {
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;
const MEDIA_PART_MARKERS = {
image_url: '[image]',
audio_url: '[audio]',
video_url: '[video]',
} as const;
function isMediaPart(part: ContentPart): part is ContentPart & { type: keyof typeof MEDIA_PART_MARKERS } {
return part.type in MEDIA_PART_MARKERS;
}
/**
* Replace media parts (image/audio/video) with text markers in the summarizer
* input, for the 413 strip-and-retry above. Messages without media are
* returned by reference (keeping the per-message token-estimate cache warm),
* and when nothing changed the input array itself is returned so the caller
* can tell there was no media to strip.
*/
function replaceMediaPartsWithMarkers(
messages: readonly ContextMessage[],
): readonly ContextMessage[] {
let changed = false;
const out = messages.map((message) => {
if (!message.content.some(isMediaPart)) return message;
changed = true;
return {
...message,
content: message.content.map((part): ContentPart =>
isMediaPart(part) ? { type: 'text', text: MEDIA_PART_MARKERS[part.type] } : part,
),
};
});
return changed ? out : messages;
}
function shrinkCompactionHistoryAfterOverflow<T extends Message>(
messages: readonly T[],
attempt: number,

View file

@ -48,7 +48,7 @@ export class ConfigState {
});
if (changed.cwd) {
this._cwd = changed.cwd;
void this.agent.kaos.chdir(changed.cwd);
this.agent.setKaos(this.agent.kaos.withCwd(changed.cwd));
}
if (changed.modelAlias) {
this._modelAlias = changed.modelAlias;

View file

@ -18,6 +18,8 @@ import {
type CompactionResult,
} from '../compaction';
import {
degradeOlderMediaParts,
MEDIA_DEGRADE_KEEP_RECENT,
project,
type ProjectionAnomaly,
type ProjectOptions,
@ -488,6 +490,17 @@ export class ContextMemory {
});
}
// Fallback projection for the post-413 media-degraded resend: the normal
// wire projection with all but the most recent media parts replaced by text
// markers, so a request body bloated by accumulated base64 media fits the
// provider's size limit. Purely read-side — the history keeps its media —
// and only used when the provider has already rejected the normal
// projection as too large; see the request-too-large fallback in
// `turn-step`.
get mediaDegradedMessages(): Message[] {
return degradeOlderMediaParts(this.messages, MEDIA_DEGRADE_KEEP_RECENT);
}
useProjectedHistoryFrom(source: ContextMemory): void {
this.clear();
this.pushHistory(...trimTrailingOpenToolExchange(source.project(source.history)));

View file

@ -465,3 +465,58 @@ export function trimTrailingOpenToolExchange(history: readonly Message[]): Messa
const closed = assistant.toolCalls.every((toolCall) => trailingToolCallIds.has(toolCall.id));
return closed ? [...history] : history.slice(0, lastNonToolIndex);
}
/**
* How many of the most recent media parts survive the media-degraded
* projection. The tail images are what the model is actively working from
* (the screenshot it just took); everything older is replaced by a marker.
*/
export const MEDIA_DEGRADE_KEEP_RECENT = 2;
const MEDIA_DEGRADED_PLACEHOLDERS = {
image_url:
'[image omitted: dropped to fit the provider request size limit; re-read the file to view it]',
audio_url:
'[audio omitted: dropped to fit the provider request size limit; re-read the file to hear it]',
video_url:
'[video omitted: dropped to fit the provider request size limit; re-read the file to view it]',
} as const;
function isDegradableMediaPart(
part: ContentPart,
): part is ContentPart & { type: keyof typeof MEDIA_DEGRADED_PLACEHOLDERS } {
return part.type in MEDIA_DEGRADED_PLACEHOLDERS;
}
/**
* Replace all but the `keepRecent` most recent media parts with deterministic
* text markers. This is the media-degraded projection used to resend a request
* the provider rejected as too large (HTTP 413 on accumulated base64 media):
* a purely read-side transform the underlying history is left untouched
* that trades old pixels for bytes while the surrounding text (including
* ReadMediaFile's `<image path="...">` wrapper) survives, so the model can
* re-read any file it still needs. Untouched messages are returned by
* reference, and when nothing needs degrading the input array itself is
* returned.
*/
export function degradeOlderMediaParts(
messages: readonly Message[],
keepRecent: number,
): Message[] {
const mediaCount = messages.reduce(
(count, message) => count + message.content.filter(isDegradableMediaPart).length,
0,
);
let toDegrade = Math.max(0, mediaCount - keepRecent);
if (toDegrade === 0) return messages as Message[];
return messages.map((message) => {
if (toDegrade === 0 || !message.content.some(isDegradableMediaPart)) return message;
const content = message.content.map((part): ContentPart => {
if (toDegrade === 0 || !isDegradableMediaPart(part)) return part;
toDegrade -= 1;
return { type: 'text', text: MEDIA_DEGRADED_PLACEHOLDERS[part.type] };
});
return { ...message, content };
});
}

View file

@ -177,8 +177,9 @@ export interface AgentRecordEvents {
messageCount: number;
turnStep?: string;
attempt?: string;
/** Set when this request is the strict wire-compliant rebuild resend. */
projection?: 'strict';
/** Set when this request is a fallback resend (strict rebuild or
* media-degraded rebuild). */
projection?: 'strict' | 'media-degraded';
/** Compaction only: messages dropped so far by overflow/empty shrinking. */
droppedCount?: number;
};

View file

@ -731,6 +731,7 @@ export class TurnFlow {
llm: this.agent.llm,
buildMessages: () => this.agent.context.messages,
buildMessagesStrict: () => this.agent.context.strictMessages,
buildMessagesMediaDegraded: () => this.agent.context.mediaDegradedMessages,
dispatchEvent: this.buildDispatchEvent(turnId),
// Re-read per step (not snapshotted per turn) so a select_tools load
// is dispatchable on the very next step of the same turn.

View file

@ -7,32 +7,28 @@ import { canonicalTelemetryArgs } from './canonical-args';
const REMINDER_TEXT_1 =
'\n\n<system-reminder>\n' +
'You are repeating the exact same tool call with identical parameters.' +
' Please carefully analyze the previous result. If the task is not yet complete,' +
' try a different method or parameters instead of repeating the same call.' +
'The same tool call has been repeated several times in a row. ' +
'Before making your next call, write one sentence stating what new information you expect it to produce. ' +
'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.' +
'\n</system-reminder>';
function makeReminderText2(toolName: string, repeatCount: number, args: unknown): string {
const argsStr = canonicalTelemetryArgs(args);
function makeReminderText2(repeatCount: number): string {
return (
'\n\n<system-reminder>\n' +
'You have repeatedly called the same tool with identical parameters many times.\n' +
'Repeated tool call detected:\n' +
`- tool: ${toolName}\n` +
`- repeated_times: ${String(repeatCount)}\n` +
`- arguments: ${argsStr}\n` +
'The previous repeated calls did not make progress. Do not call this exact same tool with the exact same arguments again.\n' +
'Carefully inspect the latest tool result and choose a different next action, different parameters, or finish the task if enough evidence has been gathered.' +
`The same tool call has now been issued ${String(repeatCount)} times in a row. ` +
'Choose exactly one of the following and state your choice before acting:\n' +
'(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' +
'(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' +
'(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.' +
'\n</system-reminder>'
);
}
const REMINDER_TEXT_3 =
'\n\n<system-reminder>\n' +
'You are stuck in a dead end and have repeatedly made the same function call without progress.\n' +
'Stop all function calls immediately. Do not call any tool in your next response.\n' +
'In analysis, review the current execution state and identify why progress is blocked.\n' +
'Then return a text-only summary to the user that reports the current problem, what has already been tried, and what information or decision is needed next.' +
'Write your final response now, without any further tool calls. ' +
'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' +
'Text only.' +
'\n</system-reminder>';
const REPEAT_REMINDER_1_START = 3;
@ -105,8 +101,8 @@ const DEDUP_PLACEHOLDER_RESULT: ExecutableToolResult = { output: '' };
* - Cross-step dedup: when the exact same call is repeated consecutively
* across steps, the result returned to the model is suffixed with a system
* reminder once the streak hits 3. The reminder escalates as the streak
* grows: r1 (gentle nudge) from streak 3, r2 (concrete repeat report) from
* streak 5, r3 (dead-end stop instruction) from streak 8. From streak 12
* grows: r1 (expectation-setting nudge) from streak 3, r2 (forced decision
* menu) from streak 5, r3 (final hand-off instruction) from streak 8. From streak 12
* onward the turn is force-stopped via `{ stopTurn: true }` so the loop
* cannot keep spinning on the same call. Force-stop does not flip a
* successful tool result into an error the underlying tool's `isError`
@ -239,7 +235,7 @@ export class ToolCallDeduplicator {
finalResult = appendReminder(result, REMINDER_TEXT_3);
action = 'r3';
} else if (streak >= REPEAT_REMINDER_2_START) {
finalResult = appendReminder(result, makeReminderText2(toolName, streak, args));
finalResult = appendReminder(result, makeReminderText2(streak));
action = 'r2';
} else if (streak >= REPEAT_REMINDER_1_START) {
finalResult = appendReminder(result, REMINDER_TEXT_1);

View file

@ -134,6 +134,25 @@ export const BackgroundConfigSchema = z.object({
export type BackgroundConfig = z.infer<typeof BackgroundConfigSchema>;
export const ImageConfigSchema = z.object({
/**
* Longest-edge ceiling (px) applied when compressing images for the model.
* Overrides the built-in default; the KIMI_IMAGE_MAX_EDGE_PX env var wins
* over this value.
*/
maxEdgePx: z.number().int().min(1).optional(),
/**
* Raw-byte budget for images the model reads for itself (ReadMediaFile's
* default path). Overrides the built-in default; the
* KIMI_IMAGE_READ_BYTE_BUDGET env var wins over this value. Explicit
* region / full_resolution reads use the provider-scale per-image limit
* instead.
*/
readByteBudget: z.number().int().min(1).optional(),
});
export type ImageConfig = z.infer<typeof ImageConfigSchema>;
export const ModelCatalogConfigSchema = z.object({
/** Interval (ms) between automatic provider-model refreshes. `0` disables. */
refreshIntervalMs: z.number().int().min(0).optional(),
@ -256,6 +275,7 @@ export const KimiConfigSchema = z.object({
extraSkillDirs: z.array(z.string()).optional(),
loopControl: LoopControlSchema.optional(),
background: BackgroundConfigSchema.optional(),
image: ImageConfigSchema.optional(),
modelCatalog: ModelCatalogConfigSchema.optional(),
experimental: ExperimentalConfigSchema.optional(),
telemetry: z.boolean().optional(),
@ -270,6 +290,7 @@ const ThinkingConfigPatchSchema = ThinkingConfigSchema.partial();
const PermissionConfigPatchSchema = PermissionConfigSchema.partial();
const LoopControlPatchSchema = LoopControlSchema.partial();
const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial();
const ImageConfigPatchSchema = ImageConfigSchema.partial();
const ModelCatalogConfigPatchSchema = ModelCatalogConfigSchema.partial();
const ExperimentalConfigPatchSchema = ExperimentalConfigSchema;
const MoonshotServiceConfigPatchSchema = MoonshotServiceConfigSchema.partial();
@ -296,6 +317,7 @@ export const KimiConfigPatchSchema = z
extraSkillDirs: z.array(z.string()).optional(),
loopControl: LoopControlPatchSchema.optional(),
background: BackgroundConfigPatchSchema.optional(),
image: ImageConfigPatchSchema.optional(),
modelCatalog: ModelCatalogConfigPatchSchema.optional(),
experimental: ExperimentalConfigPatchSchema.optional(),
telemetry: z.boolean().optional(),

View file

@ -11,6 +11,7 @@ import {
type BackgroundConfig,
type ExperimentalConfig,
type HookDefConfig,
type ImageConfig,
type KimiConfig,
type LoopControl,
type ModelAlias,
@ -312,6 +313,8 @@ export function transformTomlData(data: Record<string, unknown>): Record<string,
result[targetKey] = transformLoopControlData(value);
} else if (targetKey === 'background' && isPlainObject(value)) {
result[targetKey] = transformPlainObject(value);
} else if (targetKey === 'image' && isPlainObject(value)) {
result[targetKey] = transformPlainObject(value);
} else if (targetKey === 'experimental' && isPlainObject(value)) {
result[targetKey] = cloneRecord(value);
} else if (!isPlainObject(value)) {
@ -489,6 +492,7 @@ export function configToTomlData(config: KimiConfig): Record<string, unknown> {
setSection(out, 'services', config.services, servicesToToml);
setSection(out, 'loop_control', config.loopControl, loopControlToToml);
setSection(out, 'background', config.background, backgroundToToml);
setSection(out, 'image', config.image, imageToToml);
setSection(out, 'experimental', config.experimental, experimentalToToml);
setSection(out, 'permission', config.permission, permissionToToml);
setHooks(out, config.hooks);
@ -670,6 +674,14 @@ function backgroundToToml(
return out;
}
function imageToToml(image: ImageConfig, rawImage: unknown): Record<string, unknown> {
const out = cloneRecord(rawImage);
for (const [key, value] of Object.entries(image)) {
setDefined(out, camelToSnake(key), value);
}
return out;
}
function experimentalToToml(
experimental: ExperimentalConfig,
_rawExperimental: unknown,

View file

@ -33,8 +33,10 @@ export interface LLMRequestLogFields {
readonly attempt?: string;
/** Request purpose; absent means a regular loop step. */
readonly kind?: 'loop' | 'compaction';
/** Set when the messages are the strict wire-compliant rebuild resend. */
readonly projection?: 'strict';
/** Set when the messages are a fallback resend projection: the strict
* wire-compliant rebuild, or the media-degraded rebuild after a
* request-too-large rejection. */
readonly projection?: 'strict' | 'media-degraded';
/** Compaction only: messages dropped so far by overflow/empty shrinking. */
readonly droppedCount?: number;
}

View file

@ -41,6 +41,15 @@ export interface RunTurnInput {
* a tool_use/tool_result adjacency 400 (see `executeLoopStep`).
*/
readonly buildMessagesStrict?: LoopMessageBuilder | undefined;
/**
* Optional media-degraded rebuild of the request messages: old media parts
* replaced by text markers, the most recent kept. Used to resend once after
* the provider rejects the request body as too large (HTTP 413 on
* accumulated media, see `executeLoopStep`); after a successful degraded
* resend, later steps of the same turn build from this projection directly
* so each step does not pay a fresh rejection.
*/
readonly buildMessagesMediaDegraded?: LoopMessageBuilder | undefined;
readonly dispatchEvent: LoopEventDispatcher;
readonly tools?: readonly ExecutableTool[] | undefined;
/**
@ -74,6 +83,7 @@ export async function runTurn(input: RunTurnInput): Promise<TurnResult> {
llm,
buildMessages,
buildMessagesStrict,
buildMessagesMediaDegraded,
dispatchEvent,
tools,
buildTools,
@ -89,6 +99,11 @@ export async function runTurn(input: RunTurnInput): Promise<TurnResult> {
// Normal exits overwrite this with the completed step's stop reason.
let stopReason: LoopTurnStopReason = 'end_turn';
let activeStep: number | undefined;
// Once a step only succeeded via the media-degraded resend, later steps of
// this turn build from the degraded projection directly: the full-media
// history is deterministically over the provider's body-size limit, so
// rebuilding it would pay a fresh rejection on every step.
let mediaDegradedActive = false;
const recordStepUsage = async (
stepUsage: TokenUsage,
): Promise<RecordStepUsageResult | void> => {
@ -109,8 +124,12 @@ export async function runTurn(input: RunTurnInput): Promise<TurnResult> {
const stepResult = await executeLoopStep({
turnId,
signal,
buildMessages,
buildMessages:
mediaDegradedActive && buildMessagesMediaDegraded !== undefined
? buildMessagesMediaDegraded
: buildMessages,
buildMessagesStrict,
buildMessagesMediaDegraded,
dispatchEvent,
llm,
tools,
@ -127,6 +146,7 @@ export async function runTurn(input: RunTurnInput): Promise<TurnResult> {
recordUsage: recordStepUsage,
});
activeStep = undefined;
mediaDegradedActive = mediaDegradedActive || stepResult.mediaDegradedResendUsed === true;
if (stepResult.stopReason === 'tool_use') {
continue;

View file

@ -9,7 +9,11 @@
import { randomUUID } from 'node:crypto';
import { isRecoverableRequestStructureError, type TokenUsage } from '@moonshot-ai/kosong';
import {
APIRequestTooLargeError,
isRecoverableRequestStructureError,
type TokenUsage,
} from '@moonshot-ai/kosong';
import type { Logger } from '#/logging/types';
import type { LoopEventDispatcher } from './events';
@ -35,6 +39,8 @@ export interface ExecuteLoopStepDeps {
readonly signal: AbortSignal;
readonly buildMessages: LoopMessageBuilder;
readonly buildMessagesStrict?: LoopMessageBuilder | undefined;
/** See RunTurnInput.buildMessagesMediaDegraded. */
readonly buildMessagesMediaDegraded?: LoopMessageBuilder | undefined;
readonly dispatchEvent: LoopEventDispatcher;
readonly llm: LLM;
readonly tools?: readonly ExecutableTool[] | undefined;
@ -57,12 +63,20 @@ export interface ExecuteLoopStepDeps {
export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{
readonly usage: TokenUsage;
readonly stopReason: LoopStepStopReason;
/**
* True when this step only succeeded after resending with the
* media-degraded projection. The turn loop uses it to keep later steps on
* that projection re-sending the full-media history would pay a fresh
* rejection on every step of the turn.
*/
readonly mediaDegradedResendUsed?: boolean;
}> {
const {
turnId,
signal,
buildMessages,
buildMessagesStrict,
buildMessagesMediaDegraded,
dispatchEvent,
llm,
tools,
@ -140,49 +154,90 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{
log,
} as const;
let response: LLMChatResponse;
let mediaDegradedResendUsed = false;
try {
response = await chatWithRetry({ ...retryInput, params: chatParams });
} catch (error) {
// A structural request rejection (tool_use/tool_result pairing, empty or
// whitespace-only text, non-user first message, non-alternating roles) means
// the projected history is not wire-compliant for a strict provider — and
// since the same history is re-sent every turn, the session would stay stuck
// on this error forever. Resend ONCE with a strict, guaranteed-compliant
// rebuild (every open call closed, stray results dropped, leading non-user
// trimmed, consecutive assistants merged) as a last resort. Any other error,
// or a host that supplied no strict builder, propagates unchanged.
if (buildMessagesStrict === undefined || !isRecoverableRequestStructureError(error)) throw error;
signal.throwIfAborted();
log?.warn('provider rejected request structure; resending with strict projection', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
});
const strictMessages = await buildMessagesStrict();
signal.throwIfAborted();
try {
response = await chatWithRetry({
...retryInput,
params: {
...chatParams,
messages: strictMessages,
requestLogFields: { projection: 'strict' },
},
});
} catch (strictError) {
// The strictly-sanitized rebuild was still rejected — our wire-compliance
// repair did not cover this case. Surface it loudly: the session is stuck
// and this is the signal we need to diagnose the gap.
log?.error('strict resend still rejected by provider; request remains wire-invalid', {
if (buildMessagesMediaDegraded !== undefined && error instanceof APIRequestTooLargeError) {
// The provider rejected the request BODY as too large (HTTP 413) —
// accumulated base64 media, not tokens, so compaction's token-driven
// recovery never fires (media is estimated at a small flat cost). The
// same media is re-sent on every request, so without intervention the
// session stays stuck. Resend ONCE with the media-degraded projection
// (old media replaced by text markers, the most recent kept); a
// rejection of that rebuild propagates unchanged.
signal.throwIfAborted();
log?.warn('provider rejected request as too large; resending with degraded media', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
originalError: errorMessage(error),
strictError: errorMessage(strictError),
});
throw strictError;
const degradedMessages = await buildMessagesMediaDegraded();
signal.throwIfAborted();
try {
response = await chatWithRetry({
...retryInput,
params: {
...chatParams,
messages: degradedMessages,
requestLogFields: { projection: 'media-degraded' },
},
});
} catch (degradedError) {
log?.error('media-degraded resend still rejected by provider', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
originalError: errorMessage(error),
degradedError: errorMessage(degradedError),
});
throw degradedError;
}
mediaDegradedResendUsed = true;
log?.info('recovered after media-degraded resend', {
turnStep: `${turnId}.${String(currentStep)}`,
});
} else if (buildMessagesStrict !== undefined && isRecoverableRequestStructureError(error)) {
// A structural request rejection (tool_use/tool_result pairing, empty or
// whitespace-only text, non-user first message, non-alternating roles) means
// the projected history is not wire-compliant for a strict provider — and
// since the same history is re-sent every turn, the session would stay stuck
// on this error forever. Resend ONCE with a strict, guaranteed-compliant
// rebuild (every open call closed, stray results dropped, leading non-user
// trimmed, consecutive assistants merged) as a last resort. Any other error,
// or a host that supplied no strict builder, propagates unchanged.
signal.throwIfAborted();
log?.warn('provider rejected request structure; resending with strict projection', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
});
const strictMessages = await buildMessagesStrict();
signal.throwIfAborted();
try {
response = await chatWithRetry({
...retryInput,
params: {
...chatParams,
messages: strictMessages,
requestLogFields: { projection: 'strict' },
},
});
} catch (strictError) {
// The strictly-sanitized rebuild was still rejected — our wire-compliance
// repair did not cover this case. Surface it loudly: the session is stuck
// and this is the signal we need to diagnose the gap.
log?.error('strict resend still rejected by provider; request remains wire-invalid', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
originalError: errorMessage(error),
strictError: errorMessage(strictError),
});
throw strictError;
}
log?.info('recovered after strict resend', {
turnStep: `${turnId}.${String(currentStep)}`,
});
} else {
throw error;
}
log?.info('recovered after strict resend', {
turnStep: `${turnId}.${String(currentStep)}`,
});
}
const usage = response.usage;
const usageResult = await recordUsage(usage);
@ -244,6 +299,7 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{
usage,
stopReason:
stopTurnAfterStep && effectiveStopReason === 'tool_use' ? 'end_turn' : effectiveStopReason,
mediaDegradedResendUsed,
};
}

View file

@ -7,6 +7,7 @@ import { PluginManager } from '#/plugin';
import { LocalFetchURLProvider } from '#/tools/providers/local-fetch-url';
import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url';
import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search';
import { setConfiguredMaxImageEdgePx, setConfiguredReadImageByteBudget } from '#/tools/support/image-compress';
import type { PromisableMethods } from '#/utils/types';
import { getCoreVersion } from '#/version';
import { resolveThinkingEffort } from '../agent/config/thinking';
@ -205,6 +206,8 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
FLAG_DEFINITIONS,
this.config.experimental,
);
setConfiguredMaxImageEdgePx(this.config.image?.maxEdgePx);
setConfiguredReadImageByteBudget(this.config.image?.readByteBudget);
this.sessionStore = new SessionStore(this.homeDir);
this.plugins = new PluginManager({ kimiHomeDir: this.homeDir });
// Capture the error rather than swallow it: mutators and explicit /plugins
@ -1079,6 +1082,8 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
private setRuntimeConfig(config: KimiConfig): KimiConfig {
this.config = config;
this.experimentalFlags.setConfigOverrides(config.experimental);
setConfiguredMaxImageEdgePx(config.image?.maxEdgePx);
setConfiguredReadImageByteBudget(config.image?.readByteBudget);
return this.config;
}

View file

@ -45,6 +45,7 @@ import {
compressImageForModel,
cropImageForModel,
formatByteSize,
resolveReadImageByteBudget,
type ImageCompressionTelemetry,
type ImageCropRegion,
} from '../../support/image-compress';
@ -213,6 +214,45 @@ function buildMediaNote(input: {
// ── Implementation ───────────────────────────────────────────────────
/**
* Refusal message for HEIC/HEIF with a conversion command matching the
* execution environment (`kaos.osEnv.osKind` where Bash actually runs, so
* SSH/container sessions get the right command too). macOS converts with the
* built-in `sips`; Linux and Windows have no built-in HEIC decoder, so the
* guidance names the common tools and how to get them.
*/
function buildHeicConversionGuidance(path: string, mimeType: string, osKind: string): string {
const converted = path.replace(/\.[^./\\]+$/, '') + '.jpg';
return (
`"${path}" is a ${mimeType} image, which the provider does not accept. ` +
'Convert it to JPEG first, then read the converted file. ' +
heicConversionCommand(path, converted, osKind)
);
}
function heicConversionCommand(path: string, converted: string, osKind: string): string {
switch (osKind) {
case 'macOS':
return `On macOS: sips -s format jpeg "${path}" --out "${converted}"`;
case 'Linux':
return (
`On Linux: heif-convert "${path}" "${converted}" (package libheif-examples), ` +
`or with ImageMagick: magick "${path}" "${converted}"`
);
case 'Windows':
return (
`On Windows, with ImageMagick: magick "${path}" "${converted}" ` +
'(install it first if missing: winget install ImageMagick.ImageMagick)'
);
default:
return (
`Options: sips -s format jpeg "${path}" --out "${converted}" (macOS), ` +
`heif-convert "${path}" "${converted}" (Linux, package libheif-examples), ` +
`or magick "${path}" "${converted}" (ImageMagick)`
);
}
}
export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
readonly name = 'ReadMediaFile' as const;
readonly description: string;
@ -293,6 +333,17 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
'Tell the user to use a model with image input capability.',
};
}
// HEIC/HEIF must never reach the provider: no provider accepts them,
// and once the image_url lands in the history every subsequent request
// in the session is rejected. Refuse with a conversion command for the
// execution environment instead — the model can run it through Bash
// (under the normal permission flow) and read the converted file.
if (fileType.mimeType === 'image/heic' || fileType.mimeType === 'image/heif') {
return {
isError: true,
output: buildHeicConversionGuidance(args.path, fileType.mimeType, this.kaos.osEnv.osKind),
};
}
if (fileType.kind === 'video' && !this.capabilities.video_in) {
return {
isError: true,
@ -388,10 +439,14 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
};
} else {
// Shrink oversized images so a large screenshot neither wastes context
// tokens nor trips the provider's per-image byte ceiling. Best effort:
// on any failure compressImageForModel returns the original bytes, so
// the read still succeeds with the uncompressed image.
// tokens nor trips the provider's per-image byte ceiling. Model-read
// images get the much tighter read budget: they accumulate in the
// request body on every turn, and detail stays reachable through the
// region readback (which ignores the budget). Best effort: on any
// failure compressImageForModel returns the original bytes, so the
// read still succeeds with the uncompressed image.
const compressed = await compressImageForModel(data, fileType.mimeType, {
byteBudget: resolveReadImageByteBudget(),
telemetry: this.compressTelemetry,
});
const base64 = Buffer.from(compressed.data).toString('base64');

View file

@ -8,14 +8,16 @@
* untouched the common case is a fast, codec-free pass-through.
*
* Design notes:
* - Pure JS (jimp), imported lazily so the codec is only paid for when an
* image actually needs work; startup and the fast path stay cheap.
* - Pure JS (jimp + a wasm WebP decoder), imported lazily so the codecs are
* only paid for when an image actually needs work; startup and the fast
* path stay cheap.
* - Best effort: any decode/encode failure returns the original bytes
* unchanged (`changed: false`), so a compression problem never blocks a
* prompt. Callers simply send the original instead.
* - Only PNG and JPEG are re-encoded. GIF is passed through to preserve
* animation; WebP is passed through because the default jimp build ships no
* WebP codec. Unknown formats are passed through.
* - PNG, JPEG, and (non-animated) WebP are re-encoded; WebP re-encodes
* through the PNG/JPEG ladder, so only its decoder wasm ships. GIF and
* animated WebP are passed through to preserve animation. Unknown formats
* are passed through.
* - Compression must never be silent to the model: results carry the
* original dimensions, {@link buildImageCompressionCaption} renders the
* shared "what was compressed, where is the original" note every ingestion
@ -31,9 +33,53 @@ import type { ContentPart } from '@moonshot-ai/kosong';
import type { TelemetryClient } from '#/telemetry';
import { sniffImageDimensions } from './file-type';
import { decodeWebp, isAnimatedWebp } from './webp-decode';
/** Longest-edge ceiling (px). Larger images are scaled down to fit. */
export const MAX_IMAGE_EDGE_PX = 3000;
/**
* Built-in longest-edge ceiling (px). Larger images are scaled down to fit.
* This is the default only: the effective ceiling is resolved per call by
* {@link resolveMaxImageEdgePx} (explicit option > env > config > this).
*/
export const MAX_IMAGE_EDGE_PX = 2000;
/**
* Env var overriding the longest-edge ceiling (px). Read live on every
* resolution so it applies in any process without wiring; a value that is
* not a positive integer is ignored.
*/
export const MAX_IMAGE_EDGE_ENV = 'KIMI_IMAGE_MAX_EDGE_PX';
/**
* The `[image] max_edge_px` value from config.toml, pushed by the config
* owner (KimiCore) on load and reload. Processes that never load config
* (TUI paste, ACP adapter) leave this unset and get env/built-in behavior.
*/
let configuredMaxImageEdgePx: number | undefined;
/** Push (or clear, with `undefined`) the config.toml longest-edge ceiling. */
export function setConfiguredMaxImageEdgePx(value: number | undefined): void {
configuredMaxImageEdgePx = value !== undefined && isPositiveInt(value) ? value : undefined;
}
/**
* Effective default longest-edge ceiling (px), for calls that pass no
* explicit `maxEdge`. Precedence mirrors the experimental-flag resolver:
* env var > config.toml > built-in {@link MAX_IMAGE_EDGE_PX}.
*/
export function resolveMaxImageEdgePx(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = env[MAX_IMAGE_EDGE_ENV]?.trim();
if (raw !== undefined && raw.length > 0 && /^\d+$/.test(raw)) {
const parsed = Number(raw);
if (isPositiveInt(parsed)) return parsed;
}
return configuredMaxImageEdgePx ?? MAX_IMAGE_EDGE_PX;
}
function isPositiveInt(value: number): boolean {
return Number.isInteger(value) && value > 0;
}
/**
* Raw-byte budget for a single image. base64 inflates bytes by ~4/3, so a
@ -42,16 +88,70 @@ export const MAX_IMAGE_EDGE_PX = 3000;
*/
export const IMAGE_BYTE_BUDGET = 3.75 * 1024 * 1024;
/**
* Built-in raw-byte budget for images the model reads for itself
* (ReadMediaFile's default path). Far below {@link IMAGE_BYTE_BUDGET}: a
* session that keeps screenshotting and reading images accumulates every one
* of them in the request body on every turn, so per-image size not the
* provider's per-image ceiling is what keeps the total under the
* provider's request-size limit. 256 KB keeps a clean 2000px UI screenshot
* on the lossless fast path while capping dense content at a readable
* q80/1000px JPEG; fine detail stays reachable through the `region`
* readback, which deliberately ignores this budget.
*/
export const READ_IMAGE_BYTE_BUDGET = 256 * 1024;
/**
* Env var overriding the read-image byte budget. Read live on every
* resolution; a value that is not a positive integer is ignored.
*/
export const READ_IMAGE_BYTE_BUDGET_ENV = 'KIMI_IMAGE_READ_BYTE_BUDGET';
/** The `[image] read_byte_budget` value from config.toml; see {@link setConfiguredMaxImageEdgePx}. */
let configuredReadImageByteBudget: number | undefined;
/** Push (or clear, with `undefined`) the config.toml read-image byte budget. */
export function setConfiguredReadImageByteBudget(value: number | undefined): void {
configuredReadImageByteBudget = value !== undefined && isPositiveInt(value) ? value : undefined;
}
/**
* Effective read-image byte budget. Precedence mirrors
* {@link resolveMaxImageEdgePx}: env var > config.toml > built-in
* {@link READ_IMAGE_BYTE_BUDGET}.
*/
export function resolveReadImageByteBudget(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = env[READ_IMAGE_BYTE_BUDGET_ENV]?.trim();
if (raw !== undefined && raw.length > 0 && /^\d+$/.test(raw)) {
const parsed = Number(raw);
if (isPositiveInt(parsed)) return parsed;
}
return configuredReadImageByteBudget ?? READ_IMAGE_BYTE_BUDGET;
}
/** Progressively lower JPEG quality until the payload fits the byte budget. */
const JPEG_QUALITY_STEPS = [80, 60, 40, 20] as const;
/**
* Longest-edge step-downs tried when the budget cannot be met at the fitted
* size. The 2000px step preserves the behavior of the previous 2000px cap:
* an image whose 2000px encode fits the budget keeps that resolution
* instead of dropping straight to the 1000px last resort.
* size. With the built-in 2000px ceiling the first step is a no-op; it
* matters when a larger ceiling is configured (config/env/option). The
* sub-1000px tail exists for small (read-scale) budgets: JPEG bytes shrink
* roughly linearly with pixel count, so stepping down to 256px lets even
* entropy-upper-bound content (noise, photos) land within any budget of a
* few tens of KB instead of stalling at the q20@1000px floor.
*/
const FALLBACK_EDGES_PX = [2000, 1000] as const;
const FALLBACK_EDGES_PX = [2000, 1000, 768, 512, 384, 256] as const;
/**
* PNG rescales stop at this edge; below it the ladder goes lossy instead.
* For text-bearing screenshots a q80 JPEG at 1000px reads better than a
* lossless PNG at 512px resolution beats losslessness once both are
* degraded so sub-floor edges are only ever tried with the JPEG ladder.
*/
const PNG_RESCALE_FLOOR_PX = 1000;
/**
* Pixel-count ceiling above which we skip compression entirely. A tiny-byte,
@ -76,11 +176,13 @@ const MAX_DECODE_PIXELS = 100_000_000;
*/
const MAX_DECODE_BYTES = 64 * 1024 * 1024;
/** Formats we can both decode and re-encode with the default jimp build. */
const RECODABLE_MIME = new Set(['image/png', 'image/jpeg']);
/** Formats we can decode and re-encode. WebP decodes via the bundled wasm
* codec and re-encodes through the PNG/JPEG ladder (animated WebP is gated
* to a passthrough before decoding). */
const RECODABLE_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']);
export interface CompressImageOptions {
/** Override the longest-edge ceiling (px). */
/** Override the longest-edge ceiling (px); defaults to {@link resolveMaxImageEdgePx}. */
readonly maxEdge?: number;
/** Override the raw-byte budget. */
readonly byteBudget?: number;
@ -152,7 +254,7 @@ export async function compressImageForModel(
options: CompressImageOptions = {},
): Promise<CompressImageResult> {
const startedAt = Date.now();
const maxEdge = options.maxEdge ?? MAX_IMAGE_EDGE_PX;
const maxEdge = options.maxEdge ?? resolveMaxImageEdgePx();
const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET;
const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES;
const normalizedMime = normalizeMime(mimeType);
@ -183,6 +285,11 @@ export async function compressImageForModel(
if (bytes.length === 0) return finish('passthrough_unsupported', passthrough());
// Only re-encode formats the codec handles; everything else passes through.
if (!RECODABLE_MIME.has(normalizedMime)) return finish('passthrough_unsupported', passthrough());
// Animated WebP would be flattened to one frame by decoding — pass it
// through whole, the same reason GIF is never re-encoded.
if (normalizedMime === 'image/webp' && isAnimatedWebp(bytes)) {
return finish('passthrough_unsupported', passthrough());
}
// Fast path: already within both budgets — no codec load, no allocation.
const longestEdge = dims ? Math.max(dims.width, dims.height) : 0;
@ -202,9 +309,10 @@ export async function compressImageForModel(
if (bytes.length > maxDecodeBytes) return finish('passthrough_guard', passthrough());
try {
const { Jimp } = await import('jimp');
const image = await Jimp.fromBuffer(Buffer.from(bytes));
const sourceIsPng = normalizedMime === 'image/png';
const image = await decodeToJimp(bytes, normalizedMime);
// WebP joins PNG on the lossless-first ladder: both carry alpha and
// screenshot-grade detail that the PNG rungs preserve.
const preferLossless = normalizedMime !== 'image/jpeg';
// The decoded bitmap is authoritative for the original size: jimp
// applies EXIF orientation while decoding, and this is the coordinate
// space the encoded result and any later crop region (see
@ -218,7 +326,7 @@ export async function compressImageForModel(
fitWithinEdge(image, maxEdge);
const encoded = await encodeWithinBudget(image, {
sourceIsPng,
preferLossless,
byteBudget,
fallbackEdges: FALLBACK_EDGES_PX,
});
@ -518,7 +626,7 @@ export async function cropImageForModel(
options: CropImageOptions = {},
): Promise<CropImageOutcome> {
const startedAt = Date.now();
const maxEdge = options.maxEdge ?? MAX_IMAGE_EDGE_PX;
const maxEdge = options.maxEdge ?? resolveMaxImageEdgePx();
const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET;
const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES;
const normalizedMime = normalizeMime(mimeType);
@ -538,9 +646,14 @@ export async function cropImageForModel(
if (!RECODABLE_MIME.has(normalizedMime)) {
return fail(
'unsupported_format',
`Cropping is only supported for PNG and JPEG images; got ${mimeType}.`,
`Cropping is only supported for PNG, JPEG, and WebP images; got ${mimeType}.`,
);
}
// A crop is a still image by definition; decoding an animated WebP would
// silently crop a single frame, so refuse explicitly.
if (normalizedMime === 'image/webp' && isAnimatedWebp(bytes)) {
return fail('unsupported_format', 'Cropping is not supported for animated WebP images.');
}
// NaN slips past every </>= comparison in the bounds guard below, so gate
// on finiteness explicitly rather than surfacing a codec-internal error.
if (
@ -564,8 +677,7 @@ export async function cropImageForModel(
}
try {
const { Jimp } = await import('jimp');
const image = await Jimp.fromBuffer(Buffer.from(bytes));
const image = await decodeToJimp(bytes, normalizedMime);
const originalWidth = image.width;
const originalHeight = image.height;
@ -582,13 +694,15 @@ export async function cropImageForModel(
const h = Math.min(Math.floor(region.height), originalHeight - y);
const applied: ImageCropRegion = { x, y, width: w, height: h };
image.crop({ x, y, w, h });
const sourceIsPng = normalizedMime === 'image/png';
// WebP joins PNG on the lossless side: both carry alpha and
// screenshot-grade detail that PNG output preserves.
const preferLossless = normalizedMime !== 'image/jpeg';
if (options.skipResize === true) {
// Native resolution requested: encode once, favoring fidelity (lossless
// PNG, or high-quality JPEG), and refuse rather than degrade when the
// result cannot fit the byte budget.
const buffer = sourceIsPng
const buffer = preferLossless
? await image.getBuffer('image/png', { deflateLevel: 9 })
: await image.getBuffer('image/jpeg', { quality: 90 });
if (buffer.length > byteBudget) {
@ -603,7 +717,7 @@ export async function cropImageForModel(
return succeed({
ok: true,
data: new Uint8Array(buffer),
mimeType: sourceIsPng ? 'image/png' : 'image/jpeg',
mimeType: preferLossless ? 'image/png' : 'image/jpeg',
width: image.width,
height: image.height,
originalWidth,
@ -617,7 +731,7 @@ export async function cropImageForModel(
fitWithinEdge(image, maxEdge);
const encoded = await encodeWithinBudget(image, {
sourceIsPng,
preferLossless,
byteBudget,
fallbackEdges: FALLBACK_EDGES_PX,
});
@ -767,28 +881,53 @@ interface EncodedImage {
}
interface EncodeOptions {
readonly sourceIsPng: boolean;
/** Lossless-first (PNG rungs before JPEG): PNG and WebP sources, which
* carry alpha and screenshot-grade detail. JPEG sources skip straight to
* the quality ladder their detail is already lossy. */
readonly preferLossless: boolean;
readonly byteBudget: number;
readonly fallbackEdges: readonly number[];
}
/**
* Decode `bytes` into a jimp image. PNG/JPEG decode through jimp itself
* (which applies EXIF orientation); WebP decodes through the bundled wasm
* codec and enters jimp as a raw RGBA bitmap (WebP carries no EXIF-style
* orientation, so the decoded pixels are already display space).
*/
async function decodeToJimp(bytes: Uint8Array, normalizedMime: string): Promise<JimpImage> {
const { Jimp } = await import('jimp');
if (normalizedMime === 'image/webp') {
const decoded = await decodeWebp(bytes);
return Jimp.fromBitmap({
data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),
width: decoded.width,
height: decoded.height,
});
}
return Jimp.fromBuffer(Buffer.from(bytes));
}
/**
* Encode `image` (already fitted to the edge ceiling) under the byte budget.
*
* Strategy prefer the source format so a downscaled screenshot stays lossless
* PNG (preserving text and transparency), and only fall back to lossy JPEG when
* PNG cannot meet the byte budget:
* - PNG source: PNG at the fitted size smaller PNG rescales, stepping down
* the fallback edges JPEG ladder.
* - PNG source: PNG at the fitted size smaller PNG rescales down to the
* {@link PNG_RESCALE_FLOOR_PX} floor JPEG ladder at that size JPEG
* ladder again at each sub-floor edge.
* - JPEG source: the full quality ladder at the fitted size, then again at
* each fallback edge a smaller rescale must not skip the high-quality
* rungs its extra pixels just paid for.
*
* Always returns the smallest buffer it produced, even if no attempt met the
* budget the caller still gates on whether it actually helped.
* The sub-floor edges make the ladder converge for small (read-scale)
* budgets: any budget of a few tens of KB is met by q20 at 256px even for
* entropy-upper-bound content. Below that, the smallest buffer produced is
* still returned the caller gates on whether it actually helped.
*/
async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promise<EncodedImage> {
const { sourceIsPng, byteBudget, fallbackEdges } = opts;
const { preferLossless, byteBudget, fallbackEdges } = opts;
let smallest: EncodedImage | null = null;
const consider = (data: Buffer, mimeType: string): EncodedImage => {
@ -799,43 +938,52 @@ async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promis
return candidate;
};
if (sourceIsPng) {
const jpegLadder = async (): Promise<EncodedImage | null> => {
for (const quality of JPEG_QUALITY_STEPS) {
const jpeg = await image.getBuffer('image/jpeg', { quality });
if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg');
consider(jpeg, 'image/jpeg');
}
return null;
};
if (preferLossless) {
// Lossless PNG first: best for screenshots/UI (sharp text) and keeps alpha.
const png = await image.getBuffer('image/png', { deflateLevel: 9 });
if (png.length <= byteBudget) return consider(png, 'image/png');
consider(png, 'image/png');
// Over budget: progressively smaller PNGs before going lossy.
// Over budget: progressively smaller PNGs (down to the floor) before
// going lossy.
for (const edge of fallbackEdges) {
if (edge < PNG_RESCALE_FLOOR_PX) break;
if (!fitWithinEdge(image, edge)) continue;
const smallerPng = await image.getBuffer('image/png', { deflateLevel: 9 });
if (smallerPng.length <= byteBudget) return consider(smallerPng, 'image/png');
consider(smallerPng, 'image/png');
}
// Last resort: lossy JPEG ladder (drops transparency) to meet the budget.
for (const quality of JPEG_QUALITY_STEPS) {
const jpeg = await image.getBuffer('image/jpeg', { quality });
if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg');
consider(jpeg, 'image/jpeg');
// Lossy JPEG ladder (drops transparency) at the floored size, then at
// each sub-floor edge until the budget is met.
const atFloor = await jpegLadder();
if (atFloor !== null) return atFloor;
for (const edge of fallbackEdges) {
if (edge >= PNG_RESCALE_FLOOR_PX) continue;
if (!fitWithinEdge(image, edge)) continue;
const atEdge = await jpegLadder();
if (atEdge !== null) return atEdge;
}
return smallest!;
}
// JPEG source: quality ladder at the fitted size, then the full ladder
// again at each fallback rescale.
for (const quality of JPEG_QUALITY_STEPS) {
const jpeg = await image.getBuffer('image/jpeg', { quality });
if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg');
consider(jpeg, 'image/jpeg');
}
const atFitted = await jpegLadder();
if (atFitted !== null) return atFitted;
for (const edge of fallbackEdges) {
if (!fitWithinEdge(image, edge)) continue;
for (const quality of JPEG_QUALITY_STEPS) {
const jpeg = await image.getBuffer('image/jpeg', { quality });
if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg');
consider(jpeg, 'image/jpeg');
}
const atEdge = await jpegLadder();
if (atEdge !== null) return atEdge;
}
return smallest!;

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,86 @@
/**
* WebP decoding for the image-compression pipeline.
*
* The default jimp build ships no WebP codec, so WebP is decoded with
* `@jsquash/webp`'s wasm decoder instead. The decoder wasm is compiled from a
* base64 string committed to the repo (see `webp-dec-wasm.ts`): the published
* CLI bundles every dependency into a single file with no runtime
* node_modules, so a file-path or fetch lookup for the .wasm (what the
* emscripten glue would do on its own) cannot work there the module is
* compiled and injected manually via the codec's `init()` hook. Only the
* decoder is bundled: re-encoding runs through the existing PNG/JPEG ladder,
* so the (larger) WebP encoder wasm is never needed.
*
* The repo's tsconfig carries no DOM lib, so the global `WebAssembly` and
* `ImageData` names are unavailable at the type level the wasm namespace is
* reached through a structurally-typed `globalThis` and the decoder's RGBA
* output is described by the local {@link DecodedWebp} shape.
*/
/** Decoded RGBA bitmap in the shape `Jimp.fromBitmap` accepts. */
export interface DecodedWebp {
readonly data: Uint8ClampedArray;
readonly width: number;
readonly height: number;
}
type WebpDecodeFn = (bytes: Uint8Array) => Promise<DecodedWebp>;
interface WasmGlobal {
readonly WebAssembly: {
compile(bytes: Uint8Array): Promise<object>;
};
}
let decoderReady: Promise<WebpDecodeFn> | null = null;
async function loadDecoder(): Promise<WebpDecodeFn> {
decoderReady ??= (async () => {
const [decodeModule, { WEBP_DECODER_WASM_BASE64 }] = await Promise.all([
import('@jsquash/webp/decode.js'),
import('./webp-dec-wasm'),
]);
const wasm = await (globalThis as unknown as WasmGlobal).WebAssembly.compile(
Buffer.from(WEBP_DECODER_WASM_BASE64, 'base64'),
);
await decodeModule.init(wasm as never);
const decode = decodeModule.default;
return async (bytes: Uint8Array) => {
const copy = new Uint8Array(bytes); // detach from any shared buffer
return (await decode(copy.buffer as ArrayBuffer)) as unknown as DecodedWebp;
};
})();
return decoderReady;
}
/**
* Decode a (non-animated) WebP payload to RGBA. Throws on undecodable input
* callers keep their existing best-effort catch semantics.
*/
export async function decodeWebp(bytes: Uint8Array): Promise<DecodedWebp> {
const decode = await loadDecoder();
return decode(bytes);
}
/**
* True when the payload is a WebP whose VP8X container header carries the
* ANIM flag. Animated WebP must be passed through, not re-encoded: decoding
* yields a single frame and would silently destroy the animation (the same
* reason GIF is passed through).
*/
export function isAnimatedWebp(bytes: Uint8Array): boolean {
if (bytes.length < 21) return false;
return (
hasAscii(bytes, 'RIFF', 0) &&
hasAscii(bytes, 'WEBP', 8) &&
hasAscii(bytes, 'VP8X', 12) &&
(bytes[20]! & 0x02) !== 0
);
}
function hasAscii(bytes: Uint8Array, text: string, at: number): boolean {
for (let i = 0; i < text.length; i++) {
if (bytes[at + i] !== text.codePointAt(i)) return false;
}
return true;
}

View file

@ -439,6 +439,33 @@ describe('compaction — probe tests (high-risk scenarios)', () => {
});
});
describe('compaction — summarizer request media handling', () => {
// GUARD — the first summarizer attempt sends media as-is: a multimodal
// summarizer can still read an image nobody narrated. Media is only
// replaced with text markers when the provider rejects the request body
// as too large (see full.test.ts "request too large" cases).
it('keeps media parts in the summarizer request when it is not rejected', async () => {
const ctx = testAgent();
ctx.configure({ provider: PROVIDER, modelCapabilities: CAPS });
ctx.agent.context.appendUserMessage(
[
{ type: 'text', text: '<image path="/workspace/shot.png">' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
{ type: 'text', text: '</image>' },
],
{ kind: 'user' },
);
ctx.mockNextResponse({ type: 'text', text: 'Summary.' });
await ctx.rpc.beginCompaction({});
await ctx.once('compaction.completed');
const request = ctx.llmCalls.at(-1)!;
const parts = request.history.flatMap((message) => message.content);
expect(parts.some((part) => part.type === 'image_url')).toBe(true);
});
});
describe('compaction — head/tail user-message retention', () => {
const FIRST = `FIRST ${'a'.repeat(4_000)}`; // ~1k tokens
const MIDDLE = 'b'.repeat(88_000); // ~22k tokens, over the 20k budget on its own

View file

@ -5,6 +5,7 @@ import { join } from 'pathe';
import {
APIConnectionError,
APIContextOverflowError,
APIRequestTooLargeError,
APIStatusError,
generate as runKosongGenerate,
UNKNOWN_CAPABILITY,
@ -560,6 +561,142 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
it('strips media to text markers and retries when the summarizer request is rejected as too large', async () => {
let attempts = 0;
const histories: Message[][] = [];
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
attempts += 1;
histories.push(structuredClone(history));
if (attempts === 1) {
throw new APIRequestTooLargeError(413, 'Request exceeds the maximum size', 'req-413');
}
return textResult('Compacted without media.');
};
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
// A ReadMediaFile-shaped result: path wrapper text around inline image data.
ctx.agent.context.appendUserMessage(
[
{ type: 'text', text: '<image path="/workspace/shot.png">' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
{ type: 'text', text: '</image>' },
],
{ kind: 'user' },
);
ctx.agent.context.appendUserMessage(
[
{ type: 'video_url', videoUrl: { url: 'data:video/mp4;base64,BBBB' } },
{ type: 'audio_url', audioUrl: { url: 'data:audio/mp3;base64,CCCC' } },
],
{ kind: 'user' },
);
const compacted = ctx.once('context.apply_compaction');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(attempts).toBe(2);
// The first attempt goes out with the media as-is.
const firstParts = histories[0]!.flatMap((message) => message.content);
expect(firstParts.some((part) => part.type === 'image_url')).toBe(true);
// The 413 retry replaces every media part with a text marker; the
// ReadMediaFile path wrapper survives so the summary can still reference
// the file, and no base64 payload leaks into the retried request.
// (Projection may merge adjacent text parts, so assert on the joined text.)
const retryParts = histories[1]!.flatMap((message) => message.content);
expect(retryParts.some((part) => part.type !== 'text' && part.type !== 'think')).toBe(false);
const retryText = retryParts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n');
expect(retryText).toContain('[image]');
expect(retryText).toContain('[video]');
expect(retryText).toContain('[audio]');
expect(retryText).toContain('<image path="/workspace/shot.png">');
expect(JSON.stringify(histories[1])).not.toContain('base64');
// Stripping is not an overflow shrink: the retry drops no messages.
expect(histories[1]!.length).toBe(histories[0]!.length);
// The real history is untouched: recent kept user messages retain media.
const keptParts = ctx.agent.context.history.flatMap((message) => message.content);
expect(keptParts.some((part) => part.type === 'image_url')).toBe(true);
await ctx.expectResumeMatches();
});
it('shrinks the history when the summarizer request stays too large after media stripping', async () => {
let attempts = 0;
const histories: Message[][] = [];
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
attempts += 1;
histories.push(structuredClone(history));
if (attempts <= 2) {
throw new APIRequestTooLargeError(413, 'Request exceeds the maximum size');
}
return textResult('Recovered after shrink.');
};
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'old user two', 'old assistant two', 40);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 120);
ctx.agent.context.appendUserMessage(
[{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }],
{ kind: 'user' },
);
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await completed;
expect(attempts).toBe(3);
// Attempt 2 is the media strip: same message count, no media parts.
expect(histories[1]!.length).toBe(histories[0]!.length);
expect(
histories[1]!.flatMap((m) => m.content).some((part) => part.type === 'image_url'),
).toBe(false);
// Attempt 3 falls through to the overflow shrink and drops old messages.
expect(histories[2]!.length).toBeLessThan(histories[1]!.length);
await ctx.expectResumeMatches();
});
it('shrinks immediately on a too-large rejection when the history has no media', async () => {
let attempts = 0;
const histories: Message[][] = [];
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
attempts += 1;
histories.push(structuredClone(history));
if (attempts === 1) {
throw new APIRequestTooLargeError(413, 'Request exceeds the maximum size');
}
return textResult('Recovered after shrink.');
};
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'old user two', 'old assistant two', 40);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 120);
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await completed;
// With no media to strip, the too-large rejection goes straight to the
// overflow shrink instead of wasting a retry on an identical request.
expect(attempts).toBe(2);
expect(histories[1]!.length).toBeLessThan(histories[0]!.length);
await ctx.expectResumeMatches();
});
it('retries compaction responses with empty summaries before applying context', async () => {
vi.useFakeTimers();
const firstEmptySummary = deferred<void>();

View file

@ -4,8 +4,27 @@ import { emptyUsage } from '@moonshot-ai/kosong';
import { ProviderManager } from '../../src/session/provider-manager';
import type { KimiConfig } from '../../src/config';
import { testAgent } from './harness';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
describe('ConfigState model capabilities', () => {
it('updates the agent cwd without requiring the directory to exist', () => {
const chdir = vi.fn(async () => {
throw Object.assign(new Error('missing workspace'), { code: 'ENOENT' });
});
const ctx = testAgent({
kaos: createFakeKaos({
getcwd: () => '/workspace',
chdir,
}),
});
ctx.agent.config.update({ cwd: '/tmp/missing-workdir' });
expect(ctx.agent.config.cwd).toBe('/tmp/missing-workdir');
expect(ctx.agent.kaos.getcwd()).toBe('/tmp/missing-workdir');
expect(chdir).not.toHaveBeenCalled();
});
it('computes provider and model capabilities from ProviderManager metadata', () => {
const ctx = testAgent({
providerManager: new ProviderManager({

View file

@ -1,7 +1,11 @@
import type { ContentPart, Message, ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import { project, type ProjectionAnomaly } from '../../../src/agent/context/projector';
import {
degradeOlderMediaParts,
project,
type ProjectionAnomaly,
} from '../../../src/agent/context/projector';
import type { ContextMessage } from '../../../src/agent/context/types';
// ---------------------------------------------------------------------------
@ -768,3 +772,63 @@ function shuffle<T>(items: T[], rng: Rng): void {
[items[i], items[j]] = [items[j]!, items[i]!];
}
}
describe('degradeOlderMediaParts', () => {
function imageMessage(name: string): Message {
return {
role: 'user',
content: [
{ type: 'text', text: `<image path="/ws/${name}.png">` },
{ type: 'image_url', imageUrl: { url: `data:image/png;base64,${name}` } },
{ type: 'text', text: '</image>' },
],
toolCalls: [],
};
}
it('replaces all but the most recent N media parts with placeholder text', () => {
const messages: Message[] = [
imageMessage('old'),
{
role: 'user',
content: [
{ type: 'video_url', videoUrl: { url: 'data:video/mp4;base64,VVVV' } },
{ type: 'audio_url', audioUrl: { url: 'data:audio/mp3;base64,SSSS' } },
],
toolCalls: [],
},
imageMessage('recent'),
];
const degraded = degradeOlderMediaParts(messages, 2);
const parts = degraded.flatMap((message) => message.content);
// The two most recent media parts survive; older ones become text.
expect(parts.filter((part) => part.type === 'audio_url')).toHaveLength(1);
expect(parts.filter((part) => part.type === 'image_url')).toHaveLength(1);
expect(parts.filter((part) => part.type === 'video_url')).toHaveLength(0);
const texts = parts.filter((part) => part.type === 'text').map((part) => part.text);
expect(texts.some((text) => text.startsWith('[image omitted:'))).toBe(true);
expect(texts.some((text) => text.startsWith('[video omitted:'))).toBe(true);
// The path wrapper of the degraded image survives for readback.
expect(texts).toContain('<image path="/ws/old.png">');
// The surviving image is the most recent one.
const survivor = parts.find((part) => part.type === 'image_url');
expect(survivor?.type === 'image_url' && survivor.imageUrl.url).toContain('recent');
});
it('returns the input array by reference when nothing needs degrading', () => {
const messages: Message[] = [imageMessage('a'), imageMessage('b')];
expect(degradeOlderMediaParts(messages, 2)).toBe(messages);
});
it('keeps untouched messages by reference and does not mutate degraded ones', () => {
const messages: Message[] = [imageMessage('old'), imageMessage('mid'), imageMessage('new')];
const degraded = degradeOlderMediaParts(messages, 2);
expect(degraded).not.toBe(messages);
expect(degraded[1]).toBe(messages[1]);
expect(degraded[2]).toBe(messages[2]);
// The input's first message still carries its image part.
expect(messages[0]!.content.some((part) => part.type === 'image_url')).toBe(true);
});
});

View file

@ -9,9 +9,11 @@ import { createControlledPromise } from '@antfu/utils';
import {
APIConnectionError,
APIEmptyResponseError,
APIRequestTooLargeError,
APIStatusError,
APITimeoutError,
type ChatProvider,
type Message,
type ModelCapability,
type ToolCall,
} from '@moonshot-ai/kosong';
@ -60,6 +62,76 @@ function captureLogs(): { logger: Logger; entries: CapturedLogEntry[] } {
}
describe('Agent turn flow', () => {
it('degrades older history media and retries when the provider rejects the request body as too large', async () => {
let attempts = 0;
const histories: Message[][] = [];
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
attempts += 1;
histories.push(structuredClone(history));
if (attempts === 1) {
throw new APIRequestTooLargeError(413, 'Request exceeds the maximum size');
}
return {
id: 'mock-degraded-recovery',
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
usage: { inputOther: 1, output: 1, inputCacheRead: 0, inputCacheCreation: 0 },
finishReason: 'completed',
rawFinishReason: 'stop',
};
};
const ctx = testAgent({ generate });
ctx.configure({
provider: { type: 'kimi', apiKey: 'test-key', model: 'kimi-code' },
modelCapabilities: {
image_in: true,
video_in: false,
audio_in: false,
thinking: false,
tool_use: true,
max_context_tokens: 256_000,
},
});
// Three ReadMediaFile-shaped image results in the history.
for (const name of ['a', 'b', 'c']) {
ctx.agent.context.appendUserMessage(
[
{ type: 'text', text: `<image path="/workspace/${name}.png">` },
{ type: 'image_url', imageUrl: { url: `data:image/png;base64,${name}AAA` } },
{ type: 'text', text: '</image>' },
],
{ kind: 'user' },
);
}
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'inspect the screenshots' }] });
await ctx.untilTurnEnd();
expect(attempts).toBe(2);
// The first request carried all three images.
const firstParts = histories[0]!.flatMap((message) => message.content);
expect(firstParts.filter((part) => part.type === 'image_url')).toHaveLength(3);
// The retry keeps only the two most recent images; the oldest becomes a
// placeholder while its path wrapper survives for readback.
const retryParts = histories[1]!.flatMap((message) => message.content);
const retryImages = retryParts.filter((part) => part.type === 'image_url');
expect(retryImages).toHaveLength(2);
expect(
retryImages.map((part) => (part.type === 'image_url' ? part.imageUrl.url : '')),
).toEqual(['data:image/png;base64,bAAA', 'data:image/png;base64,cAAA']);
const retryText = retryParts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n');
expect(retryText).toContain('[image omitted:');
expect(retryText).toContain('<image path="/workspace/a.png">');
// The real history is untouched.
expect(
ctx.agent.context.history
.flatMap((message) => message.content)
.filter((part) => part.type === 'image_url'),
).toHaveLength(3);
});
it('tracks turn_started and turn_interrupted telemetry', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });

View file

@ -116,8 +116,8 @@ describe('ToolCallDeduplicator', () => {
dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain('repeating the exact same tool call');
expect(last!.output as string).not.toContain('repeated_times');
expect(last!.output as string).toContain('what new information you expect');
expect(last!.output as string).not.toContain('Choose exactly one');
});
it('keeps injecting reminder1 at 4 consecutive', async () => {
@ -129,7 +129,7 @@ describe('ToolCallDeduplicator', () => {
dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain('repeating the exact same tool call');
expect(last!.output as string).toContain('what new information you expect');
});
it('injects reminder2 at exactly 5 consecutive', async () => {
@ -141,9 +141,9 @@ describe('ToolCallDeduplicator', () => {
dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain('repeated_times: 5');
expect(last!.output as string).toContain('tool: Read');
expect(last!.output as string).toContain('arguments:');
expect(last!.output as string).toContain('issued 5 times in a row');
expect(last!.output as string).toContain('Choose exactly one of the following');
expect(last!.output as string).toContain('Falsification check');
});
it.each([6, 7])('keeps injecting reminder2 at %i consecutive', async (streak) => {
@ -155,8 +155,8 @@ describe('ToolCallDeduplicator', () => {
dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain(`repeated_times: ${String(streak)}`);
expect(last!.output as string).toContain('tool: Read');
expect(last!.output as string).toContain(`issued ${String(streak)} times in a row`);
expect(last!.output as string).toContain('Choose exactly one of the following');
});
it('injects the dead-end reminder at exactly 8 consecutive', async () => {
@ -168,7 +168,7 @@ describe('ToolCallDeduplicator', () => {
dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain('stuck in a dead end');
expect(last!.output as string).toContain('without any further tool calls');
});
it('resets streak when a different call is interleaved', async () => {
@ -214,9 +214,9 @@ describe('ToolCallDeduplicator', () => {
dedup.endStep();
expect(original.output as string).toContain('<system-reminder>');
expect(original.output as string).toContain('repeating the exact same tool call');
expect(original.output as string).toContain('what new information you expect');
expect(finalDup.output as string).toContain('<system-reminder>');
expect(finalDup.output as string).toContain('repeating the exact same tool call');
expect(finalDup.output as string).toContain('what new information you expect');
});
it('same-step spam alone does not trigger reminder', async () => {
@ -273,7 +273,7 @@ describe('ToolCallDeduplicator', () => {
const arr = final.output as Array<{ type: string; text: string }>;
expect(arr).toHaveLength(1);
expect(arr[0]!.type).toBe('text');
expect(arr[0]!.text).toBe('hello' + makeReminderText2('X', 5, { a: 1 }));
expect(arr[0]!.text).toBe('hello' + makeReminderText2(5));
});
it('pushes a new text part when trailing part is non-text', async () => {
@ -408,8 +408,8 @@ describe('ToolCallDeduplicator', () => {
const dedup = new ToolCallDeduplicator();
const last = await runStreak(dedup, 8);
expect(last.output as string).toContain('<system-reminder>');
expect(last.output as string).toContain('stuck in a dead end');
expect(last.output as string).toContain('Stop all function calls immediately');
expect(last.output as string).toContain('Write your final response now');
expect(last.output as string).toContain('without any further tool calls');
// 8 is the reminder threshold, not yet force-stop.
expect(last.isError).toBeUndefined();
expect(stopTurnOf(last)).toBeUndefined();
@ -420,7 +420,7 @@ describe('ToolCallDeduplicator', () => {
async (streak) => {
const dedup = new ToolCallDeduplicator();
const last = await runStreak(dedup, streak);
expect(last.output as string).toContain('stuck in a dead end');
expect(last.output as string).toContain('Write your final response now');
expect(last.isError).toBeUndefined();
expect(stopTurnOf(last)).toBeUndefined();
},
@ -429,7 +429,7 @@ describe('ToolCallDeduplicator', () => {
it('force-stops the turn at exactly 12 consecutive without marking the tool failed', async () => {
const dedup = new ToolCallDeduplicator();
const last = await runStreak(dedup, 12);
expect(last.output as string).toContain('stuck in a dead end');
expect(last.output as string).toContain('Write your final response now');
// The underlying tool succeeded — force-stop must not flip it to error.
expect(last.isError).toBeUndefined();
expect(stopTurnOf(last)).toBe(true);
@ -459,7 +459,7 @@ describe('ToolCallDeduplicator', () => {
// The underlying tool was an error — that must survive force-stop.
expect(last!.isError).toBe(true);
expect(stopTurnOf(last!)).toBe(true);
expect(last!.output as string).toContain('stuck in a dead end');
expect(last!.output as string).toContain('Write your final response now');
});
});

View file

@ -104,6 +104,10 @@ keep_alive_on_exit = false
kill_grace_period_ms = 2000
print_wait_ceiling_s = 3600
[image]
max_edge_px = 1500
read_byte_budget = 131072
[[hooks]]
event = "PreToolUse"
matcher = "Shell"
@ -181,6 +185,7 @@ describe('harness config TOML loader', () => {
killGracePeriodMs: 2000,
printWaitCeilingS: 3600,
});
expect(config.image).toEqual({ maxEdgePx: 1500, readByteBudget: 131072 });
expect(config.hooks).toEqual([
{
event: 'PreToolUse',
@ -201,6 +206,23 @@ describe('harness config TOML loader', () => {
expect(config.raw?.['notifications']).toEqual({ claim_stale_after_ms: 15000 });
});
it('round-trips the [image] section', async () => {
const dir = makeTempDir();
const configPath = join(dir, 'image-round-trip.toml');
const toml = `
[image]
max_edge_px = 2500
read_byte_budget = 524288
`;
const config = parseConfigString(toml, configPath);
expect(config.image).toEqual({ maxEdgePx: 2500, readByteBudget: 524288 });
await writeConfigFile(configPath, config);
const text = await readFile(configPath, 'utf-8');
const roundTripped = parseConfigString(text, configPath);
expect(roundTripped.image).toEqual({ maxEdgePx: 2500, readByteBudget: 524288 });
});
it('round-trips a custom registry source field on a provider', async () => {
const dir = makeTempDir();
const configPath = join(dir, 'round-trip.toml');

View file

@ -1,14 +1,22 @@
/**
* Post-400 strict-resend fallback.
* Post-rejection resend fallbacks in `executeLoopStep`.
*
* When a strict provider rejects a step with a tool_use/tool_result adjacency
* 400, the same history would be re-sent every turn and the session would stay
* stuck forever. `executeLoopStep` resends ONCE with a strict, guaranteed
* wire-compliant rebuild (`buildMessagesStrict`). Any other error propagates
* unchanged and the strict builder is never consulted.
* Strict resend: when a strict provider rejects a step with a
* tool_use/tool_result adjacency 400, the same history would be re-sent every
* turn and the session would stay stuck forever. `executeLoopStep` resends
* ONCE with a strict, guaranteed wire-compliant rebuild
* (`buildMessagesStrict`).
*
* Media-degraded resend: when the provider rejects the request BODY as too
* large (HTTP 413, `APIRequestTooLargeError` accumulated base64 media, not
* tokens), the step resends ONCE with the media-degraded projection
* (`buildMessagesMediaDegraded`), and later steps of the same turn keep using
* it so each step does not pay a fresh 413.
*
* Any other error propagates unchanged and the builders are never consulted.
*/
import { APIStatusError, type Message } from '@moonshot-ai/kosong';
import { APIRequestTooLargeError, APIStatusError, type Message } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import {
@ -18,8 +26,9 @@ import {
type RunTurnInput,
} from '../../src/loop/index';
import { CollectingSink } from './fixtures/collecting-sink';
import { FakeLLM, makeEndTurnResponse } from './fixtures/fake-llm';
import { FakeLLM, makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm';
import { RecordingContext } from './fixtures/recording-context';
import { EchoTool } from './fixtures/tools';
const ADJACENCY_400 = new APIStatusError(
400,
@ -160,3 +169,154 @@ describe('executeLoopStep — tool exchange adjacency fallback', () => {
expect(strictCount).toBe(1);
});
});
describe('executeLoopStep — request-too-large media-degraded fallback', () => {
const REQUEST_TOO_LARGE = new APIRequestTooLargeError(413, 'Request exceeds the maximum size');
interface MediaHarness {
readonly input: RunTurnInput;
readonly llm: FakeLLM;
readonly degradedCalls: { count: number };
readonly degradedMessages: Message[];
readonly strictCalls: { count: number };
readonly normalCalls: { count: number };
}
function makeMediaHarness(
error: unknown,
extra: Partial<Pick<RunTurnInput, 'tools'>> & { responses?: number } = {},
): MediaHarness {
const responseCount = extra.responses ?? 2;
const llm = new FakeLLM({
responses: Array.from({ length: responseCount }, (_, index) =>
makeEndTurnResponse(index === 0 ? 'unused' : 'recovered'),
),
throwOnIndex: { index: 0, error },
});
const sink = new CollectingSink({});
const normalCalls = { count: 0 };
const normalMessages: Message[] = [userMessage('normal projection')];
const context = new RecordingContext({ messages: normalMessages });
const buildMessages: LoopMessageBuilder = () => {
normalCalls.count += 1;
return normalMessages;
};
const degradedMessages: Message[] = [userMessage('media-degraded projection')];
const degradedCalls = { count: 0 };
const buildMessagesMediaDegraded: LoopMessageBuilder = () => {
degradedCalls.count += 1;
return degradedMessages;
};
const strictCalls = { count: 0 };
const buildMessagesStrict: LoopMessageBuilder = () => {
strictCalls.count += 1;
return [userMessage('strict projection')];
};
const input: RunTurnInput = {
turnId: 'turn-1',
signal: new AbortController().signal,
llm,
buildMessages,
buildMessagesStrict,
buildMessagesMediaDegraded,
tools: extra.tools,
dispatchEvent: createLoopEventDispatcher({
appendTranscriptRecord: context.appendTranscriptRecord,
emitLiveEvent: sink.emit,
}),
};
return { input, llm, degradedCalls, degradedMessages, strictCalls, normalCalls };
}
it('resends once with the media-degraded projection after a request-too-large 413 and recovers', async () => {
const { input, llm, degradedCalls, degradedMessages, strictCalls } =
makeMediaHarness(REQUEST_TOO_LARGE);
const result = await runTurn(input);
expect(result.stopReason).toBe('end_turn');
// Exactly two provider calls: the rejected one and the degraded resend —
// and the strict builder is never consulted for a body-size rejection.
expect(llm.callCount).toBe(2);
expect(degradedCalls.count).toBe(1);
expect(strictCalls.count).toBe(0);
expect(llm.calls[0]?.messages).toEqual([userMessage('normal projection')]);
expect(llm.calls[1]?.messages).toBe(degradedMessages);
});
it('does not degrade for an unclassified 413 — the error propagates', async () => {
const { input, llm, degradedCalls } = makeMediaHarness(
new APIStatusError(413, 'Request failed'),
);
await expect(runTurn(input)).rejects.toThrow('Request failed');
expect(llm.callCount).toBe(1);
expect(degradedCalls.count).toBe(0);
});
it('resends only once: a degraded rebuild that is also rejected gives up (no loop)', async () => {
const llm = new FakeLLM({ responses: [] });
let calls = 0;
llm.chat = async () => {
calls += 1;
throw REQUEST_TOO_LARGE;
};
const sink = new CollectingSink({});
const context = new RecordingContext({ messages: [userMessage('normal')] });
let degradedCount = 0;
const input: RunTurnInput = {
turnId: 'turn-1',
signal: new AbortController().signal,
llm,
buildMessages: context.buildMessages,
buildMessagesMediaDegraded: () => {
degradedCount += 1;
return [userMessage('degraded')];
},
dispatchEvent: createLoopEventDispatcher({
appendTranscriptRecord: context.appendTranscriptRecord,
emitLiveEvent: sink.emit,
}),
};
await expect(runTurn(input)).rejects.toBe(REQUEST_TOO_LARGE);
expect(calls).toBe(2); // first attempt + one degraded resend, then give up
expect(degradedCount).toBe(1);
});
it('keeps using the degraded projection for later steps of the same turn', async () => {
// Step 1 is rejected with a 413 and recovers via the degraded projection,
// then issues a tool call; step 2 must build from the degraded projection
// directly — re-sending the full-media history would deterministically
// pay a fresh 413 on every step.
const echo = new EchoTool();
const llm = new FakeLLM({
responses: [
makeEndTurnResponse('unused'),
makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-1')]),
makeEndTurnResponse('done'),
],
throwOnIndex: { index: 0, error: REQUEST_TOO_LARGE },
});
const harness = makeMediaHarness(REQUEST_TOO_LARGE);
const input: RunTurnInput = {
...harness.input,
llm,
tools: [echo],
};
const result = await runTurn(input);
expect(result.stopReason).toBe('end_turn');
expect(llm.callCount).toBe(3);
// Step 1: normal projection rejected, degraded resend recovers.
expect(llm.calls[0]?.messages).toEqual([userMessage('normal projection')]);
expect(llm.calls[1]?.messages).toBe(harness.degradedMessages);
// Step 2: built straight from the degraded projection, not the normal one.
expect(llm.calls[2]?.messages).toBe(harness.degradedMessages);
expect(harness.normalCalls.count).toBe(1);
expect(harness.degradedCalls.count).toBe(2);
expect(echo.calls).toHaveLength(1);
});
});

View file

@ -32,9 +32,9 @@ export function createFakeKaos(
overrides?: Partial<Kaos>,
envLayers: readonly Record<string, string>[] = [],
): Kaos {
// Hold cwd in a closure so `chdir` (which `config.update({cwd})` now
// routes through) can mutate it and later `getcwd()` calls see the
// update — mirroring real-kaos semantics without needing a backing fs.
// Hold cwd in a closure so tests that call `chdir` directly can mutate it
// and later `getcwd()` calls see the update — mirroring real-kaos semantics
// without needing a backing fs.
let cwd = overrides?.getcwd?.() ?? '/workspace';
const base: Kaos = {
name: 'fake',

View file

@ -10,8 +10,10 @@
* comes back as JPEG, strictly smaller than the input
* - alpha: a translucent PNG stays PNG when the budget allows, and only
* drops to JPEG as a last resort to meet a tiny budget
* - fallback: corrupt/empty bytes and non-recodable formats (GIF/WebP)
* return the original unchanged never throws
* - fallback: corrupt/empty bytes and non-recodable formats (GIF,
* animated WebP) return the original unchanged never throws
* - webp: still WebP decodes through the bundled wasm codec and re-encodes
* on the lossless-first ladder; animated WebP passes through whole
* - invariant: `changed` implies the result is strictly smaller
* - base64 wrapper round-trips
* - performance: the fast path is codec-free; a large image compresses
@ -32,8 +34,10 @@
* result is a no-op; extreme aspect ratios never collapse to zero
*/
import { createRequire } from 'node:module';
import { Jimp, ResizeStrategy } from 'jimp';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
// eslint-disable-next-line import/no-unresolved
import {
@ -44,7 +48,14 @@ import {
cropImageForModel,
extractImageCompressionCaptions,
IMAGE_BYTE_BUDGET,
MAX_IMAGE_EDGE_ENV,
MAX_IMAGE_EDGE_PX,
READ_IMAGE_BYTE_BUDGET,
READ_IMAGE_BYTE_BUDGET_ENV,
resolveMaxImageEdgePx,
resolveReadImageByteBudget,
setConfiguredMaxImageEdgePx,
setConfiguredReadImageByteBudget,
} from '../../src/tools/support/image-compress';
// eslint-disable-next-line import/no-unresolved
import { sniffImageDimensions } from '../../src/tools/support/file-type';
@ -184,11 +195,11 @@ describe('compressImageForModel — dimension cap', () => {
const result = await compressImageForModel(png, 'image/png');
expect(result.changed).toBe(true);
expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX);
// 4500x2250 → 3000x1500 (aspect 2:1 preserved).
expect(result.width).toBe(3000);
expect(result.height).toBe(1500);
// 4500x2250 → 2000x1000 (aspect 2:1 preserved).
expect(result.width).toBe(2000);
expect(result.height).toBe(1000);
const dims = sniffImageDimensions(result.data);
expect(dims).toEqual({ width: 3000, height: 1500 });
expect(dims).toEqual({ width: 2000, height: 1000 });
});
it('respects a custom maxEdge', async () => {
@ -239,9 +250,10 @@ describe('compressImageForModel — byte budget', () => {
});
it('steps down through the 2000px edge before the 1000px fallback', async () => {
// Regression guard for the 3000px cap raise: a PNG whose fitted encode
// is over budget but whose 2000px encode fits must come back at 2000px
// (as it did under the old cap), not skip straight to 1000px.
// Fallback-ladder guard, pinned with an explicit 3000px ceiling (the
// built-in default is 2000px, where the first fallback edge is a no-op):
// a PNG whose fitted encode is over budget but whose 2000px encode fits
// must come back at 2000px, not skip straight to 1000px.
// The budget is anchored to the actual 2000px encode size (probed with
// an unlimited budget) so the test does not depend on exact deflate
// output sizes.
@ -258,6 +270,7 @@ describe('compressImageForModel — byte budget', () => {
expect(probe.finalByteLength + 1024).toBeLessThan(png.length);
const result = await compressImageForModel(png, 'image/png', {
maxEdge: 3000,
byteBudget: probe.finalByteLength + 1024,
});
expect(result.changed).toBe(true);
@ -299,6 +312,195 @@ describe('compressImageForModel — byte budget', () => {
);
});
// ── webp fixtures (encoder wasm loaded manually from node_modules) ──
/**
* Encode RGBA pixels to WebP using the encoder wasm from node_modules
* test-fixture only; production never encodes WebP. The emscripten glue
* cannot auto-locate its wasm under Node (it tries fetch on a file URL), so
* the module is compiled and injected manually, mirroring how production
* initializes the decoder from the bundled base64.
*/
async function encodeWebp(
image: { bitmap: { data: Buffer | Uint8Array; width: number; height: number } },
quality = 90,
): Promise<Uint8Array> {
const requireLocal = createRequire(import.meta.url);
const encMod = (await import(
requireLocal.resolve('@jsquash/webp/encode.js')
)) as typeof import('@jsquash/webp/encode.js');
const { readFileSync } = await import('node:fs');
// The repo tsconfig has no DOM lib, so the global WebAssembly name is
// reached structurally (same approach as the production decoder).
const wasmNamespace = (
globalThis as unknown as { WebAssembly: { compile(bytes: Uint8Array): Promise<object> } }
).WebAssembly;
const wasm = await wasmNamespace.compile(
readFileSync(requireLocal.resolve('@jsquash/webp/codec/enc/webp_enc.wasm')),
);
await encMod.init(wasm as never);
const { bitmap } = image;
const encoded = await encMod.default(
{
data: new Uint8ClampedArray(
bitmap.data.buffer,
bitmap.data.byteOffset,
bitmap.data.byteLength,
),
width: bitmap.width,
height: bitmap.height,
} as never,
{ quality },
);
return new Uint8Array(encoded);
}
/** Minimal VP8X container header with the ANIM flag set. */
function animatedWebpHeader(): Uint8Array {
const bytes = new Uint8Array(30);
const ascii = (s: string, at: number) => {
for (let i = 0; i < s.length; i++) bytes[at + i] = s.charCodeAt(i);
};
ascii('RIFF', 0);
new DataView(bytes.buffer).setUint32(4, 22, true);
ascii('WEBP', 8);
ascii('VP8X', 12);
new DataView(bytes.buffer).setUint32(16, 10, true);
bytes[20] = 0x02; // ANIM flag
return bytes;
}
describe('compressImageForModel — webp', () => {
it(
'downscales an oversized WebP to the edge cap',
async () => {
const source = new Jimp({ width: 2600, height: 1300, color: 0x3366ccff });
const webp = await encodeWebp(source);
const result = await compressImageForModel(webp, 'image/webp');
expect(result.changed).toBe(true);
expect(Math.max(result.width, result.height)).toBe(2000);
expect(result.originalWidth).toBe(2600);
expect(result.originalHeight).toBe(1300);
expect(sniffImageDimensions(result.data)).toEqual({ width: 2000, height: 1000 });
},
15_000,
);
it(
're-encodes an over-budget WebP within the byte budget',
async () => {
const budget = 128 * 1024;
const noisy = new Jimp({ width: 1200, height: 1200, color: 0x000000ff });
fillXorshiftNoise(noisy.bitmap.data);
const webp = await encodeWebp(noisy, 100);
expect(webp.length).toBeGreaterThan(budget);
const result = await compressImageForModel(webp, 'image/webp', { byteBudget: budget });
expect(result.changed).toBe(true);
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
},
15_000,
);
it(
'keeps alpha when re-encoding a translucent WebP',
async () => {
const translucent = new Jimp({ width: 2600, height: 1300, color: 0x33_66_cc_80 });
const webp = await encodeWebp(translucent);
const result = await compressImageForModel(webp, 'image/webp');
expect(result.changed).toBe(true);
expect(result.mimeType).toBe('image/png');
expect(await decodeAlpha(result.data)).toBe(true);
},
15_000,
);
it('passes an animated WebP through to preserve animation', async () => {
const animated = animatedWebpHeader();
const result = await compressImageForModel(animated, 'image/webp');
expect(result.changed).toBe(false);
expect(result.data).toBe(animated);
});
it(
'crops a region out of a WebP',
async () => {
const source = new Jimp({ width: 800, height: 400, color: 0x3366ccff });
const webp = await encodeWebp(source);
const result = await cropImageForModel(webp, 'image/webp', {
x: 10,
y: 20,
width: 300,
height: 200,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.width).toBe(300);
expect(result.height).toBe(200);
expect(result.originalWidth).toBe(800);
expect(result.originalHeight).toBe(400);
},
15_000,
);
});
// ── small byte budgets (per-image read budget scale) ────────────────
// These walk many encode rungs over noise fixtures, which is slow on CI
// runners — each carries an explicit timeout like the quality-ladder test
// above.
describe('compressImageForModel — small byte budgets', () => {
it(
'converges under a 128KB budget for high-entropy PNG content',
async () => {
// Statistically random noise is the entropy upper bound (photos, dense
// charts): the old [2000, 1000] fallback floor left q20@1000px at ~200KB,
// over a read-scale budget. The extended ladder must land within it.
const budget = 128 * 1024;
const png = await randomNoisePng(1200, 1200);
expect(png.length).toBeGreaterThan(budget);
const result = await compressImageForModel(png, 'image/png', { byteBudget: budget });
expect(result.changed).toBe(true);
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
expect(sniffImageDimensions(result.data)).not.toBeNull();
},
15_000,
);
it(
'converges under a 128KB budget for a JPEG source',
async () => {
const budget = 128 * 1024;
const jpeg = await randomNoiseJpeg(1200, 1200);
expect(jpeg.length).toBeGreaterThan(budget);
const result = await compressImageForModel(jpeg, 'image/jpeg', { byteBudget: budget });
expect(result.changed).toBe(true);
expect(result.mimeType).toBe('image/jpeg');
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
},
15_000,
);
it(
'shrinks pixels instead of passing through an already-optimized JPEG over budget',
async () => {
// A JPEG already at the encoder's quality floor for its size: re-encoding
// at the same size cannot shrink it, so without sub-size fallbacks the
// "unhelpful" guard used to return the original — silently over budget.
const image = new Jimp({ width: 900, height: 900, color: 0x000000ff });
fillXorshiftNoise(image.bitmap.data);
const optimized = new Uint8Array(await image.getBuffer('image/jpeg', { quality: 20 }));
const budget = optimized.length - 10 * 1024;
expect(budget).toBeGreaterThan(0);
const result = await compressImageForModel(optimized, 'image/jpeg', { byteBudget: budget });
expect(result.changed).toBe(true);
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
expect(Math.max(result.width, result.height)).toBeLessThan(900);
},
15_000,
);
});
// ── fallback / robustness ────────────────────────────────────────────
describe('compressImageForModel — fallback', () => {
@ -325,7 +527,10 @@ describe('compressImageForModel — fallback', () => {
expect(result.data).toBe(gif);
});
it('passes WebP through (no codec in the default build)', async () => {
it('passes undecodable WebP bytes through (never throws)', async () => {
// A bare RIFF/WEBP container header with no image payload: the sniffer
// reports no dimensions and the wasm decoder cannot decode it, so it
// passes through unchanged like any other undecodable input.
const webp = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50,
]);
@ -444,7 +649,101 @@ describe('compressImageForModel — performance', () => {
it('exposes a sane default budget', () => {
expect(IMAGE_BYTE_BUDGET).toBeGreaterThan(0);
expect(MAX_IMAGE_EDGE_PX).toBe(3000);
expect(MAX_IMAGE_EDGE_PX).toBe(2000);
});
});
// ── default edge resolution (env + config) ──────────────────────────
describe('resolveMaxImageEdgePx', () => {
afterEach(() => {
vi.unstubAllEnvs();
setConfiguredMaxImageEdgePx(undefined);
});
it('defaults to the built-in ceiling', () => {
expect(resolveMaxImageEdgePx()).toBe(MAX_IMAGE_EDGE_PX);
});
it('uses the configured value when set, and clears with undefined', () => {
setConfiguredMaxImageEdgePx(1200);
expect(resolveMaxImageEdgePx()).toBe(1200);
setConfiguredMaxImageEdgePx(undefined);
expect(resolveMaxImageEdgePx()).toBe(MAX_IMAGE_EDGE_PX);
});
it('lets the env var override the configured value', () => {
setConfiguredMaxImageEdgePx(1200);
vi.stubEnv(MAX_IMAGE_EDGE_ENV, '900');
expect(resolveMaxImageEdgePx()).toBe(900);
});
it.each(['abc', '-100', '0', '1.5', ' '])('ignores the invalid env value "%s"', (raw) => {
vi.stubEnv(MAX_IMAGE_EDGE_ENV, raw);
expect(resolveMaxImageEdgePx()).toBe(MAX_IMAGE_EDGE_PX);
});
it('drives compressImageForModel when no explicit maxEdge is passed', async () => {
setConfiguredMaxImageEdgePx(1200);
const png = await solidPng(1600, 800);
const result = await compressImageForModel(png, 'image/png');
expect(result.changed).toBe(true);
expect(result.width).toBe(1200);
expect(result.height).toBe(600);
});
it('an explicit maxEdge option still wins over env and config', async () => {
setConfiguredMaxImageEdgePx(1200);
vi.stubEnv(MAX_IMAGE_EDGE_ENV, '900');
const png = await solidPng(1600, 800);
const result = await compressImageForModel(png, 'image/png', { maxEdge: 800 });
expect(result.changed).toBe(true);
expect(result.width).toBe(800);
expect(result.height).toBe(400);
});
it('drives cropImageForModel region fitting', async () => {
setConfiguredMaxImageEdgePx(400);
const png = await solidPng(1600, 800);
const result = await cropImageForModel(png, 'image/png', {
x: 0,
y: 0,
width: 800,
height: 800,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(Math.max(result.width, result.height)).toBe(400);
});
});
describe('resolveReadImageByteBudget', () => {
afterEach(() => {
vi.unstubAllEnvs();
setConfiguredReadImageByteBudget(undefined);
});
it('defaults to the built-in read budget', () => {
expect(READ_IMAGE_BYTE_BUDGET).toBe(256 * 1024);
expect(resolveReadImageByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET);
});
it('uses the configured value when set, and clears with undefined', () => {
setConfiguredReadImageByteBudget(512 * 1024);
expect(resolveReadImageByteBudget()).toBe(512 * 1024);
setConfiguredReadImageByteBudget(undefined);
expect(resolveReadImageByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET);
});
it('lets the env var override the configured value', () => {
setConfiguredReadImageByteBudget(512 * 1024);
vi.stubEnv(READ_IMAGE_BYTE_BUDGET_ENV, '100000');
expect(resolveReadImageByteBudget()).toBe(100000);
});
it.each(['abc', '-1', '0', '1.5', ' '])('ignores the invalid env value "%s"', (raw) => {
vi.stubEnv(READ_IMAGE_BYTE_BUDGET_ENV, raw);
expect(resolveReadImageByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET);
});
});
@ -546,7 +845,7 @@ describe('compressImageForModel — original dimensions metadata', () => {
expect(shrunk.changed).toBe(true);
expect(shrunk.originalWidth).toBe(4500);
expect(shrunk.originalHeight).toBe(2250);
expect(shrunk.width).toBe(3000);
expect(shrunk.width).toBe(2000);
});
it('reports original dimensions through the base64 wrapper', async () => {
@ -556,8 +855,8 @@ describe('compressImageForModel — original dimensions metadata', () => {
expect(result.changed).toBe(true);
expect(result.originalWidth).toBe(3900);
expect(result.originalHeight).toBe(1950);
expect(result.width).toBe(3000);
expect(result.height).toBe(1500);
expect(result.width).toBe(2000);
expect(result.height).toBe(1000);
});
});
@ -680,7 +979,7 @@ describe('cropImageForModel', () => {
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatch(/PNG and JPEG/);
expect(result.error).toMatch(/PNG, JPEG, and WebP/);
});
it('rejects corrupt bytes without throwing', async () => {
@ -1021,14 +1320,14 @@ describe('compressImageForModel — downscale quality guards', () => {
});
it('keeps a degenerate aspect ratio at least 1px tall (no zero-size collapse)', async () => {
// 9000×2 scaled to a 3000px edge would round the short side to 0.67px;
// the resizer must clamp to 1, not produce an undecodable 3000×0 image.
// 9000×2 scaled to a 2000px edge would round the short side to 0.44px;
// the resizer must clamp to 1, not produce an undecodable 2000×0 image.
const png = await solidPng(9000, 2);
const result = await compressImageForModel(png, 'image/png');
expect(result.changed).toBe(true);
expect(result.width).toBe(3000);
expect(result.width).toBe(2000);
expect(result.height).toBe(1);
expect(sniffImageDimensions(result.data)).toEqual({ width: 3000, height: 1 });
expect(sniffImageDimensions(result.data)).toEqual({ width: 2000, height: 1 });
});
});
@ -1067,8 +1366,8 @@ describe('compressImageForModel — telemetry', () => {
expect(props['final_bytes']).toBe(result.finalByteLength);
expect(props['original_width']).toBe(4500);
expect(props['original_height']).toBe(2250);
expect(props['final_width']).toBe(3000);
expect(props['final_height']).toBe(1500);
expect(props['final_width']).toBe(2000);
expect(props['final_height']).toBe(1000);
expect(props['exif_transposed']).toBe(false);
expect(typeof props['duration_ms']).toBe('number');
});

View file

@ -5,7 +5,7 @@
import type { Kaos } from '@moonshot-ai/kaos';
import type { ContentPart, ModelCapability } from '@moonshot-ai/kosong';
import { Jimp } from 'jimp';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ToolAccesses } from '../../src/loop';
import type { ExecutableToolResult } from '../../src/loop';
@ -13,9 +13,10 @@ import {
ReadMediaFileInputSchema,
ReadMediaFileTool,
} from '../../src/tools/builtin/file/read-media';
import { setConfiguredReadImageByteBudget } from '../../src/tools/support/image-compress';
import { MEDIA_SNIFF_BYTES, sniffImageDimensions } from '../../src/tools/support/file-type';
import type { TelemetryClient } from '../../src/telemetry';
import { createFakeKaos, PERMISSIVE_WORKSPACE } from './fixtures/fake-kaos';
import { createFakeKaos, FAKE_OS_ENV, PERMISSIVE_WORKSPACE } from './fixtures/fake-kaos';
import { executeTool } from './fixtures/execute-tool';
const signal = new AbortController().signal;
@ -711,7 +712,7 @@ describe('ReadMediaFileTool', () => {
// The image actually sent to the model is downsampled to the edge cap.
const sentBytes = Buffer.from(match![2]!, 'base64');
const sentDims = sniffImageDimensions(sentBytes);
expect(Math.max(sentDims!.width, sentDims!.height)).toBeLessThanOrEqual(3000);
expect(Math.max(sentDims!.width, sentDims!.height)).toBeLessThanOrEqual(2000);
// The <system> note keeps the ORIGINAL size so coordinate mapping holds.
const systemText = noteText(result);
@ -746,7 +747,7 @@ describe('ReadMediaFileTool', () => {
const systemText = noteText(result);
expect(systemText).toContain('Original dimensions: 1800x3600');
expect(systemText).toMatch(/downsampled to 1500x3000/);
expect(systemText).toMatch(/downsampled to 1000x2000/);
});
it('reports the decoded size for a region read of an EXIF-rotated image', async () => {
@ -893,7 +894,7 @@ describe('ReadMediaFileTool', () => {
// Wording must not depend on serialization order: some providers keep
// the note inline after the media, others flatten tool text and
// re-attach the image after it — so no "above"/"below".
expect(systemText).toMatch(/The attached image was downsampled to 3000x3000/);
expect(systemText).toMatch(/The attached image was downsampled to 2000x2000/);
expect(systemText).toMatch(/fine detail/i);
expect(systemText).toContain('region');
});
@ -1014,4 +1015,182 @@ describe('ReadMediaFileTool', () => {
expect(withFullRes.isError).toBe(true);
});
});
describe('provider-unsupported formats (HEIC/HEIF)', () => {
/** Minimal ISO-BMFF header: size + 'ftyp' + the given brand. */
function ftypHeader(brand: string): Buffer {
const bytes = Buffer.alloc(16);
bytes.writeUInt32BE(16, 0);
bytes.write('ftyp', 4, 'latin1');
bytes.write(brand, 8, 'latin1');
return bytes;
}
function heicTool(osKind: string, brand = 'heic'): ReadMediaFileTool {
const data = ftypHeader(brand);
const kaos = createFakeKaos({
stat: vi.fn<Kaos['stat']>().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(data),
osEnv: { ...FAKE_OS_ENV, osKind },
});
return new ReadMediaFileTool(kaos, PERMISSIVE_WORKSPACE, capabilities());
}
it('refuses HEIC with sips guidance on macOS instead of sending it to the provider', async () => {
const result = await executeTool(heicTool('macOS'), {
turnId: 't1',
toolCallId: 'c_heic_mac',
args: { path: '/workspace/photo.HEIC' },
signal,
});
expect(result.isError).toBe(true);
expect(result.output).toContain('image/heic');
expect(result.output).toContain('sips -s format jpeg');
expect(result.output).toContain('/workspace/photo.jpg');
});
it('refuses HEIC with heif-convert / ImageMagick guidance on Linux', async () => {
const result = await executeTool(heicTool('Linux'), {
turnId: 't1',
toolCallId: 'c_heic_linux',
args: { path: '/workspace/photo.HEIC' },
signal,
});
expect(result.isError).toBe(true);
expect(result.output).toContain('heif-convert');
expect(result.output).toContain('magick');
});
it('refuses HEIC with ImageMagick guidance on Windows', async () => {
const result = await executeTool(heicTool('Windows'), {
turnId: 't1',
toolCallId: 'c_heic_win',
args: { path: '/workspace/photo.HEIC' },
signal,
});
expect(result.isError).toBe(true);
expect(result.output).toContain('magick');
expect(result.output).toContain('winget');
});
it('refuses HEIF brands and region reads the same way', async () => {
const heif = await executeTool(heicTool('macOS', 'mif1'), {
turnId: 't1',
toolCallId: 'c_heif',
args: { path: '/workspace/photo.heif' },
signal,
});
expect(heif.isError).toBe(true);
expect(heif.output).toContain('image/heif');
const region = await executeTool(heicTool('macOS'), {
turnId: 't1',
toolCallId: 'c_heic_region',
args: { path: '/workspace/photo.HEIC', region: { x: 0, y: 0, width: 10, height: 10 } },
signal,
});
expect(region.isError).toBe(true);
expect(region.output).toContain('sips');
});
});
describe('read byte budget', () => {
afterEach(() => {
setConfiguredReadImageByteBudget(undefined);
});
/** High-entropy PNG whose bytes stay large after downscaling. */
async function noisePng(width: number, height: number): Promise<Buffer> {
const image = new Jimp({ width, height, color: 0x000000ff });
const data = image.bitmap.data;
let state = 0x9e3779b9;
for (let i = 0; i < data.length; i += 4) {
state ^= (state << 13) >>> 0;
state ^= state >>> 17;
state ^= (state << 5) >>> 0;
state >>>= 0;
data[i] = state & 0xff;
data[i + 1] = (state >>> 8) & 0xff;
data[i + 2] = (state >>> 16) & 0xff;
data[i + 3] = 0xff;
}
return Buffer.from(await image.getBuffer('image/png'));
}
function toolFor(data: Buffer): ReadMediaFileTool {
return makeReadMediaTool({
stat: vi.fn<Kaos['stat']>().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(data),
});
}
function sentBytes(result: Awaited<ReturnType<typeof executeTool>>): Buffer {
const parts = outputParts(result);
const url = (parts[1] as { imageUrl: { url: string } }).imageUrl.url;
const match = /^data:image\/[a-z]+;base64,(.+)$/.exec(url);
expect(match).not.toBeNull();
return Buffer.from(match![1]!, 'base64');
}
it(
'compresses a default read to fit the configured read budget',
async () => {
const budget = 64 * 1024;
setConfiguredReadImageByteBudget(budget);
const data = await noisePng(1200, 1200);
expect(data.length).toBeGreaterThan(budget);
const result = await executeTool(toolFor(data), {
turnId: 't1',
toolCallId: 'c_budget',
args: { path: '/workspace/noisy.png' },
signal,
});
expect(result.isError).toBe(false);
expect(sentBytes(result).length).toBeLessThanOrEqual(budget);
expect(noteText(result)).toMatch(/downsampled/);
},
15_000,
);
it('full_resolution ignores the read budget (per-image provider limit applies)', async () => {
setConfiguredReadImageByteBudget(64 * 1024);
const data = await noisePng(600, 600);
expect(data.length).toBeGreaterThan(64 * 1024);
const result = await executeTool(toolFor(data), {
turnId: 't1',
toolCallId: 'c_fullres_budget',
args: { path: '/workspace/noisy.png', full_resolution: true },
signal,
});
expect(result.isError).toBe(false);
// Delivered byte-for-byte: the read budget must not shrink the
// full-fidelity escape hatch the downsample note promises.
expect(sentBytes(result).equals(data)).toBe(true);
});
it(
'region reads ignore the read budget so detail readback stays full-fidelity',
async () => {
setConfiguredReadImageByteBudget(16 * 1024);
const data = await noisePng(800, 800);
const result = await executeTool(toolFor(data), {
turnId: 't1',
toolCallId: 'c_region_budget',
args: { path: '/workspace/noisy.png', region: { x: 0, y: 0, width: 400, height: 400 } },
signal,
});
expect(result.isError).toBe(false);
// A native-resolution noise crop is far larger than the read budget;
// it must still be delivered under the provider-scale budget.
expect(sentBytes(result).length).toBeGreaterThan(16 * 1024);
},
15_000,
);
});
});

View file

@ -56,6 +56,19 @@ export class APIContextOverflowError extends APIStatusError {
}
}
/**
* HTTP 413 that specifically means the serialized request body exceeded the
* provider's byte ceiling (e.g. accumulated base64 images), as opposed to a
* token-count overflow. Token overflow is recoverable by compaction; a body
* size rejection is not it needs media to be dropped or shrunk.
*/
export class APIRequestTooLargeError extends APIStatusError {
constructor(statusCode: number, message: string, requestId?: string | null) {
super(statusCode, message, requestId);
this.name = 'APIRequestTooLargeError';
}
}
/**
* HTTP status error that specifically means the provider rate-limited the
* request.
@ -146,6 +159,29 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [
/rate-limited/,
] as const;
// Wordings that mean the serialized request BODY was too big, matched against
// the lowercased message of a 413. Kept separate from the context-overflow
// patterns above: those describe token counts, these describe bytes. A 413
// whose message matches neither family stays a plain `APIStatusError` —
// Vertex phrases prompt-too-long as a 413, so the status alone is not proof
// of a body-size rejection.
const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [
// Moonshot / Kimi: "Request exceeds the maximum size".
/request exceeds the maximum size/,
// Reverse proxies (nginx-style HTML body): "413 Request Entity Too Large".
/request entity too large/,
// Anthropic: error type `request_too_large`, message "Request exceeds the
// maximum allowed number of bytes".
/request_too_large/,
/exceeds? the maximum allowed number of bytes/,
// RFC 9110 reason phrase (both the pre-2022 and current names).
/payload too large/,
/content too large/,
// Plain wordings: generic gateways say "request too large"; Go's
// http.MaxBytesReader (common in Go proxies) says "request body too large".
/request (?:body )?too large/,
] as const;
export function isContextOverflowErrorCode(code: string | null | undefined): boolean {
return code === 'context_length_exceeded';
}
@ -158,9 +194,14 @@ export function normalizeAPIStatusError(
if (statusCode === 429) {
return new APIProviderRateLimitError(message, requestId);
}
// Context overflow first: Vertex returns prompt-too-long as a 413, and a
// token overflow must keep routing to compaction even on that status.
if (isContextOverflowStatusError(statusCode, message)) {
return new APIContextOverflowError(statusCode, message, requestId);
}
if (isRequestTooLargeStatusError(statusCode, message)) {
return new APIRequestTooLargeError(statusCode, message, requestId);
}
return new APIStatusError(statusCode, message, requestId);
}
@ -170,6 +211,12 @@ export function isContextOverflowStatusError(statusCode: number, message: string
return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}
export function isRequestTooLargeStatusError(statusCode: number, message: string): boolean {
if (statusCode !== 413) return false;
const lowerMessage = message.toLowerCase();
return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}
// Strict providers reject a request whose assistant `tool_use`/`tool_calls` and
// `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing
// result, a stray result with no matching call, or a result that does not

Some files were not shown because too many files have changed in this diff Show more