diff --git a/.changeset/expose-extrausage-toolkit.md b/.changeset/expose-extrausage-toolkit.md new file mode 100644 index 000000000..d31c53ee5 --- /dev/null +++ b/.changeset/expose-extrausage-toolkit.md @@ -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. diff --git a/.changeset/fix-missing-workdir-resume.md b/.changeset/fix-missing-workdir-resume.md new file mode 100644 index 000000000..187152cf0 --- /dev/null +++ b/.changeset/fix-missing-workdir-resume.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix resuming sessions whose original working directory no longer exists. diff --git a/.changeset/fix-prompt-goal-mode.md b/.changeset/fix-prompt-goal-mode.md new file mode 100644 index 000000000..0aa7146f1 --- /dev/null +++ b/.changeset/fix-prompt-goal-mode.md @@ -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. diff --git a/.changeset/image-request-size-limits.md b/.changeset/image-request-size-limits.md new file mode 100644 index 000000000..076b0ef78 --- /dev/null +++ b/.changeset/image-request-size-limits.md @@ -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. + diff --git a/.changeset/polish-sidebar-ui.md b/.changeset/polish-sidebar-ui.md new file mode 100644 index 000000000..da0cefa73 --- /dev/null +++ b/.changeset/polish-sidebar-ui.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Polish the session sidebar layout, colors, icons, and typography. diff --git a/.changeset/web-ui-polish.md b/.changeset/web-ui-polish.md new file mode 100644 index 000000000..5a829b6c1 --- /dev/null +++ b/.changeset/web-ui-polish.md @@ -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. diff --git a/apps/kimi-code/src/cli/goal-prompt.ts b/apps/kimi-code/src/cli/goal-prompt.ts index 5ab0cfe85..57f223ad4 100644 --- a/apps/kimi-code/src/cli/goal-prompt.ts +++ b/apps/kimi-code/src/cli/goal-prompt.ts @@ -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 }; } diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 603f57899..3d639f6a2 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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 { 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 { + // 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(); + } }); } diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 51ccd3fc1..fd5d397f4 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -213,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 23cef0b27..195860bc3 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -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; } diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index 04780bd26..8d75c4721 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -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) => { + 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); diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 7d27be8e2..041860896 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -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', diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index ff39cb7e6..cf2598f82 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -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); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts index 0fe14bc09..e41838475 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -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. diff --git a/apps/kimi-desktop/src/main/index.ts b/apps/kimi-desktop/src/main/index.ts index 7c440a1ce..2a765274b 100644 --- a/apps/kimi-desktop/src/main/index.ts +++ b/apps/kimi-desktop/src/main/index.ts @@ -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 . + 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 ('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); }); diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index febbb8626..300d1848d 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -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", diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index f41127fb7..e46e8a2fc 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -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 {
@@ -792,8 +793,29 @@ function openPr(url: string): void { @edit-message="handleEditMessage" /> + + + + + + + + .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; +} diff --git a/apps/kimi-web/src/components/InternalBuildBanner.vue b/apps/kimi-web/src/components/InternalBuildBanner.vue index e2638aaad..a45748f7b 100644 --- a/apps/kimi-web/src/components/InternalBuildBanner.vue +++ b/apps/kimi-web/src/components/InternalBuildBanner.vue @@ -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; diff --git a/apps/kimi-web/src/components/SessionRow.vue b/apps/kimi-web/src/components/SessionRow.vue index 5d1b8a6c3..30158be01 100644 --- a/apps/kimi-web/src/components/SessionRow.vue +++ b/apps/kimi-web/src/components/SessionRow.vue @@ -254,7 +254,7 @@ defineExpose({ closeMenu }); :label="t('sidebar.options')" @click.stop="toggleMenu($event)" > - +
@@ -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.25≈16px) + 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 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); } diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 920eb2492..8c9127e2e 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -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; /** 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(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(() => {
{{ summaryFull }}
-
-
Waiting for output…
-
{{ line }}
-
+ @@ -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); -} diff --git a/apps/kimi-web/src/components/chat/tool-calls/ToolOutputBlock.vue b/apps/kimi-web/src/components/chat/tool-calls/ToolOutputBlock.vue new file mode 100644 index 000000000..3f7059556 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/ToolOutputBlock.vue @@ -0,0 +1,42 @@ + + + + + + diff --git a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue index da5d73cbd..4a87b0cc0 100644 --- a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue @@ -394,7 +394,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise { } .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 { } .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 { .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 { } .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; diff --git a/apps/kimi-web/src/components/ui/IconButton.vue b/apps/kimi-web/src/components/ui/IconButton.vue index 151018f4c..09e9bb479 100644 --- a/apps/kimi-web/src/components/ui/IconButton.vue +++ b/apps/kimi-web/src/components/ui/IconButton.vue @@ -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; } diff --git a/apps/kimi-web/src/components/ui/Kbd.vue b/apps/kimi-web/src/components/ui/Kbd.vue new file mode 100644 index 000000000..6b20984fa --- /dev/null +++ b/apps/kimi-web/src/components/ui/Kbd.vue @@ -0,0 +1,40 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/ui/MenuItem.vue b/apps/kimi-web/src/components/ui/MenuItem.vue index a44bb31d9..cc9cf0d69 100644 --- a/apps/kimi-web/src/components/ui/MenuItem.vue +++ b/apps/kimi-web/src/components/ui/MenuItem.vue @@ -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); diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 9b61944bb..0e2b7adf5 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -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; diff --git a/apps/kimi-web/src/composables/useSidebarLayout.ts b/apps/kimi-web/src/composables/useSidebarLayout.ts index f568cb6a5..4d90a1971 100644 --- a/apps/kimi-web/src/composables/useSidebarLayout.ts +++ b/apps/kimi-web/src/composables/useSidebarLayout.ts @@ -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, diff --git a/apps/kimi-web/src/i18n/locales/en/header.ts b/apps/kimi-web/src/i18n/locales/en/header.ts index bf046bee4..4273de01d 100644 --- a/apps/kimi-web/src/i18n/locales/en/header.ts +++ b/apps/kimi-web/src/i18n/locales/en/header.ts @@ -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', diff --git a/apps/kimi-web/src/i18n/locales/en/sidebar.ts b/apps/kimi-web/src/i18n/locales/en/sidebar.ts index 3bb705ca3..c33e42d01 100644 --- a/apps/kimi-web/src/i18n/locales/en/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/en/sidebar.ts @@ -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; diff --git a/apps/kimi-web/src/i18n/locales/en/status.ts b/apps/kimi-web/src/i18n/locales/en/status.ts index fc4ca8cc4..8b7f02278 100644 --- a/apps/kimi-web/src/i18n/locales/en/status.ts +++ b/apps/kimi-web/src/i18n/locales/en/status.ts @@ -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 diff --git a/apps/kimi-web/src/i18n/locales/en/tools.ts b/apps/kimi-web/src/i18n/locales/en/tools.ts index aaddeb334..13cc18211 100644 --- a/apps/kimi-web/src/i18n/locales/en/tools.ts +++ b/apps/kimi-web/src/i18n/locales/en/tools.ts @@ -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', diff --git a/apps/kimi-web/src/i18n/locales/zh/composer.ts b/apps/kimi-web/src/i18n/locales/zh/composer.ts index 6c1ec1926..719f7adb7 100644 --- a/apps/kimi-web/src/i18n/locales/zh/composer.ts +++ b/apps/kimi-web/src/i18n/locales/zh/composer.ts @@ -22,7 +22,7 @@ export default { emptyConversationTitle: 'Kimi Code', emptyConversation: '还没有消息 —— 在下方输入开始对话', quickStartPlaceholder: '输入消息开始新对话…', - thinkingSuffix: ' · thinking', - thinkingSuffixEffort: ' · thinking: {level}', + thinkingSuffix: ' · 思考', + thinkingSuffixEffort: ' · 思考: {level}', } as const; diff --git a/apps/kimi-web/src/i18n/locales/zh/header.ts b/apps/kimi-web/src/i18n/locales/zh/header.ts index 543cece6c..687845609 100644 --- a/apps/kimi-web/src/i18n/locales/zh/header.ts +++ b/apps/kimi-web/src/i18n/locales/zh/header.ts @@ -10,6 +10,11 @@ export default { gitTooltip: '打开「文件 > 改动」', detached: '游离', openPr: '打开 Pull Request', + prStatusOpen: '已打开', + prStatusClosed: '已关闭', + prStatusMerged: '已合并', + prStatusDraft: '草稿', + prStatusUnknown: '未知', options: '选项', copySessionId: '复制 Session ID', renameSession: '重命名', diff --git a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts index bea56f556..f252dbb03 100644 --- a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts @@ -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: '没有匹配的会话', }; diff --git a/apps/kimi-web/src/i18n/locales/zh/status.ts b/apps/kimi-web/src/i18n/locales/zh/status.ts index 98287e9d5..0256b119b 100644 --- a/apps/kimi-web/src/i18n/locales/zh/status.ts +++ b/apps/kimi-web/src/i18n/locales/zh/status.ts @@ -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: '更多模型…', // 状态面板 diff --git a/apps/kimi-web/src/i18n/locales/zh/tools.ts b/apps/kimi-web/src/i18n/locales/zh/tools.ts index 0cee01058..f9560e3da 100644 --- a/apps/kimi-web/src/i18n/locales/zh/tools.ts +++ b/apps/kimi-web/src/i18n/locales/zh/tools.ts @@ -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: '运行中', diff --git a/apps/kimi-web/src/icons/kimi/add-conversation.svg b/apps/kimi-web/src/icons/kimi/add-conversation.svg new file mode 100644 index 000000000..77bad01e5 --- /dev/null +++ b/apps/kimi-web/src/icons/kimi/add-conversation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/kimi-web/src/icons/kimi/folder-open.svg b/apps/kimi-web/src/icons/kimi/folder-open.svg new file mode 100644 index 000000000..1c843b49b --- /dev/null +++ b/apps/kimi-web/src/icons/kimi/folder-open.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/kimi-web/src/icons/kimi/folder.svg b/apps/kimi-web/src/icons/kimi/folder.svg new file mode 100644 index 000000000..db95c0951 --- /dev/null +++ b/apps/kimi-web/src/icons/kimi/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/kimi-web/src/icons/kimi/more.svg b/apps/kimi-web/src/icons/kimi/more.svg new file mode 100644 index 000000000..5ed94f777 --- /dev/null +++ b/apps/kimi-web/src/icons/kimi/more.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/kimi-web/src/icons/kimi/search.svg b/apps/kimi-web/src/icons/kimi/search.svg new file mode 100644 index 000000000..82dd01aab --- /dev/null +++ b/apps/kimi-web/src/icons/kimi/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/kimi-web/src/icons/kimi/setting.svg b/apps/kimi-web/src/icons/kimi/setting.svg new file mode 100644 index 000000000..3215d2556 --- /dev/null +++ b/apps/kimi-web/src/icons/kimi/setting.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/kimi-web/src/lib/icons.ts b/apps/kimi-web/src/lib/icons.ts index e41f0b1e4..6e2b43d08 100644 --- a/apps/kimi-web/src/lib/icons.ts +++ b/apps/kimi-web/src/lib/icons.ts @@ -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 ) 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 ) +// 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 = { 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 = { 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 = { '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 = { '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 'folder-solid', 'file', 'file-text', + 'file-edit', 'file-plus', 'file-off', 'image-off', @@ -365,6 +395,7 @@ export const ICON_GROUPS: ReadonlyArray 'check-list', 'bolt', 'git-pull-request', + 'target', 'calendar-schedule', 'calendar-todo', 'calendar-close', @@ -379,6 +410,7 @@ export const ICON_GROUPS: ReadonlyArray 'alert-triangle', 'clock', 'sparkles', + 'pause', 'play', 'stop', 'star', diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index 36dab4c61..5cd60760c 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -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): 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(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 diff --git a/apps/kimi-web/src/lib/toolMeta.ts b/apps/kimi-web/src/lib/toolMeta.ts index 6ecfd4c00..a2b011132 100644 --- a/apps/kimi-web/src/lib/toolMeta.ts +++ b/apps/kimi-web/src/lib/toolMeta.ts @@ -25,6 +25,10 @@ const TOOL_LABEL_KEYS: Record = { 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 = { 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 = { 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 | undefined { return str(d.path) ?? str(d.file_path) ?? str(d.filePath) ?? str(d.filename); } +const GOAL_STATUS_KEYS: Record = { + 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 | 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(); } diff --git a/apps/kimi-web/src/main.ts b/apps/kimi-web/src/main.ts index d546f7ae6..55475a234 100644 --- a/apps/kimi-web/src/main.ts +++ b/apps/kimi-web/src/main.ts @@ -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'; diff --git a/apps/kimi-web/src/style.css b/apps/kimi-web/src/style.css index dda829b72..4d22d3e6a 100644 --- a/apps/kimi-web/src/style.css +++ b/apps/kimi-web/src/style.css @@ -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; diff --git a/apps/kimi-web/src/views/DesignSystemView.vue b/apps/kimi-web/src/views/DesignSystemView.vue index 7053cfce0..a67af2a43 100644 --- a/apps/kimi-web/src/views/DesignSystemView.vue +++ b/apps/kimi-web/src/views/DesignSystemView.vue @@ -176,6 +176,7 @@ onUnmounted(() => {
bg
#ffffff / #0d1117
surface
#fafbfc / #161b22
surface-sunken
#f3f5f8 / #0d1117
+
selected
#eceff3 / #2d333b
fg
#14171c / #e8eaed
fg-muted
#6b7280 / #9aa0a8
line
#e7eaee / #2d333b
@@ -191,6 +192,9 @@ onUnmounted(() => { --color-text#14171c#e8eaedBody text / headings --color-text-muted#6b7280#9aa0a8Secondary text / placeholder --color-line#e7eaee#2d333bDivider / card border + --color-selected#00000014#ffffff14Neutral selected fill (sidebar rows, list pickers) — translucent, never accent-tinted + --color-hover#0000000d#ffffff0dRow hover wash — lighter than the selected fill (hover < selected); translucent, sits on any surface + --color-sidebar-bg#fbfaf9#181817Sidebar surface — one step off --color-bg so the session column reads as its own plane --color-accent#1783ff#58a6ffPrimary action / link / focus --color-success#0e7a38#3fb950Success / pass --color-warning#a9610a#d29922Warning / pending @@ -227,18 +231,19 @@ onUnmounted(() => {

All disabled controls use opacity:.5 + cursor:not-allowed uniformly; do not separately grey out or recolor.

Font families

-

Kimi Web uses two font families: --font-ui (UI and body, system fonts first) and --font-mono (code and monospace). Components always reference the variables; do not hard-code font names.

+

Kimi Web uses two font families: --font-ui (UI and body, Inter first) and --font-mono (code and monospace). Components always reference the variables; do not hard-code font names.

-

--font-ui · UI & body (system fonts first)

-

Body and UI use each platform's native UI font — close to the system feel, comfortable for long text and CJK. Fallback chain:

-
--font-ui
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI",
+            

--font-ui · UI & body (Inter first)

+

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:

+
--font-ui
--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";
    -
  • System UI fonts first: SF Pro on macOS / iOS, Segoe UI on Windows.
  • -
  • CJK next: PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans SC (Linux).
  • -
  • Helvetica Neue / Arial / sans-serif as generic fallbacks; emoji fonts at the end.
  • +
  • Inter first: self-hosted Latin UI and body text, loaded through the optical-size normal and italic variable faces.
  • +
  • Western fallbacks next: Helvetica Neue / Arial for environments where Inter cannot load.
  • +
  • CJK and system UI fallbacks late: PingFang SC / Microsoft YaHei / Noto Sans SC, then platform UI fonts and emoji fonts.

--font-mono · Code & monospace

@@ -251,8 +256,8 @@ onUnmounted(() => { FontSourceBundledUsage JetBrains Mono@fontsource-variable/jetbrains-mono✓ self-hostedmonospace / code (--font-mono) - Inter@fontsource-variable/inter✓ self-hostedoptional: page titles / brand wordmark (--font-display) - System UI / CJK fontsoperating system—body / UI (--font-ui), not bundled + Inter@fontsource-variable/inter/opsz.css + opsz-italic.css✓ self-hostedUI / body / display (--font-ui, --font-display), wght 100-900, opsz 14-32, normal + italic + System UI / CJK fontsoperating system—late fallback for UI / body, not bundled
@@ -262,12 +267,13 @@ onUnmounted(() => {

Usage rules

  • Components always use var(--font-ui) / var(--font-mono); do not hard-code font names like 'Inter' / 'JetBrains Mono'.
  • -
  • Body / UI use --font-ui (system fonts first); code / monospace use --font-mono (JetBrains Mono).
  • -
  • Inter is used only for headings / brand scenarios that need a unified look (optional --font-display); it is no longer the body default.
  • +
  • Body / UI use --font-ui (Inter first); code / monospace use --font-mono (JetBrains Mono).
  • +
  • Inter is loaded from the complete optical-size variable faces, including normal and italic styles; font-optical-sizing: auto is enabled globally.
  • +
  • CJK and platform system UI fonts stay late in the --font-ui fallback chain, after Inter and Western fallbacks.

Type scale & weight

-

The user font-size preference writes --base-ui-font-size. Compact UI chrome follows it through --ui-font-size, while chat reading surfaces and the sidebar derive one readable step above it through --content-font-size and --sidebar-ui-font-size.

+

The user font-size preference writes --base-ui-font-size. Compact UI chrome and the sidebar follow it through --ui-font-size, while chat reading surfaces derive one readable step above it through --content-font-size.

The fixed product type tokens still define component defaults: UI controls / buttons / forms use --text-base (14px); reading body — including chat Markdown, message bubbles, etc. stays one step larger than compact chrome for readability; the sidebar session list follows that same readable step while keeping list density. Drop stray font-weight: 650 / 750; converge on two weights, 400 / 500 (regular / emphasis).

@@ -281,11 +287,10 @@ onUnmounted(() => { - + - @@ -565,6 +570,18 @@ onUnmounted(() => { + +

Kbd · keyboard shortcut

+

Kbd renders a shortcut as keycaps — one block per key, never inline text like (⌘K). 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).

+
+
Kbd · keycaps
+
+ K + CtrlK + P +
+
+

Card / Surface

All cards across the site share one shell: flat, 1px border, --radius-md radius, no shadow. The structure is split into three parts — head / body / foot. Cards differ only in the head — in two tiers by visual weight, while the shell stays consistent:

@@ -1336,13 +1353,13 @@ onUnmounted(() => {

Layout grid

-

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.

-
App.vue · .app
grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
-    /*           sidebar ↑      ↑handle  ↑conversation  ↑handle ↑right panel (auto) */
+

On desktop it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent auto track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles.

+
App.vue · .app
grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
+    /*         sidebar ↑    ↑handle  ↑conversation  ↑handle ↑right panel (auto) */
TokenValueUsage
--font-ui-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC"…UI & body (system fonts first)
--font-ui"Inter Variable", "Inter", "Helvetica Neue", Arial…UI & body (Inter first)
--font-monoJetBrains Mono…code, tool names, line numbers, diffs
--base-ui-font-size14px user preferenceroot setting that drives UI, reading body, and sidebar font sizes
--content-font-sizecalc(base + 1px)chat Markdown, message bubbles, composer
--sidebar-ui-font-sizecalc(base + 1px)sidebar brand, search, workspace and session rows
--leading-tight/normal/relaxed1.25 / 1.5 / 1.7headings / UI / long text
--weight-regular/medium400 / 500body / emphasis
- + @@ -1350,17 +1367,18 @@ onUnmounted(() => {
TokenValueUsage
--side-w248px (adjustable)left conversation column width, changed by dragging the ResizeHandle; should approach §02's --p-sidebar-w (264px)
sidebar width270px default (adjustable)expanded sidebar width, changed by dragging the ResizeHandle; should approach §02's --p-sidebar-w (264px)
--preview-w460pxwidth of the right preview panel when open
--panel-head-h48pxunified height for all right panel heads + the conversation column head, so the hairline runs as one line
--p-bp-sm640px≤640 switches to a mobile single column (top bar + conversation), no sidebar / handle / right panel
  • The right panel track exists permanently, with its width transitioning between 0 ↔ var(--preview-w) (when open it squeezes the conversation column, rather than switching templates).
  • -
  • When the sidebar is collapsed, track 1 becomes a thin "rail" holding only an expand IconButton, avoiding crushing the conversation column head.
  • +
  • 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 macOS desktop 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 Windows / web 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.
  • All grid children must have min-height:0; min-width:0, so only the inner scroll containers scroll and the page itself does not scroll.

Sidebar alignment system (--sb-*)

-

All sidebar rows (group head, session row, New chat button) share 3 custom properties, so the "session title" aligns precisely under the "workspace name".

+

All sidebar rows (group head, session row, New chat button) share 4 custom properties, so the "session title" aligns precisely under the "workspace name".

- - + + +
TokenValueUsage
--sb-pad-x16pxrow horizontal padding
--sb-gutter20pxleading icon slot width (14px icon + 6px whitespace)
--sb-inset12pxrow box (hover/selected pill) inset from the sidebar edges — matches the brand header's 12px padding
--sb-pad-x20pxcontent start x (= --sb-inset + 8px row padding)
--sb-gutter16pxleading icon slot width — matches the workspace folder icon so the session title aligns under the workspace name
--sb-gap6pxgap between the icon slot and the text
@@ -1369,15 +1387,16 @@ onUnmounted(() => {

Sidebar structure

-

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.

+

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 --color-sidebar-bg (one step off --color-bg: 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 --sb-hover (= the global --color-hover wash); the selected row uses --color-selected — neutral, never the accent.

- - - + + + +
BlockUseNote
Brand headerlogo + name + IconButtoncollapse / settings use IconButton sm; the logo is animated (a blinking eye)
Searchbare search row (custom)no border, hover/focus shows a sunken background; icon + input + clear IconButton. Do not use Input (the 38px bordered version is too heavy)
New chatfull-width left-aligned button (custom)same rhythm as the session rows in the list (left-aligned, hover sunken). Do not use Button (centered, breaks the rhythm)
Brand headerlogo + name + collapse IconButton (right-aligned)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)
New chatfull-width left-aligned button (custom)same rhythm as the session rows in the list (left-aligned, hover = --sb-hover). Do not use Button (centered, breaks the rhythm)
Searchbare search row (custom)no border, hover/focus shows a sunken background; icon + label, with the Kbd keycaps (⌘K / Ctrl K) pushed to the trailing edge — label and shortcut are justified apart. Do not use Input (the 38px bordered version is too heavy). Last fixed row above the list — its wrapper carries the scroll-linked seam
Section label.p-section-labeluppercase muted small titles like "Workspaces"
Workspace head / session rowsee next two sectionsshare --sb-* alignment
Settings footerfull-width left-aligned button (custom)pinned row under the session list, separated by a 1px --line top border; icon + label, same list-style family as New chat
!
@@ -1389,7 +1408,7 @@ onUnmounted(() => { - + @@ -1400,11 +1419,11 @@ onUnmounted(() => {
PartRule
Containermargin: 1px 6px; padding: 7px 10px; radius-md; hover = surface-sunken; active = accent-soft + inset 0 0 0 1px accent-bd
Containerpadding: 8px 8px inside the list's --sb-inset gutter, radius-sm; no fixed/min height — row height is font-driven (title line-height: --leading-tight, ≈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 = --sb-hover (the global --color-hover wash); active = --color-selected — neutral, no accent tint, no border, no weight change
Status slot (lead)fixed --sb-gutter width; running = Spinner sm, otherwise unread = 7px accent dot
Titleflex:1 with truncation; double-click enters inline rename (compact input, not Input)
Timemono xs, fg-faint; yields to the kebab on hover

Workspace group

-

The group head and session rows share --sb-*: folder icon (open/closed) → name → path subtitle, with the kebab and "+" revealed on hover.

+

The group head and session rows share --sb-*: folder icon (open/closed) → name, with the kebab and "+" revealed on hover.

    -
  • The folder icon sits in the --sb-gutter slot, switching icons between open and closed states.
  • -
  • A small fg-muted path line sits below the name.
  • -
  • The kebab (menu) and "+" (new chat in this workspace) both use IconButton sm, shown on hover or keyboard focus (when not hovered they stay in the tab order via opacity:0, keeping them keyboard-reachable).
  • +
  • The folder icon leads the row (switching icons between open and closed states) with the plain --sb-gap before the name — it does not pad out the --sb-gutter slot.
  • +
  • The name is quiet by design — regular weight, muted color (--color-text-muted, 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 Tooltip.
  • +
  • The kebab (menu) and "+" (new chat in this workspace) both use IconButton 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 ::after 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 opacity:0, staying in the tab order).
  • The group is collapsible; when collapsed its session list is hidden.
@@ -1413,7 +1432,7 @@ onUnmounted(() => { - + @@ -1442,7 +1461,7 @@ onUnmounted(() => {
i
- One-sentence principle: 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. + One-sentence principle: 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.
@@ -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 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; diff --git a/apps/kimi-web/vite.config.ts b/apps/kimi-web/vite.config.ts index d72cf22f3..28e5ecfd6 100644 --- a/apps/kimi-web/vite.config.ts +++ b/apps/kimi-web/vite.config.ts @@ -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/` (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. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index f7e893555..303886e36 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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
PartRule
Containersession-row pill: display:flex; gap:--sb-gap; min-height:26px, same padding as a session row, radius-md; hover = surface-sunken (no text recolor); :focus-visible uses --p-focus-ring
Containersession-row pill: display:flex; gap:--sb-gap; padding:8px …, no fixed/min height (font-driven, ≈32px like a session row), same padding as a session row, radius-sm; hover = --sb-hover (no text recolor); :focus-visible uses --p-focus-ring
Lead slotempty, --sb-gutter wide, so the label's start x aligns with the session titles (--sb-pad-x + --sb-gutter + --sb-gap)
Labelfont-ui, text-xs, --color-text; flex:1, truncated
Behavior"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
` | — | 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 ""`), 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`. +