mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
Compare commits
19 commits
@moonshot-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ad568436a | ||
|
|
5a90d9d7f1 | ||
|
|
7ce3c2dc31 | ||
|
|
9f9324cdab | ||
|
|
ec8dc3456c | ||
|
|
046b6c4175 | ||
|
|
a3548035a8 | ||
|
|
170ae44205 | ||
|
|
1bf2c9afee | ||
|
|
b91099ed7a | ||
|
|
fe9479d89a | ||
|
|
173bdfdab1 | ||
|
|
9fb19154ac | ||
|
|
ad30a1c632 | ||
|
|
735922c291 | ||
|
|
b89fc1a4fb | ||
|
|
93c0b7bb78 | ||
|
|
e83511a711 | ||
|
|
2394d013bc |
143 changed files with 5352 additions and 977 deletions
|
|
@ -148,8 +148,8 @@ The docs changelog uses five section types:
|
||||||
| English section | Chinese section | Meaning |
|
| English section | Chinese section | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before |
|
| `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before |
|
||||||
| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken |
|
|
||||||
| `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities |
|
| `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities |
|
||||||
|
| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken |
|
||||||
| `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames |
|
| `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames |
|
||||||
| `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts |
|
| `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts |
|
||||||
|
|
||||||
|
|
@ -176,7 +176,7 @@ Keyword hints:
|
||||||
Within each version, section order is:
|
Within each version, section order is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Features → Bug Fixes → Polish → Refactors → Other
|
Features → Polish → Bug Fixes → Refactors → Other
|
||||||
```
|
```
|
||||||
|
|
||||||
Omit empty sections. Within each section, order entries by reader value, not upstream order:
|
Omit empty sections. Within each section, order entries by reader value, not upstream order:
|
||||||
|
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -37,7 +37,7 @@ jobs:
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
- name: Upgrade npm for Trusted Publishing
|
- name: Upgrade npm for Trusted Publishing
|
||||||
run: npm install -g npm@latest
|
run: npm install -g npm@11
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,35 @@
|
||||||
# @moonshot-ai/kimi-code
|
# @moonshot-ai/kimi-code
|
||||||
|
|
||||||
|
## 0.23.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#1501](https://github.com/MoonshotAI/kimi-code/pull/1501) [`b91099e`](https://github.com/MoonshotAI/kimi-code/commit/b91099ed7a2590d1afa4d6e3675671da52b7661c) Thanks [@liruifengv](https://github.com/liruifengv)! - Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands.
|
||||||
|
|
||||||
|
- [#1517](https://github.com/MoonshotAI/kimi-code/pull/1517) [`173bdfd`](https://github.com/MoonshotAI/kimi-code/commit/173bdfdab1f484ed79927aeaac7dc8116d3fd346) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix resuming sessions whose original working directory no longer exists.
|
||||||
|
|
||||||
|
- [#1516](https://github.com/MoonshotAI/kimi-code/pull/1516) [`9fb1915`](https://github.com/MoonshotAI/kimi-code/commit/9fb19154accf6b6f7abfbf7a9820ccda517bc87e) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts.
|
||||||
|
|
||||||
|
- [#1508](https://github.com/MoonshotAI/kimi-code/pull/1508) [`1bf2c9a`](https://github.com/MoonshotAI/kimi-code/commit/1bf2c9afee4643fbf6755f0b92fd60aa14240501) Thanks [@RealKai42](https://github.com/RealKai42)! - 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.
|
||||||
|
|
||||||
|
- [#1519](https://github.com/MoonshotAI/kimi-code/pull/1519) [`170ae44`](https://github.com/MoonshotAI/kimi-code/commit/170ae4420526b6592d696cd597d1693dbd1a660b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Polish the session sidebar layout, colors, icons, and typography.
|
||||||
|
|
||||||
|
- [#1521](https://github.com/MoonshotAI/kimi-code/pull/1521) [`046b6c4`](https://github.com/MoonshotAI/kimi-code/commit/046b6c417581792933732c7ffe154e120c96171d) Thanks [@RealKai42](https://github.com/RealKai42)! - The `[image]` limits in config.toml now also apply to pasted images (CLI paste and ACP prompts), and each core now uses its own settings, so reloading one client's config no longer changes another client's image compression.
|
||||||
|
|
||||||
|
- [#1494](https://github.com/MoonshotAI/kimi-code/pull/1494) [`a354803`](https://github.com/MoonshotAI/kimi-code/commit/a3548035a8b6d25df9a11daab37a21daee1ef73f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add a Kimi WebBridge entry to the Official tab of the /plugins panel that opens the WebBridge install page in your browser.
|
||||||
|
|
||||||
|
- [#1479](https://github.com/MoonshotAI/kimi-code/pull/1479) [`735922c`](https://github.com/MoonshotAI/kimi-code/commit/735922c291ec3d32d60da6af053f75e1c6179f92) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Add notifications when a tool needs approval, and improve notification reliability.
|
||||||
|
|
||||||
|
- [#1522](https://github.com/MoonshotAI/kimi-code/pull/1522) [`ec8dc34`](https://github.com/MoonshotAI/kimi-code/commit/ec8dc3456c1696a5eba6c37b6e26ef99837c35e2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent.
|
||||||
|
|
||||||
|
- [#1502](https://github.com/MoonshotAI/kimi-code/pull/1502) [`ad30a1c`](https://github.com/MoonshotAI/kimi-code/commit/ad30a1c6328327729221f9f5fc700b621dfef779) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling.
|
||||||
|
|
||||||
|
## 0.23.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account.
|
||||||
|
|
||||||
## 0.23.2
|
## 0.23.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@moonshot-ai/kimi-code",
|
"name": "@moonshot-ai/kimi-code",
|
||||||
"version": "0.23.2",
|
"version": "0.23.4",
|
||||||
"description": "The Starting Point for Next-Gen Agents",
|
"description": "The Starting Point for Next-Gen Agents",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Moonshot AI",
|
"author": "Moonshot AI",
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/;
|
||||||
* Parses a headless prompt into a goal-create request, or `undefined` when the
|
* 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 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
|
* 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 {
|
export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined {
|
||||||
const trimmed = prompt.trim();
|
const trimmed = prompt.trim();
|
||||||
if (!GOAL_PREFIX.test(trimmed)) return undefined;
|
if (!GOAL_PREFIX.test(trimmed)) return undefined;
|
||||||
const args = trimmed.replace(/^\/goal/, '').trim();
|
const args = trimmed.replace(/^\/goal/, '').trim();
|
||||||
const parsed = parseGoalCommand(args);
|
const parsed = parseGoalCommand(args);
|
||||||
|
if (parsed.kind === 'error') {
|
||||||
|
throw new Error(parsed.message);
|
||||||
|
}
|
||||||
if (parsed.kind !== 'create') return undefined;
|
if (parsed.kind !== 'create') return undefined;
|
||||||
return { objective: parsed.objective, replace: parsed.replace };
|
return { objective: parsed.objective, replace: parsed.replace };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ async function runHeadlessGoal(
|
||||||
try {
|
try {
|
||||||
// The objective is sent as the normal prompt; goal continuation keeps the
|
// The objective is sent as the normal prompt; goal continuation keeps the
|
||||||
// turn alive until a terminal state is reached.
|
// 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 {
|
} finally {
|
||||||
unsubscribeGoalEvents();
|
unsubscribeGoalEvents();
|
||||||
const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
|
const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
|
||||||
|
|
@ -432,9 +432,11 @@ function runPromptTurn(
|
||||||
outputFormat: PromptOutputFormat,
|
outputFormat: PromptOutputFormat,
|
||||||
stdout: PromptOutput,
|
stdout: PromptOutput,
|
||||||
stderr: PromptOutput,
|
stderr: PromptOutput,
|
||||||
|
waitForGoalTerminal = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let activeTurnId: number | undefined;
|
let activeTurnId: number | undefined;
|
||||||
let activeAgentId: string | undefined;
|
let activeAgentId: string | undefined;
|
||||||
|
let latestStartedTurnId: number | undefined;
|
||||||
const outputWriter =
|
const outputWriter =
|
||||||
outputFormat === 'stream-json'
|
outputFormat === 'stream-json'
|
||||||
? new PromptJsonWriter(stdout)
|
? new PromptJsonWriter(stdout)
|
||||||
|
|
@ -469,6 +471,18 @@ function runPromptTurn(
|
||||||
}
|
}
|
||||||
activeTurnId = event.turnId;
|
activeTurnId = event.turnId;
|
||||||
activeAgentId = event.agentId;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -515,19 +529,29 @@ function runPromptTurn(
|
||||||
return;
|
return;
|
||||||
case 'turn.ended':
|
case 'turn.ended':
|
||||||
if (event.reason === 'completed') {
|
if (event.reason === 'completed') {
|
||||||
void (async () => {
|
outputWriter.flushAssistant();
|
||||||
// Flush the buffered assistant message before draining background
|
if (waitForGoalTerminal) {
|
||||||
// tasks: in stream-json mode the final message is only emitted by
|
const completedTurnId = event.turnId;
|
||||||
// finish(), so a long background wait would otherwise withhold the
|
activeTurnId = undefined;
|
||||||
// main turn's result until the drain settles.
|
activeAgentId = undefined;
|
||||||
outputWriter.flushAssistant();
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await session.waitForBackgroundTasksOnPrint();
|
const { goal } = await session.getGoal();
|
||||||
} catch (error) {
|
if (
|
||||||
log.warn('waitForBackgroundTasksOnPrint failed', { error });
|
activeTurnId !== undefined ||
|
||||||
}
|
latestStartedTurnId !== completedTurnId
|
||||||
finish();
|
) {
|
||||||
})();
|
return;
|
||||||
|
}
|
||||||
|
if (goal?.status === 'active') return;
|
||||||
|
await finishCompletedTurn();
|
||||||
|
} catch (error) {
|
||||||
|
finish(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void finishCompletedTurn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finish(new Error(formatTurnEndedFailure(event)));
|
finish(new Error(formatTurnEndedFailure(event)));
|
||||||
|
|
@ -560,6 +584,20 @@ function runPromptTurn(
|
||||||
session.prompt(prompt).catch((error: unknown) => {
|
session.prompt(prompt).catch((error: unknown) => {
|
||||||
finish(error instanceof Error ? error : new Error(String(error)));
|
finish(error instanceof Error ? error : new Error(String(error)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function finishCompletedTurn(): Promise<void> {
|
||||||
|
// Flush the buffered assistant message before draining background tasks:
|
||||||
|
// in stream-json mode the final message is only emitted by finish(), so a
|
||||||
|
// long background wait would otherwise withhold the main turn's result
|
||||||
|
// until the drain settles.
|
||||||
|
outputWriter.flushAssistant();
|
||||||
|
try {
|
||||||
|
await session.waitForBackgroundTasksOnPrint();
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('waitForBackgroundTasksOnPrint failed', { error });
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -610,7 +648,9 @@ class PromptTranscriptWriter implements PromptTurnWriter {
|
||||||
|
|
||||||
writeToolResult(): void {}
|
writeToolResult(): void {}
|
||||||
|
|
||||||
flushAssistant(): void {}
|
flushAssistant(): void {
|
||||||
|
this.assistantWriter.finish();
|
||||||
|
}
|
||||||
|
|
||||||
discardAssistant(): void {}
|
discardAssistant(): void {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUs
|
||||||
if (res.kind === 'error') {
|
if (res.kind === 'error') {
|
||||||
return { error: res.message };
|
return { error: res.message };
|
||||||
}
|
}
|
||||||
return { usage: { summary: res.summary, limits: res.limits } };
|
return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { UsagePanelComponent } from '../components/messages/usage-panel';
|
||||||
import { formatErrorMessage } from '../utils/event-payload';
|
import { formatErrorMessage } from '../utils/event-payload';
|
||||||
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
|
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
|
||||||
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
|
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
|
||||||
|
import { openUrl } from '#/utils/open-url';
|
||||||
import type { SlashCommandHost } from './dispatch';
|
import type { SlashCommandHost } from './dispatch';
|
||||||
|
|
||||||
interface ShowPluginsPickerOptions {
|
interface ShowPluginsPickerOptions {
|
||||||
|
|
@ -411,6 +412,12 @@ async function handlePluginsPanelSelection(
|
||||||
isOfficialPluginSource(selection.source),
|
isOfficialPluginSource(selection.source),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
case 'open-url':
|
||||||
|
host.restoreEditor();
|
||||||
|
openUrl(selection.url);
|
||||||
|
host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success');
|
||||||
|
host.showStatus(`If it did not open, visit ${selection.url}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,27 @@ const INSTALL_TRUST_EXIT = 'exit';
|
||||||
const INSTALL_TRUST_TRUST = 'trust';
|
const INSTALL_TRUST_TRUST = 'trust';
|
||||||
const ELLIPSIS = '…';
|
const ELLIPSIS = '…';
|
||||||
|
|
||||||
|
// Hardcoded Web Bridge promotion: a built-in entry that always leads the
|
||||||
|
// Official tab, even when the marketplace catalog is unavailable. Selecting it
|
||||||
|
// opens the install page in the browser rather than installing from a source,
|
||||||
|
// because Web Bridge is a browser extension + daemon, not a plugin package.
|
||||||
|
const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge';
|
||||||
|
const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = {
|
||||||
|
id: 'kimi-webbridge',
|
||||||
|
displayName: 'Kimi WebBridge',
|
||||||
|
source: WEB_BRIDGE_URL,
|
||||||
|
tier: 'official',
|
||||||
|
homepage: WEB_BRIDGE_URL,
|
||||||
|
description: 'Control your real browser from Kimi Code — navigate, click, type, and screenshot',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only the hardcoded pinned row should open the WebBridge install page. Match
|
||||||
|
// by reference (not id) so a catalog entry on another tab that happens to
|
||||||
|
// reuse the same id still installs normally instead of being hijacked.
|
||||||
|
function isPinnedWebBridgeEntry(entry: PluginMarketplaceEntry): boolean {
|
||||||
|
return entry === WEB_BRIDGE_ENTRY;
|
||||||
|
}
|
||||||
|
|
||||||
interface PluginsOverviewItem {
|
interface PluginsOverviewItem {
|
||||||
readonly value: string;
|
readonly value: string;
|
||||||
readonly kind: 'plugin' | 'action';
|
readonly kind: 'plugin' | 'action';
|
||||||
|
|
@ -304,7 +325,8 @@ export type PluginsPanelSelection =
|
||||||
| { readonly kind: 'details'; readonly id: string }
|
| { readonly kind: 'details'; readonly id: string }
|
||||||
| { readonly kind: 'reload' }
|
| { readonly kind: 'reload' }
|
||||||
| { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry }
|
| { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry }
|
||||||
| { readonly kind: 'install-source'; readonly source: string };
|
| { readonly kind: 'install-source'; readonly source: string }
|
||||||
|
| { readonly kind: 'open-url'; readonly url: string; readonly label: string };
|
||||||
|
|
||||||
export interface PluginsPanelOptions {
|
export interface PluginsPanelOptions {
|
||||||
readonly installed: readonly PluginSummary[];
|
readonly installed: readonly PluginSummary[];
|
||||||
|
|
@ -402,7 +424,19 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private get officialEntries(): readonly PluginMarketplaceEntry[] {
|
private get officialEntries(): readonly PluginMarketplaceEntry[] {
|
||||||
return this.marketplaceEntries.filter((entry) => entry.tier === 'official');
|
// The hardcoded Web Bridge entry always leads the Official tab, even when
|
||||||
|
// the catalog is loading or unreachable. Dedupe by id so a catalog that
|
||||||
|
// also lists it does not render a second row.
|
||||||
|
return [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries];
|
||||||
|
}
|
||||||
|
|
||||||
|
private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] {
|
||||||
|
// Dedupe by id (not reference): if the official catalog also lists
|
||||||
|
// kimi-webbridge, the pinned row already represents it, so suppress the
|
||||||
|
// catalog copy to avoid a duplicate row on the Official tab.
|
||||||
|
return this.marketplaceEntries.filter(
|
||||||
|
(entry) => entry.tier === 'official' && entry.id !== WEB_BRIDGE_ENTRY.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] {
|
private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] {
|
||||||
|
|
@ -516,6 +550,10 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
||||||
if (matchesKey(data, Key.enter)) {
|
if (matchesKey(data, Key.enter)) {
|
||||||
const entry = entries[this.selectedIndex];
|
const entry = entries[this.selectedIndex];
|
||||||
if (entry === undefined) return;
|
if (entry === undefined) return;
|
||||||
|
if (isPinnedWebBridgeEntry(entry)) {
|
||||||
|
this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.opts.onSelect({ kind: 'install', entry });
|
this.opts.onSelect({ kind: 'install', entry });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -622,6 +660,7 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
||||||
lines: string[],
|
lines: string[],
|
||||||
width: number,
|
width: number,
|
||||||
entries: readonly PluginMarketplaceEntry[],
|
entries: readonly PluginMarketplaceEntry[],
|
||||||
|
indexOffset = 0,
|
||||||
): void {
|
): void {
|
||||||
const colors = currentTheme.palette;
|
const colors = currentTheme.palette;
|
||||||
if (this.market.status === 'loading' || this.market.status === 'idle') {
|
if (this.market.status === 'loading' || this.market.status === 'idle') {
|
||||||
|
|
@ -637,7 +676,7 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
||||||
lines.push(chalk.hex(colors.textMuted)(' No plugins found.'));
|
lines.push(chalk.hex(colors.textMuted)(' No plugins found.'));
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < entries.length; i++) {
|
for (let i = 0; i < entries.length; i++) {
|
||||||
lines.push(...this.renderMarketplaceRow(entries[i]!, i, width));
|
lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length;
|
const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length;
|
||||||
|
|
@ -649,7 +688,11 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderOfficial(lines: string[], width: number): void {
|
private renderOfficial(lines: string[], width: number): void {
|
||||||
this.renderMarketplaceTab(lines, width, this.officialEntries);
|
// Web Bridge is pinned above the catalog and stays visible while the
|
||||||
|
// catalog loads or errors, since it's built into the TUI rather than
|
||||||
|
// fetched. Catalog rows shift down by one index to match.
|
||||||
|
lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width));
|
||||||
|
this.renderMarketplaceTab(lines, width, this.officialCatalogEntries, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderThirdParty(lines: string[], width: number): void {
|
private renderThirdParty(lines: string[], width: number): void {
|
||||||
|
|
@ -662,7 +705,9 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
||||||
const pointer = selected ? SELECT_POINTER : ' ';
|
const pointer = selected ? SELECT_POINTER : ' ';
|
||||||
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
|
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
|
||||||
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
|
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
|
||||||
const status = marketplaceEntryStatus(entry, this.installedVersions);
|
const status = isPinnedWebBridgeEntry(entry)
|
||||||
|
? 'open in browser'
|
||||||
|
: marketplaceEntryStatus(entry, this.installedVersions);
|
||||||
const line =
|
const line =
|
||||||
prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status);
|
prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status);
|
||||||
const descWidth = Math.max(1, width - 4);
|
const descWidth = Math.max(1, width - 4);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@ import {
|
||||||
safeUsageRatio,
|
safeUsageRatio,
|
||||||
} from '#/utils/usage/usage-format';
|
} from '#/utils/usage/usage-format';
|
||||||
|
|
||||||
import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel';
|
import {
|
||||||
|
buildExtraUsageSection,
|
||||||
|
buildManagedUsageReportLines,
|
||||||
|
type ManagedUsageReport,
|
||||||
|
} from './usage-panel';
|
||||||
|
|
||||||
interface FieldRow {
|
interface FieldRow {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
|
@ -145,5 +149,16 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] {
|
||||||
lines.push(...managedSection);
|
lines.push(...managedSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extraSection = buildExtraUsageSection(
|
||||||
|
options.managedUsage?.extraUsage,
|
||||||
|
accent,
|
||||||
|
value,
|
||||||
|
muted,
|
||||||
|
);
|
||||||
|
if (extraSection.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(...extraSection);
|
||||||
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,19 @@ export interface ManagedUsageRow {
|
||||||
readonly resetHint?: string;
|
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 {
|
export interface ManagedUsageReport {
|
||||||
readonly summary: ManagedUsageRow | null;
|
readonly summary: ManagedUsageRow | null;
|
||||||
readonly limits: readonly ManagedUsageRow[];
|
readonly limits: readonly ManagedUsageRow[];
|
||||||
|
readonly extraUsage?: BoosterWalletInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageReportOptions {
|
export interface UsageReportOptions {
|
||||||
|
|
@ -121,8 +131,7 @@ function buildManagedUsageSection(
|
||||||
r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0;
|
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 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 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')];
|
const out: string[] = [accent('Plan usage')];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const ratioUsed = usedRatio(row);
|
const ratioUsed = usedRatio(row);
|
||||||
|
|
@ -136,6 +145,91 @@ function buildManagedUsageSection(
|
||||||
return out;
|
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[] {
|
export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] {
|
||||||
const accent = (text: string) => currentTheme.boldFg('primary', text);
|
const accent = (text: string) => currentTheme.boldFg('primary', text);
|
||||||
const value = (text: string) => currentTheme.fg('text', 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 value = (text: string) => currentTheme.fg('text', text);
|
||||||
const muted = (text: string) => currentTheme.fg('textDim', text);
|
const muted = (text: string) => currentTheme.fg('textDim', text);
|
||||||
const errorStyle = (text: string) => currentTheme.fg('error', 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[] = [
|
const lines: string[] = [
|
||||||
accent('Session usage'),
|
accent('Session usage'),
|
||||||
|
|
@ -197,6 +289,17 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
|
||||||
lines.push(...managedSection);
|
lines.push(...managedSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extraSection = buildExtraUsageSection(
|
||||||
|
options.managedUsage?.extraUsage,
|
||||||
|
accent,
|
||||||
|
value,
|
||||||
|
muted,
|
||||||
|
);
|
||||||
|
if (extraSection.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(...extraSection);
|
||||||
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Session } from '@moonshot-ai/kimi-code-sdk';
|
import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
|
||||||
import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk';
|
import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk';
|
||||||
|
|
||||||
import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image';
|
import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image';
|
||||||
|
|
@ -23,6 +23,12 @@ export interface EditorKeyboardHost {
|
||||||
state: TUIState;
|
state: TUIState;
|
||||||
session: Session | undefined;
|
session: Session | undefined;
|
||||||
cancelInFlight: (() => void) | undefined;
|
cancelInFlight: (() => void) | undefined;
|
||||||
|
/**
|
||||||
|
* The host's harness (KimiTUI always has one). Its `imageLimits` drives
|
||||||
|
* paste-time image compression; hosts without one fall back to the
|
||||||
|
* env/built-in default.
|
||||||
|
*/
|
||||||
|
harness?: KimiHarness | undefined;
|
||||||
|
|
||||||
handleUserInput(text: string): void;
|
handleUserInput(text: string): void;
|
||||||
readonly btwPanelController: BtwPanelController;
|
readonly btwPanelController: BtwPanelController;
|
||||||
|
|
@ -407,7 +413,11 @@ export class EditorKeyboardController {
|
||||||
// session's media-originals dir when known, else the temp-dir fallback)
|
// session's media-originals dir when known, else the temp-dir fallback)
|
||||||
// and recorded on the attachment, so submit-time expansion can announce
|
// and recorded on the attachment, so submit-time expansion can announce
|
||||||
// the compression and point the model at the full-fidelity copy.
|
// the compression and point the model at the full-fidelity copy.
|
||||||
|
// The edge cap comes from the host harness's [image] config (resolved per
|
||||||
|
// paste so a config reload applies immediately); hosts without a harness
|
||||||
|
// use the env/built-in default.
|
||||||
const compressed = await compressImageForModel(media.bytes, meta.mime, {
|
const compressed = await compressImageForModel(media.bytes, meta.mime, {
|
||||||
|
maxEdge: this.host.harness?.imageLimits?.maxEdgePx(),
|
||||||
telemetry: {
|
telemetry: {
|
||||||
client: {
|
client: {
|
||||||
track: (event, properties) =>
|
track: (event, properties) =>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ describe('parseHeadlessGoalCreate', () => {
|
||||||
expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined();
|
expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined();
|
||||||
expect(parseHeadlessGoalCreate('/goal pause')).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', () => {
|
describe('goal summary', () => {
|
||||||
|
|
@ -97,6 +103,7 @@ const mocks = vi.hoisted(() => {
|
||||||
handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
waitForBackgroundTasksOnPrint: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
|
|
@ -164,6 +171,8 @@ describe('runPrompt headless goal mode', () => {
|
||||||
mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }];
|
mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }];
|
||||||
mocks.sessions = [];
|
mocks.sessions = [];
|
||||||
mocks.session.createGoal.mockClear();
|
mocks.session.createGoal.mockClear();
|
||||||
|
mocks.session.prompt.mockClear();
|
||||||
|
mocks.session.waitForBackgroundTasksOnPrint.mockClear();
|
||||||
mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never);
|
mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never);
|
||||||
mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'complete' }) } 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');
|
expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps listening across continuation turns until the goal is terminal', async () => {
|
||||||
|
const active = snapshot({ status: 'active', turnsUsed: 1, tokensUsed: 80 });
|
||||||
|
const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 });
|
||||||
|
mocks.session.getGoal.mockResolvedValueOnce({ goal: active } as never);
|
||||||
|
mocks.session.prompt.mockImplementationOnce(async () => {
|
||||||
|
for (const handler of mocks.eventHandlers) {
|
||||||
|
handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
||||||
|
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: '1' }));
|
||||||
|
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
||||||
|
}
|
||||||
|
await Promise.resolve();
|
||||||
|
for (const handler of mocks.eventHandlers) {
|
||||||
|
handler(
|
||||||
|
mocks.mainEvent({
|
||||||
|
type: 'turn.started',
|
||||||
|
turnId: 2,
|
||||||
|
origin: { kind: 'system_trigger', name: 'goal_continuation' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: '2' }));
|
||||||
|
handler(
|
||||||
|
mocks.mainEvent({
|
||||||
|
type: 'goal.updated',
|
||||||
|
snapshot: completed,
|
||||||
|
change: { kind: 'completion', status: 'complete' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const stdout = writer();
|
||||||
|
const stderr = writer();
|
||||||
|
|
||||||
|
await runPrompt(opts(), 'test', {
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout.text()).toBe('• 1\n\n• 2\n\n');
|
||||||
|
expect(stderr.text()).toContain('Goal [complete]');
|
||||||
|
expect(stderr.text()).toContain('turns: 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale goal checks once a continuation turn has started', async () => {
|
||||||
|
const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 });
|
||||||
|
let resolveFirstGoal: ((value: { goal: null }) => void) | undefined;
|
||||||
|
const firstGoal = new Promise<{ goal: null }>((resolve) => {
|
||||||
|
resolveFirstGoal = resolve;
|
||||||
|
});
|
||||||
|
mocks.session.getGoal
|
||||||
|
.mockImplementationOnce(() => firstGoal as never)
|
||||||
|
.mockResolvedValue({ goal: null } as never);
|
||||||
|
mocks.session.prompt.mockImplementationOnce(async () => {
|
||||||
|
const emit = (event: Record<string, unknown>) => {
|
||||||
|
for (const handler of [...mocks.eventHandlers]) {
|
||||||
|
handler(mocks.mainEvent(event));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emit({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } });
|
||||||
|
emit({ type: 'assistant.delta', turnId: 1, delta: '1' });
|
||||||
|
emit({ type: 'turn.ended', turnId: 1, reason: 'completed' });
|
||||||
|
emit({
|
||||||
|
type: 'turn.started',
|
||||||
|
turnId: 2,
|
||||||
|
origin: { kind: 'system_trigger', name: 'goal_continuation' },
|
||||||
|
});
|
||||||
|
emit({ type: 'assistant.delta', turnId: 2, delta: '2' });
|
||||||
|
emit({
|
||||||
|
type: 'goal.updated',
|
||||||
|
snapshot: completed,
|
||||||
|
change: { kind: 'completion', status: 'complete' },
|
||||||
|
});
|
||||||
|
resolveFirstGoal?.({ goal: null });
|
||||||
|
await Promise.resolve();
|
||||||
|
emit({ type: 'assistant.delta', turnId: 2, delta: ' tail' });
|
||||||
|
emit({ type: 'turn.ended', turnId: 2, reason: 'completed' });
|
||||||
|
});
|
||||||
|
const stdout = writer();
|
||||||
|
const stderr = writer();
|
||||||
|
|
||||||
|
await runPrompt(opts(), 'test', {
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout.text()).toBe('• 1\n\n• 2 tail\n\n');
|
||||||
|
expect(stderr.text()).toContain('Goal [complete]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send an invalid goal create prompt as a normal prompt', async () => {
|
||||||
|
const stdout = writer();
|
||||||
|
const stderr = writer();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runPrompt(opts({ prompt: `/goal ${'x'.repeat(4001)}` }), 'test', {
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Goal objective is too long');
|
||||||
|
|
||||||
|
expect(mocks.session.createGoal).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.session.prompt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('validates the resumed session model before creating a headless goal', async () => {
|
it('validates the resumed session model before creating a headless goal', async () => {
|
||||||
mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }];
|
mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }];
|
||||||
mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never);
|
mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never);
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,87 @@ describe('plugins selector dialogs', () => {
|
||||||
expect(out).toContain('0 installed · 1 available');
|
expect(out).toContain('0 installed · 1 available');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the hardcoded Web Bridge entry on the Official tab while loading', () => {
|
||||||
|
const { panel } = makePanel({ initialTab: 'official' });
|
||||||
|
// The catalog is still loading, but the built-in Web Bridge entry is shown
|
||||||
|
// immediately because it is baked into the TUI, not fetched.
|
||||||
|
const out = strip(renderRaw(panel));
|
||||||
|
expect(out).toContain('Kimi WebBridge open in browser');
|
||||||
|
expect(out).toContain('Loading marketplace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the Web Bridge entry visible when the Official catalog errors', () => {
|
||||||
|
const { panel } = makePanel({ initialTab: 'official' });
|
||||||
|
panel.setMarketplaceError('fetch failed');
|
||||||
|
const out = strip(renderRaw(panel));
|
||||||
|
expect(out).toContain('Kimi WebBridge open in browser');
|
||||||
|
expect(out).toContain('Marketplace unavailable: fetch failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the Web Bridge webpage on Enter instead of installing', () => {
|
||||||
|
const { panel, onSelect } = makePanel({ initialTab: 'official' });
|
||||||
|
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
|
||||||
|
// Web Bridge is pinned at index 0, so Enter selects it directly.
|
||||||
|
panel.handleInput('\r');
|
||||||
|
expect(onSelect).toHaveBeenCalledWith({
|
||||||
|
kind: 'open-url',
|
||||||
|
url: 'https://www.kimi.com/features/webbridge',
|
||||||
|
label: 'Kimi WebBridge',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('installs a catalog official entry after navigating past Web Bridge', () => {
|
||||||
|
const { panel, onSelect } = makePanel({ initialTab: 'official' });
|
||||||
|
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
|
||||||
|
panel.handleInput('\u001B[B'); // ↓ → kimi-datasource
|
||||||
|
panel.handleInput('\r');
|
||||||
|
expect(onSelect).toHaveBeenCalledWith({
|
||||||
|
kind: 'install',
|
||||||
|
entry: expect.objectContaining({ id: 'kimi-datasource' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate Web Bridge when the catalog also lists it', () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
id: 'kimi-webbridge',
|
||||||
|
tier: 'official' as const,
|
||||||
|
displayName: 'Kimi WebBridge',
|
||||||
|
source: 'https://x/w.zip',
|
||||||
|
},
|
||||||
|
...officialEntries,
|
||||||
|
];
|
||||||
|
const { panel } = makePanel({ initialTab: 'official' });
|
||||||
|
panel.setMarketplace(entries, '/tmp/marketplace.json');
|
||||||
|
const out = strip(renderRaw(panel));
|
||||||
|
// The label should appear exactly once — the hardcoded row wins, the
|
||||||
|
// catalog copy is filtered out.
|
||||||
|
expect(out.split('Kimi WebBridge').length - 1).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('installs a Third-party entry whose id matches the pinned WebBridge', () => {
|
||||||
|
// A curated/custom marketplace entry can legitimately reuse the
|
||||||
|
// kimi-webbridge id; on the Third-party tab it must install normally, not
|
||||||
|
// open the WebBridge page (that shortcut is reserved for the pinned row).
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
id: 'kimi-webbridge',
|
||||||
|
tier: 'curated' as const,
|
||||||
|
displayName: 'Kimi WebBridge',
|
||||||
|
source: 'https://x/w.zip',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { panel, onSelect } = makePanel({ initialTab: 'third-party' });
|
||||||
|
panel.setMarketplace(entries, '/tmp/marketplace.json');
|
||||||
|
const out = strip(renderRaw(panel));
|
||||||
|
expect(out).toContain('Kimi WebBridge install');
|
||||||
|
panel.handleInput('\r');
|
||||||
|
expect(onSelect).toHaveBeenCalledWith({
|
||||||
|
kind: 'install',
|
||||||
|
entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'https://x/w.zip' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('installs the selected Third-party entry on Enter', () => {
|
it('installs the selected Third-party entry on Enter', () => {
|
||||||
const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' });
|
const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' });
|
||||||
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
|
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,44 @@ describe('status panel report lines', () => {
|
||||||
expect(output).not.toContain('Runtime');
|
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', () => {
|
it('falls back to app state and shows status load errors as warnings', () => {
|
||||||
const lines = buildStatusReportLines({
|
const lines = buildStatusReportLines({
|
||||||
version: '1.2.3',
|
version: '1.2.3',
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ describe('UsagePanelComponent', () => {
|
||||||
output: 250,
|
output: 250,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never,
|
},
|
||||||
contextUsage: 0.25,
|
contextUsage: 0.25,
|
||||||
contextTokens: 2500,
|
contextTokens: 2500,
|
||||||
maxContextTokens: 10000,
|
maxContextTokens: 10000,
|
||||||
|
|
@ -48,6 +48,142 @@ describe('UsagePanelComponent', () => {
|
||||||
expect(lines.join('\n')).toContain('resets tomorrow');
|
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', () => {
|
it('wraps preformatted usage lines in a bordered panel', () => {
|
||||||
const component = new UsagePanelComponent(() => ['Session usage'], 'primary');
|
const component = new UsagePanelComponent(() => ['Session usage'], 'primary');
|
||||||
const output = component.render(80).map(strip);
|
const output = component.render(80).map(strip);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
} from '#/tui/controllers/editor-keyboard';
|
} from '#/tui/controllers/editor-keyboard';
|
||||||
import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store';
|
import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store';
|
||||||
import { parseImageMeta } from '#/utils/image/image-mime';
|
import { parseImageMeta } from '#/utils/image/image-mime';
|
||||||
|
import { ImageLimits, type KimiHarness } from '@moonshot-ai/kimi-code-sdk';
|
||||||
|
|
||||||
// vitest hoists vi.mock/vi.hoisted above the imports above, so the mock still
|
// vitest hoists vi.mock/vi.hoisted above the imports above, so the mock still
|
||||||
// applies to the editor-keyboard module that pulls in readClipboardMedia.
|
// applies to the editor-keyboard module that pulls in readClipboardMedia.
|
||||||
|
|
@ -41,7 +42,7 @@ interface PasteHarness {
|
||||||
pasteImage(): Promise<void>;
|
pasteImage(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness {
|
function createPasteHarness(options: { sessionDir?: string; imageLimits?: ImageLimits } = {}): PasteHarness {
|
||||||
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
|
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
|
||||||
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
|
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
|
||||||
};
|
};
|
||||||
|
|
@ -65,6 +66,11 @@ function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness
|
||||||
openUndoSelector: vi.fn(),
|
openUndoSelector: vi.fn(),
|
||||||
cancelRunningShellCommand: vi.fn(),
|
cancelRunningShellCommand: vi.fn(),
|
||||||
} as unknown as EditorKeyboardHost;
|
} as unknown as EditorKeyboardHost;
|
||||||
|
if (options.imageLimits !== undefined) {
|
||||||
|
(host as unknown as { harness: KimiHarness }).harness = {
|
||||||
|
imageLimits: options.imageLimits,
|
||||||
|
} as unknown as KimiHarness;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new EditorKeyboardController(host, store);
|
const controller = new EditorKeyboardController(host, store);
|
||||||
controller.install();
|
controller.install();
|
||||||
|
|
@ -141,14 +147,33 @@ describe('clipboard image paste compression', () => {
|
||||||
if (att?.kind !== 'image') throw new Error('expected image attachment');
|
if (att?.kind !== 'image') throw new Error('expected image attachment');
|
||||||
|
|
||||||
// Stored metadata reflects the compressed size.
|
// Stored metadata reflects the compressed size.
|
||||||
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(3000);
|
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000);
|
||||||
expect(att.placeholder).toContain('3000×1500');
|
expect(att.placeholder).toContain('2000×1000');
|
||||||
|
|
||||||
// The stored bytes decode to the compressed dimensions — the thumbnail and
|
// The stored bytes decode to the compressed dimensions — the thumbnail and
|
||||||
// the submitted image both read from these bytes, so they cannot diverge.
|
// the submitted image both read from these bytes, so they cannot diverge.
|
||||||
const dims = parseImageMeta(att.bytes);
|
const dims = parseImageMeta(att.bytes);
|
||||||
expect(dims).not.toBeNull();
|
expect(dims).not.toBeNull();
|
||||||
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(3000);
|
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors the harness [image] max_edge_px when pasting', async () => {
|
||||||
|
const big = await solidPng(3600, 1800);
|
||||||
|
readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' });
|
||||||
|
|
||||||
|
const { store, pasteImage } = createPasteHarness({
|
||||||
|
imageLimits: new ImageLimits(process.env, { maxEdgePx: 800 }),
|
||||||
|
});
|
||||||
|
await pasteImage();
|
||||||
|
|
||||||
|
const att = store.get(1);
|
||||||
|
if (att?.kind !== 'image') throw new Error('expected image attachment');
|
||||||
|
// The harness [image] config — not the built-in 2000px — drives ingestion.
|
||||||
|
expect(Math.max(att.width, att.height)).toBe(800);
|
||||||
|
expect(att.placeholder).toContain('800×400');
|
||||||
|
const dims = parseImageMeta(att.bytes);
|
||||||
|
expect(dims).not.toBeNull();
|
||||||
|
expect(Math.max(dims!.width, dims!.height)).toBe(800);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records and persists the pre-compression original for an oversized paste', async () => {
|
it('records and persists the pre-compression original for an oversized paste', async () => {
|
||||||
|
|
|
||||||
|
|
@ -3781,6 +3781,9 @@ command = "vim"
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource');
|
expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource');
|
||||||
});
|
});
|
||||||
|
// The pinned Kimi WebBridge row leads the Official tab, so move down to
|
||||||
|
// the Kimi Datasource entry before installing.
|
||||||
|
panel.handleInput('\u001B[B');
|
||||||
panel.handleInput('\r');
|
panel.handleInput('\r');
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
|
|
@ -3987,6 +3990,9 @@ command = "vim"
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource');
|
expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource');
|
||||||
});
|
});
|
||||||
|
// The pinned Kimi WebBridge row leads the Official tab, so move down to
|
||||||
|
// the Kimi Datasource entry before installing.
|
||||||
|
panel.handleInput('\u001B[B');
|
||||||
panel.handleInput('\r');
|
panel.handleInput('\r');
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
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 type { MenuItemConstructorOptions } from 'electron';
|
||||||
|
|
||||||
import { ensureServer, kimiHome, serverLogPath } from './ensure-server';
|
import { ensureServer, kimiHome, serverLogPath } from './ensure-server';
|
||||||
|
|
@ -152,8 +152,13 @@ function createWindow(): void {
|
||||||
title: 'Kimi Code Desktop',
|
title: 'Kimi Code Desktop',
|
||||||
// macOS: hide the native title bar and float the traffic lights over the
|
// 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.
|
// 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).
|
// '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: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
|
@ -165,6 +170,63 @@ function createWindow(): void {
|
||||||
win.webContents.on('page-title-updated', (event) => {
|
win.webContents.on('page-title-updated', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
|
// macOS traffic lights.
|
||||||
|
//
|
||||||
|
// 1) Visibility across transitions: with titleBarStyle 'hidden' + a custom
|
||||||
|
// trafficLightPosition, the buttons can vanish (or lose their custom
|
||||||
|
// position) after a full-screen round-trip or on re-focus. Re-assert both
|
||||||
|
// on those transitions (observed on Electron 33; belt-and-braces).
|
||||||
|
//
|
||||||
|
// 2) Blur is NOT such a case: unfocused traffic lights are merely DIMMED by
|
||||||
|
// AppKit, and the dimmed color follows the WINDOW appearance, not the
|
||||||
|
// page (electron#27295) — with the OS in dark mode but the web UI on a
|
||||||
|
// light theme, the light-gray dimmed dots become invisible against the
|
||||||
|
// light sidebar. That is fixed by the theme sync below, which keeps the
|
||||||
|
// window appearance aligned with the web UI's <html data-color-scheme>.
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const showTrafficLights = (): void => {
|
||||||
|
if (win.isDestroyed()) return;
|
||||||
|
win.setWindowButtonPosition({ x: 16, y: 18 });
|
||||||
|
win.setWindowButtonVisibility(true);
|
||||||
|
};
|
||||||
|
win.on('enter-full-screen', showTrafficLights);
|
||||||
|
win.on('leave-full-screen', showTrafficLights);
|
||||||
|
win.on('focus', showTrafficLights);
|
||||||
|
|
||||||
|
// Theme sync: no preload/IPC channel exists, so inject a tiny observer
|
||||||
|
// that reports <html data-color-scheme> ('light' | 'dark' | 'system')
|
||||||
|
// through a tagged console message, and mirror it into
|
||||||
|
// nativeTheme.themeSource (same three states). The startup/error screens
|
||||||
|
// (data: URLs) have no such attribute and harmlessly report 'system'.
|
||||||
|
const THEME_TAG = '__kimi_desktop_theme__:';
|
||||||
|
win.webContents.on('console-message', (_event, _level, message) => {
|
||||||
|
if (!message.startsWith(THEME_TAG)) return;
|
||||||
|
const scheme = message.slice(THEME_TAG.length);
|
||||||
|
if (scheme === 'light' || scheme === 'dark' || scheme === 'system') {
|
||||||
|
nativeTheme.themeSource = scheme;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
win.webContents.on('did-finish-load', () => {
|
||||||
|
win.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
const report = () => {
|
||||||
|
const v = document.documentElement.dataset.colorScheme;
|
||||||
|
console.info(${JSON.stringify(THEME_TAG)} + (v === 'light' || v === 'dark' ? v : 'system'));
|
||||||
|
};
|
||||||
|
new MutationObserver(report).observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-color-scheme'],
|
||||||
|
});
|
||||||
|
report();
|
||||||
|
})();`,
|
||||||
|
)
|
||||||
|
.catch(() => {
|
||||||
|
// Navigation can tear the page down mid-injection; theme sync is
|
||||||
|
// cosmetic, so ignore.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
win.on('close', () => {
|
win.on('close', () => {
|
||||||
saveBounds(win);
|
saveBounds(win);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
"check:style": "node scripts/check-style.mjs"
|
"check:style": "node scripts/check-style.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/ri": "^1.2.10",
|
"@iconify-json/ri": "^1.2.10",
|
||||||
|
"@iconify-json/tabler": "^1.2.35",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"typescript": "6.0.2",
|
"typescript": "6.0.2",
|
||||||
"unplugin-icons": "^23.0.0",
|
"unplugin-icons": "^23.0.0",
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ import { stripSkillPrefix } from './lib/slashCommands';
|
||||||
import Button from './components/ui/Button.vue';
|
import Button from './components/ui/Button.vue';
|
||||||
import IconButton from './components/ui/IconButton.vue';
|
import IconButton from './components/ui/IconButton.vue';
|
||||||
import Icon from './components/ui/Icon.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)
|
// Hydrate the server-transport credential (fragment token or sessionStorage)
|
||||||
// BEFORE the client connects, so the first REST/WS calls already carry it.
|
// BEFORE the client connects, so the first REST/WS calls already carry it.
|
||||||
|
|
@ -213,6 +215,7 @@ const {
|
||||||
sidebarMax,
|
sidebarMax,
|
||||||
sessionColWidth,
|
sessionColWidth,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
|
sidebarDragging,
|
||||||
sideWidth,
|
sideWidth,
|
||||||
loadSidebarCollapsed,
|
loadSidebarCollapsed,
|
||||||
toggleSidebarCollapse,
|
toggleSidebarCollapse,
|
||||||
|
|
@ -645,13 +648,18 @@ function openPr(url: string): void {
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="app"
|
class="app"
|
||||||
:class="{ mobile: isMobile, 'sidebar-collapsed': sidebarCollapsed && !isMobile }"
|
:class="{
|
||||||
:style="{ '--side-w': sideWidth + 'px', '--preview-w': previewPanelWidth + 'px' }"
|
mobile: isMobile,
|
||||||
|
'sidebar-collapsed': sidebarCollapsed && !isMobile,
|
||||||
|
'macos-desktop': isMacosDesktop,
|
||||||
|
}"
|
||||||
|
:style="{ '--preview-w': previewPanelWidth + 'px' }"
|
||||||
>
|
>
|
||||||
<!-- Desktop navigation: workspace rail + resizable session column. -->
|
<!-- Desktop navigation: workspace rail + resizable session column. -->
|
||||||
<template v-if="!isMobile">
|
<template v-if="!isMobile">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
v-show="!sidebarCollapsed"
|
:collapsed="sidebarCollapsed"
|
||||||
|
:dragging="sidebarDragging"
|
||||||
:col-width="sideWidth"
|
:col-width="sideWidth"
|
||||||
:active-workspace="client.visibleWorkspace.value"
|
:active-workspace="client.visibleWorkspace.value"
|
||||||
:active-workspace-id="client.activeWorkspaceId.value"
|
:active-workspace-id="client.activeWorkspaceId.value"
|
||||||
|
|
@ -681,21 +689,14 @@ function openPr(url: string): void {
|
||||||
/>
|
/>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
v-show="!sidebarCollapsed"
|
v-show="!sidebarCollapsed"
|
||||||
|
class="side-handle"
|
||||||
:storage-key="SIDEBAR_WIDTH_KEY"
|
:storage-key="SIDEBAR_WIDTH_KEY"
|
||||||
:default-width="SIDEBAR_DEFAULT"
|
:default-width="SIDEBAR_DEFAULT"
|
||||||
:min="SIDEBAR_MIN"
|
:min="SIDEBAR_MIN"
|
||||||
:max="sidebarMax"
|
:max="sidebarMax"
|
||||||
@update:width="sessionColWidth = $event"
|
@update:width="sessionColWidth = $event"
|
||||||
|
@update:dragging="sidebarDragging = $event"
|
||||||
/>
|
/>
|
||||||
<div v-if="sidebarCollapsed" class="sidebar-rail">
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
:label="t('sidebar.expandSidebar')"
|
|
||||||
@click="toggleSidebarCollapse"
|
|
||||||
>
|
|
||||||
<Icon name="panel-expand" size="sm" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Mobile navigation: slim top bar (switcher + settings sheets). -->
|
<!-- Mobile navigation: slim top bar (switcher + settings sheets). -->
|
||||||
|
|
@ -738,6 +739,7 @@ function openPr(url: string): void {
|
||||||
:search-files="client.searchFiles"
|
:search-files="client.searchFiles"
|
||||||
:upload-image="client.uploadImage"
|
:upload-image="client.uploadImage"
|
||||||
:sending="client.isSending.value"
|
:sending="client.isSending.value"
|
||||||
|
:starting="client.isStartingFirstPrompt.value"
|
||||||
:fast-moon="client.fastMoon.value"
|
:fast-moon="client.fastMoon.value"
|
||||||
:file-reload-key="client.activeSessionId.value"
|
:file-reload-key="client.activeSessionId.value"
|
||||||
:session-loading="client.sessionLoading.value"
|
:session-loading="client.sessionLoading.value"
|
||||||
|
|
@ -792,8 +794,29 @@ function openPr(url: string): void {
|
||||||
@edit-message="handleEditMessage"
|
@edit-message="handleEditMessage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Sidebar toggle — floating only when the in-header control can't serve:
|
||||||
|
on macOS desktop it's RESIDENT (always rendered beside the traffic
|
||||||
|
lights, the sidebar slides underneath and only the glyph swaps, so it
|
||||||
|
never moves or flashes); on Windows/web the collapse button lives
|
||||||
|
inside the sidebar header, so this floating button only appears while
|
||||||
|
COLLAPSED (to re-expand the sidebar). It must come AFTER
|
||||||
|
ConversationPane in the DOM: Electron computes the window-drag region
|
||||||
|
in tree order (drag rects union, no-drag rects subtract), so a no-drag
|
||||||
|
element placed before the ChatHeader drag region would have its hole
|
||||||
|
painted back over — making the button an inert drag area. -->
|
||||||
|
<IconButton
|
||||||
|
v-if="!isMobile && (isMacosDesktop || sidebarCollapsed)"
|
||||||
|
class="sidebar-toggle-btn"
|
||||||
|
size="sm"
|
||||||
|
:label="sidebarCollapsed ? t('sidebar.expandSidebar') : t('sidebar.collapseSidebar')"
|
||||||
|
@click="toggleSidebarCollapse"
|
||||||
|
>
|
||||||
|
<Icon :name="sidebarCollapsed ? 'panel-expand' : 'panel-collapse'" />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
v-if="sidePanelVisible && !isMobile"
|
v-if="sidePanelVisible && !isMobile"
|
||||||
|
class="preview-handle"
|
||||||
:storage-key="PREVIEW_WIDTH_KEY"
|
:storage-key="PREVIEW_WIDTH_KEY"
|
||||||
:default-width="previewDefaultWidth"
|
:default-width="previewDefaultWidth"
|
||||||
:min="PREVIEW_MIN"
|
:min="PREVIEW_MIN"
|
||||||
|
|
@ -875,6 +898,11 @@ function openPr(url: string): void {
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- Internal-build tag — pinned to the app's bottom-right corner, above
|
||||||
|
whatever pane happens to be there. Purely informational: pointer
|
||||||
|
events pass through so it never blocks clicks. -->
|
||||||
|
<InternalBuildBanner class="internal-build-fab" />
|
||||||
|
|
||||||
<!-- Model Picker overlay -->
|
<!-- Model Picker overlay -->
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
v-if="showModelPicker"
|
v-if="showModelPicker"
|
||||||
|
|
@ -898,6 +926,7 @@ function openPr(url: string): void {
|
||||||
:account-model="client.defaultModel.value"
|
:account-model="client.defaultModel.value"
|
||||||
:notify="client.notifyOnComplete.value"
|
:notify="client.notifyOnComplete.value"
|
||||||
:notify-question="client.notifyOnQuestion.value"
|
:notify-question="client.notifyOnQuestion.value"
|
||||||
|
:notify-approval="client.notifyOnApproval.value"
|
||||||
:notify-permission="client.notifyPermission.value"
|
:notify-permission="client.notifyPermission.value"
|
||||||
:sound="client.soundOnComplete.value"
|
:sound="client.soundOnComplete.value"
|
||||||
:conversation-toc="client.conversationToc.value"
|
:conversation-toc="client.conversationToc.value"
|
||||||
|
|
@ -910,6 +939,7 @@ function openPr(url: string): void {
|
||||||
@set-ui-font-size="client.setUiFontSize($event)"
|
@set-ui-font-size="client.setUiFontSize($event)"
|
||||||
@set-notify="client.setNotifyOnComplete($event)"
|
@set-notify="client.setNotifyOnComplete($event)"
|
||||||
@set-notify-question="client.setNotifyOnQuestion($event)"
|
@set-notify-question="client.setNotifyOnQuestion($event)"
|
||||||
|
@set-notify-approval="client.setNotifyOnApproval($event)"
|
||||||
@set-sound="client.setSoundOnComplete($event)"
|
@set-sound="client.setSoundOnComplete($event)"
|
||||||
@set-conversation-toc="client.setConversationToc($event)"
|
@set-conversation-toc="client.setConversationToc($event)"
|
||||||
@update-config="handleUpdateConfig($event)"
|
@update-config="handleUpdateConfig($event)"
|
||||||
|
|
@ -1099,18 +1129,20 @@ function openPr(url: string): void {
|
||||||
color: var(--dim);
|
color: var(--dim);
|
||||||
}
|
}
|
||||||
.app {
|
.app {
|
||||||
--side-w: 248px;
|
|
||||||
--preview-w: 460px;
|
--preview-w: 460px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
/* sidebar (rail + resizable session column) | 0-width handle | conversation.
|
/* sidebar | 0-width handle | conversation | 0-width handle | right panel.
|
||||||
The 4px ResizeHandle overflows its zero-width track via negative margins so
|
The 4px ResizeHandles overflow their zero-width tracks via negative margins
|
||||||
the whole strip is grabbable without consuming layout space. */
|
so the whole strip is grabbable without consuming layout space. */
|
||||||
/* The right-panel track is PERMANENT (auto = follows the aside's width, 0
|
/* Both side tracks are PERMANENT (auto = follows the aside's width, 0 when
|
||||||
when closed) — opening animates the aside's width, so the conversation
|
closed/collapsed) — opening or collapsing animates the aside's width, so
|
||||||
column is squeezed over smoothly instead of snapping to a new template. */
|
the conversation column is squeezed over smoothly instead of snapping to a
|
||||||
grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
|
new template. Every column is pinned explicitly (grid-column 1–5) so a
|
||||||
|
display:none handle can't shift auto-placement. */
|
||||||
|
grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -1124,20 +1156,50 @@ function openPr(url: string): void {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapsed sidebar rail: keeps a slim, dedicated grid track so the expand
|
/* Pin every desktop grid child to its track so auto-placement can never
|
||||||
button never overlaps the conversation header or squeezes the main pane. */
|
reshuffle columns when a handle is display:none (v-show/v-if). */
|
||||||
.sidebar-rail {
|
.app > .side { grid-column: 1; }
|
||||||
grid-column: 1;
|
.side-handle { grid-column: 2; }
|
||||||
display: flex;
|
.app:not(.mobile) > .con { grid-column: 3; }
|
||||||
justify-content: center;
|
.preview-handle { grid-column: 4; }
|
||||||
padding-top: 8px;
|
|
||||||
background: var(--panel);
|
/* Sidebar toggle — floating button pinned to the top-left corner. On macOS
|
||||||
border-right: 1px solid var(--line);
|
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
|
/* macOS desktop (hidden title bar): resident beside the floating traffic
|
||||||
conversation track even though the sidebar/handle are display:none. */
|
lights (green light's right edge ≈ 68px; 72 keeps a gap that matches the
|
||||||
.app.sidebar-collapsed > .con {
|
lights' own 8px rhythm); no entrance animation since it never appears. */
|
||||||
grid-column: 3;
|
.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
|
/* Mobile single-column shell: slim top bar (auto) over the full-width
|
||||||
|
|
@ -1206,4 +1268,19 @@ function openPr(url: string): void {
|
||||||
one continuous line across the layout. */
|
one continuous line across the layout. */
|
||||||
--panel-head-h: 48px;
|
--panel-head-h: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar collapsed (desktop): the conversation header pads left so its
|
||||||
|
content clears the floating sidebar toggle (.sidebar-toggle-btn) — and the
|
||||||
|
macOS traffic lights on desktop builds. Animated in step with the sidebar
|
||||||
|
width transition. Cross-component rule (ChatHeader renders the header), so
|
||||||
|
it lives in this global block. */
|
||||||
|
.app:not(.mobile) .chat-header {
|
||||||
|
transition: padding-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.app.sidebar-collapsed .chat-header {
|
||||||
|
padding-left: 52px;
|
||||||
|
}
|
||||||
|
.app.sidebar-collapsed.macos-desktop .chat-header {
|
||||||
|
padding-left: 108px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import { isDesktop } from '../lib/desktopFlag';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders an inline
|
// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders a small
|
||||||
// tag meant to sit next to the "Kimi Code" brand in the sidebar header.
|
// tag pinned to the app's bottom-right corner (positioned by App.vue).
|
||||||
const show = isDesktop;
|
const show = isDesktop;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,7 @@ defineExpose({ closeMenu });
|
||||||
:label="t('sidebar.options')"
|
:label="t('sidebar.options')"
|
||||||
@click.stop="toggleMenu($event)"
|
@click.stop="toggleMenu($event)"
|
||||||
>
|
>
|
||||||
<Icon name="dots-horizontal" size="sm" />
|
<Icon name="dots-horizontal" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,22 +287,24 @@ defineExpose({ closeMenu });
|
||||||
.se {
|
.se {
|
||||||
/* --sb-* vars come from .side in Sidebar.vue: the title starts at
|
/* --sb-* vars come from .side in Sidebar.vue: the title starts at
|
||||||
--sb-pad-x + --sb-gutter + --sb-gap, exactly under the workspace name.
|
--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
|
The row is an inset pill: the .sessions container's --sb-inset padding +
|
||||||
leading icon at --sb-pad-x (16px), aligned with the workspace header. */
|
the row's own padding land the leading slot at --sb-pad-x, aligned with
|
||||||
|
the workspace header. */
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-1) var(--space-2);
|
padding: 8px var(--space-2);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
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 {
|
.se.on {
|
||||||
background: var(--color-accent-soft);
|
background: var(--color-selected);
|
||||||
color: var(--color-accent-hover);
|
color: var(--color-text);
|
||||||
box-shadow: inset 0 0 0 1px var(--color-accent-bd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
|
|
@ -310,9 +312,9 @@ defineExpose({ closeMenu });
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sb-gap, 6px);
|
gap: var(--sb-gap, 6px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
/* Floor the row at the hover-kebab height (IconButton sm = 26px) so swapping
|
/* Row height is font-driven: title line-height (13×1.25≈16px) + 2×5px
|
||||||
the timestamp for the kebab on hover doesn't grow the row. */
|
.se padding ≈ 26px. The hover kebab is absolutely positioned (see .act)
|
||||||
min-height: 26px;
|
so it never contributes to row height and can't cause hover jitter. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.left {
|
.left {
|
||||||
|
|
@ -341,8 +343,9 @@ defineExpose({ closeMenu });
|
||||||
|
|
||||||
.t {
|
.t {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size-sm);
|
||||||
font-weight: var(--weight-regular);
|
font-weight: 450;
|
||||||
|
line-height: var(--leading-tight);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -353,29 +356,39 @@ defineExpose({ closeMenu });
|
||||||
.ts {
|
.ts {
|
||||||
color: var(--color-text-faint);
|
color: var(--color-text-faint);
|
||||||
font-size: var(--text-xs);
|
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).
|
/* Trailing action slot: the relative time (in flow) sets the slot size; the
|
||||||
Both stay in the layout and swap via `visibility` (never display:none), so
|
kebab is absolutely positioned over it and swapped via `visibility`, so it
|
||||||
the slot width = max(time width, IconButton sm 26px) is identical in hover
|
contributes neither height (the row stays font-driven) nor width changes
|
||||||
and rest — the badges and title don't reflow, eliminating hover jitter.
|
(min-width reserves the kebab's footprint, the title doesn't reflow). */
|
||||||
`.act .kebab` out-specificities IconButton's own display so the hidden
|
|
||||||
default wins. */
|
|
||||||
.act {
|
.act {
|
||||||
display: inline-grid;
|
position: relative;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
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,
|
.se:hover .act .kebab,
|
||||||
.act:has(.kebab.open) .kebab { visibility: visible; }
|
.act:has(.kebab.open) .kebab { visibility: visible; }
|
||||||
.se:hover .act .ts,
|
.se:hover .act .ts,
|
||||||
.act:has(.kebab.open) .ts { visibility: hidden; }
|
.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
|
/* Fixed + anchored to the ⋯ button via inline style (see positionMenu); the menu
|
||||||
is teleported to <body> so the collapsing list's `overflow: hidden` can't clip it. */
|
is teleported to <body> so the collapsing list's `overflow: hidden` can't clip it. */
|
||||||
|
|
@ -409,15 +422,10 @@ defineExpose({ closeMenu });
|
||||||
|
|
||||||
.sessions .se {
|
.sessions .se {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
/* Trim the row padding by the inset margin so the title still starts at the
|
/* Trim the row padding by the container inset so the title still starts at
|
||||||
same x as the workspace name (whose header has no inset). */
|
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));
|
padding: 8px calc(var(--sb-pad-x, 20px) - var(--sb-inset, 12px));
|
||||||
}
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
.sessions .se .rename-input { border-radius: var(--radius-sm); font-family: var(--sans); }
|
.sessions .se .rename-input { border-radius: var(--radius-sm); font-family: var(--sans); }
|
||||||
.sessions .se .kebab { border-radius: var(--radius-sm); }
|
.sessions .se .kebab { border-radius: var(--radius-sm); }
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,16 @@ import { serverEndpointLabel } from '../api/config';
|
||||||
import { copyTextToClipboard } from '../lib/clipboard';
|
import { copyTextToClipboard } from '../lib/clipboard';
|
||||||
import {
|
import {
|
||||||
loadCollapsedWorkspaces,
|
loadCollapsedWorkspaces,
|
||||||
loadShowWorkspacePaths,
|
|
||||||
saveCollapsedWorkspaces,
|
saveCollapsedWorkspaces,
|
||||||
saveShowWorkspacePaths,
|
|
||||||
} from '../lib/storage';
|
} from '../lib/storage';
|
||||||
import { moveInOrder, type DropPosition, type WorkspaceSortMode } from '../lib/workspaceOrder';
|
import { moveInOrder, type DropPosition, type WorkspaceSortMode } from '../lib/workspaceOrder';
|
||||||
import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types';
|
import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types';
|
||||||
import SearchSessionsDialog from './dialogs/SearchSessionsDialog.vue';
|
import SearchSessionsDialog from './dialogs/SearchSessionsDialog.vue';
|
||||||
import WorkspaceGroup from './WorkspaceGroup.vue';
|
import WorkspaceGroup from './WorkspaceGroup.vue';
|
||||||
import InternalBuildBanner from './InternalBuildBanner.vue';
|
|
||||||
import { isMacosDesktop } from '../lib/desktopFlag';
|
import { isMacosDesktop } from '../lib/desktopFlag';
|
||||||
import IconButton from './ui/IconButton.vue';
|
import IconButton from './ui/IconButton.vue';
|
||||||
import Icon from './ui/Icon.vue';
|
import Icon from './ui/Icon.vue';
|
||||||
|
import Kbd from './ui/Kbd.vue';
|
||||||
import Menu from './ui/Menu.vue';
|
import Menu from './ui/Menu.vue';
|
||||||
import MenuItem from './ui/MenuItem.vue';
|
import MenuItem from './ui/MenuItem.vue';
|
||||||
import { useConfirmDialog } from '../composables/useConfirmDialog';
|
import { useConfirmDialog } from '../composables/useConfirmDialog';
|
||||||
|
|
@ -49,6 +47,12 @@ const props = withDefaults(
|
||||||
unreadBySession?: Record<string, boolean>;
|
unreadBySession?: Record<string, boolean>;
|
||||||
/** Width (px) of the session column, driven by the App resize handle. */
|
/** Width (px) of the session column, driven by the App resize handle. */
|
||||||
colWidth?: number;
|
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,
|
activeWorkspace: null,
|
||||||
|
|
@ -57,6 +61,8 @@ const props = withDefaults(
|
||||||
pendingBySession: () => ({}),
|
pendingBySession: () => ({}),
|
||||||
unreadBySession: () => ({}),
|
unreadBySession: () => ({}),
|
||||||
colWidth: 220,
|
colWidth: 220,
|
||||||
|
collapsed: false,
|
||||||
|
dragging: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -83,7 +89,7 @@ const emit = defineEmits<{
|
||||||
// Session search dialog (Spotlight-style; filters title + last prompt)
|
// Session search dialog (Spotlight-style; filters title + last prompt)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const showSearch = ref(false);
|
const showSearch = ref(false);
|
||||||
const sessionSearchShortcut = isAppleShortcutPlatform() ? '⌘K' : 'Ctrl K';
|
const sessionSearchKeys = isAppleShortcutPlatform() ? ['⌘', 'K'] : ['Ctrl', 'K'];
|
||||||
|
|
||||||
function openSearch(): void {
|
function openSearch(): void {
|
||||||
// Sessions are loaded per-workspace (first page only); lazily drain the rest
|
// 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';
|
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
|
// once the session list has actually scrolled, so an unscrolled list shows no
|
||||||
// abrupt boundary.
|
// abrupt boundary.
|
||||||
const sessionsScrolled = ref(false);
|
const sessionsScrolled = ref(false);
|
||||||
|
|
@ -185,18 +191,6 @@ function onLoadMore(id: string): void {
|
||||||
emit('loadMoreSessions', id);
|
emit('loadMoreSessions', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Workspace path display (toggle in the Workspaces section header)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Off by default so the list stays compact; turning it on reveals every
|
|
||||||
// workspace's root path as a stable subtitle (no hover-induced layout shift).
|
|
||||||
const showWorkspacePaths = ref<boolean>(loadShowWorkspacePaths());
|
|
||||||
|
|
||||||
function toggleShowWorkspacePaths(): void {
|
|
||||||
showWorkspacePaths.value = !showWorkspacePaths.value;
|
|
||||||
saveShowWorkspacePaths(showWorkspacePaths.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Workspace drag-to-reorder
|
// Workspace drag-to-reorder
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -486,11 +480,6 @@ function chooseSortMode(mode: WorkspaceSortMode): void {
|
||||||
closeSectionMenu();
|
closeSectionMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleShowWorkspacePathsFromMenu(): void {
|
|
||||||
toggleShowWorkspacePaths();
|
|
||||||
closeSectionMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('mousedown', onGhMenuDocClick, true);
|
document.removeEventListener('mousedown', onGhMenuDocClick, true);
|
||||||
document.removeEventListener('mousedown', onWsMenuDocClick);
|
document.removeEventListener('mousedown', onWsMenuDocClick);
|
||||||
|
|
@ -559,51 +548,49 @@ onBeforeUnmount(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="side" :class="{ 'macos-desktop': isMacosDesktop }">
|
<aside
|
||||||
|
class="side"
|
||||||
|
:class="{ 'macos-desktop': isMacosDesktop, collapsed, 'no-anim': dragging }"
|
||||||
|
:style="{ width: collapsed ? '0px' : colWidth + 'px' }"
|
||||||
|
>
|
||||||
<!-- Session column -->
|
<!-- Session column -->
|
||||||
<div class="col" :style="{ width: colWidth + 'px' }">
|
<div class="col" :style="{ width: colWidth + 'px' }">
|
||||||
<!-- Header: logo + settings (no hard border — flows into workspace list) -->
|
<!-- Header: brand + collapse. The collapse button lives INSIDE the header
|
||||||
|
on non-mac platforms (right-aligned); on macOS desktop the brand is
|
||||||
|
hidden (traffic lights own that corner) and the header is just a
|
||||||
|
window-drag strip — there the toggle is App.vue's resident floating
|
||||||
|
button beside the traffic lights. -->
|
||||||
<div class="ch">
|
<div class="ch">
|
||||||
<div class="ch-brand">
|
<div class="ch-brand">
|
||||||
<svg ref="logoRef" class="ch-logo" :class="{ 'is-dev': isDev }" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
|
<template v-if="!isMacosDesktop">
|
||||||
<defs>
|
<svg ref="logoRef" class="ch-logo" :class="{ 'is-dev': isDev }" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
|
||||||
<mask id="kimiEyes" maskUnits="userSpaceOnUse">
|
<defs>
|
||||||
<rect x="0" y="0" width="32" height="22" fill="#fff" />
|
<mask id="kimiEyes" maskUnits="userSpaceOnUse">
|
||||||
<g class="ch-eyes" fill="#000">
|
<rect x="0" y="0" width="32" height="22" fill="#fff" />
|
||||||
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
|
<g class="ch-eyes" fill="#000">
|
||||||
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
|
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
|
||||||
</g>
|
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
|
||||||
</mask>
|
</g>
|
||||||
</defs>
|
</mask>
|
||||||
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
|
</defs>
|
||||||
</svg>
|
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
|
||||||
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
|
</svg>
|
||||||
<InternalBuildBanner />
|
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
v-if="!isMacosDesktop"
|
||||||
|
class="ch-collapse"
|
||||||
size="sm"
|
size="sm"
|
||||||
:label="t('sidebar.collapseSidebar')"
|
:label="t('sidebar.collapseSidebar')"
|
||||||
@click.stop="emit('collapse')"
|
@click.stop="emit('collapse')"
|
||||||
>
|
>
|
||||||
<Icon name="panel-collapse" />
|
<Icon name="panel-collapse" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
:label="t('settings.title')"
|
|
||||||
@click.stop="emit('openSettings')"
|
|
||||||
>
|
|
||||||
<Icon name="settings" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Session search — opens the Spotlight-style search dialog -->
|
|
||||||
<button class="search" type="button" @click="openSearch">
|
|
||||||
<Icon class="search-icon" name="search" />
|
|
||||||
<span class="search-input">{{ t('sidebar.searchShortcut', { shortcut: sessionSearchShortcut }) }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- New chat + new workspace buttons -->
|
<!-- New chat + new workspace buttons -->
|
||||||
<div class="btn-wrap" :class="{ 'btn-wrap--scrolled': sessionsScrolled }">
|
<div class="btn-wrap">
|
||||||
<button class="btn-new-chat" type="button" @click.stop="emit('create')">
|
<button class="btn-new-chat" type="button" @click.stop="emit('create')">
|
||||||
<Icon name="chat-new" />
|
<Icon name="chat-new" />
|
||||||
<span>{{ t('sidebar.newChat') }}</span>
|
<span>{{ t('sidebar.newChat') }}</span>
|
||||||
|
|
@ -618,6 +605,16 @@ onBeforeUnmount(() => {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Session search — opens the Spotlight-style search dialog. Last fixed
|
||||||
|
row above the list, so it carries the scroll-linked seam. -->
|
||||||
|
<div class="search-wrap" :class="{ 'search-wrap--scrolled': sessionsScrolled }">
|
||||||
|
<button class="search" type="button" @click="openSearch">
|
||||||
|
<Icon class="search-icon" name="search" />
|
||||||
|
<span class="search-input">{{ t('sidebar.search') }}</span>
|
||||||
|
<Kbd :keys="sessionSearchKeys" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Session list — grouped by workspace -->
|
<!-- Session list — grouped by workspace -->
|
||||||
<div class="sessions" @scroll="onSessionsScroll">
|
<div class="sessions" @scroll="onSessionsScroll">
|
||||||
<!-- Empty state — only when no workspace is registered at all; empty
|
<!-- Empty state — only when no workspace is registered at all; empty
|
||||||
|
|
@ -675,7 +672,6 @@ onBeforeUnmount(() => {
|
||||||
:dragging="draggingWsId === g.workspace.id"
|
:dragging="draggingWsId === g.workspace.id"
|
||||||
:is-collapsed="isCollapsed"
|
:is-collapsed="isCollapsed"
|
||||||
:is-expanded="isExpanded"
|
:is-expanded="isExpanded"
|
||||||
:show-path="showWorkspacePaths"
|
|
||||||
@group-click="handleGhClick"
|
@group-click="handleGhClick"
|
||||||
@group-contextmenu="openGhMenu"
|
@group-contextmenu="openGhMenu"
|
||||||
@toggle-ws-menu="toggleWsMenu"
|
@toggle-ws-menu="toggleWsMenu"
|
||||||
|
|
@ -695,6 +691,14 @@ onBeforeUnmount(() => {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer: settings entry pinned under the session list -->
|
||||||
|
<div class="side-footer">
|
||||||
|
<button class="btn-settings" type="button" @click.stop="emit('openSettings')">
|
||||||
|
<Icon name="settings" />
|
||||||
|
<span>{{ t('settings.title') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Workspace right-click menu (position:fixed) -->
|
<!-- Workspace right-click menu (position:fixed) -->
|
||||||
|
|
@ -745,13 +749,6 @@ onBeforeUnmount(() => {
|
||||||
</span>
|
</span>
|
||||||
{{ t('sidebar.sortRecent') }}
|
{{ t('sidebar.sortRecent') }}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem separator />
|
|
||||||
<MenuItem @click="toggleShowWorkspacePathsFromMenu()">
|
|
||||||
<span class="section-menu-check">
|
|
||||||
<Icon v-if="showWorkspacePaths" name="check" size="sm" />
|
|
||||||
</span>
|
|
||||||
{{ t('sidebar.showWorkspacePaths') }}
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
</Menu>
|
||||||
<!-- Session search dialog (Cmd/Ctrl+K) -->
|
<!-- Session search dialog (Cmd/Ctrl+K) -->
|
||||||
<SearchSessionsDialog
|
<SearchSessionsDialog
|
||||||
|
|
@ -772,24 +769,48 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.side {
|
.side {
|
||||||
border-right: 1px solid var(--line);
|
/* Sidebar sits on its own surface (--color-sidebar-bg, one step off --bg);
|
||||||
background: var(--panel);
|
the 1px hairline on .col still separates it from the conversation pane. */
|
||||||
|
background: var(--color-sidebar-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
/* Anchor content to the right edge: while the container width animates to 0
|
||||||
|
the fixed-width column slides out to the left and is clipped, instead of
|
||||||
|
reflowing. Mirrors the right-side preview panel (App.vue .global-preview). */
|
||||||
|
justify-content: flex-end;
|
||||||
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
/* Alignment contract, inherited by SessionRow and the de-terminalization
|
transition:
|
||||||
rules in style.css: text in the workspace header, the path line and session
|
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
rows all starts at --sb-pad-x + --sb-gutter + --sb-gap from the sidebar edge. */
|
visibility 0.28s;
|
||||||
--sb-pad-x: var(--space-4); /* row horizontal padding */
|
/* Alignment contract, inherited by SessionRow and WorkspaceGroup:
|
||||||
--sb-gutter: 20px; /* leading icon slot (14px folder icon + 6px margin) */
|
- row boxes (hover/selected pills) sit --sb-inset from the sidebar edges;
|
||||||
|
- text/icons start at --sb-pad-x = --sb-inset + 8px row padding;
|
||||||
|
- row titles start at --sb-pad-x + --sb-gutter + --sb-gap. */
|
||||||
|
--sb-inset: var(--space-3); /* row box inset from the sidebar edge */
|
||||||
|
--sb-pad-x: var(--space-5); /* content start x (inset + row padding) */
|
||||||
|
--sb-gutter: 16px; /* leading icon slot (matches the 16px folder icon, so the session title aligns under the workspace name) */
|
||||||
--sb-gap: var(--space-2); /* gap between the icon slot and the text */
|
--sb-gap: var(--space-2); /* gap between the icon slot and the text */
|
||||||
/* Sidebar stays one step above compact UI chrome, but still follows the
|
/* Row hover wash — global --color-hover (lighter than the selected fill;
|
||||||
user-controlled font-size preference. */
|
both translucent, so they sit on any surface). */
|
||||||
--ui-font-size: var(--sidebar-ui-font-size);
|
--sb-hover: var(--color-hover);
|
||||||
|
}
|
||||||
|
/* While dragging the resize handle, follow the pointer 1:1 (same pattern as
|
||||||
|
.global-preview.no-anim in App.vue). */
|
||||||
|
.side.no-anim {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
/* Fully collapsed: width 0 (animated), then drop out of hit-testing / tab
|
||||||
|
order once the transition ends (visibility interpolates to hidden at the
|
||||||
|
end when collapsing, and back to visible immediately when expanding). */
|
||||||
|
.side.collapsed {
|
||||||
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Session column. Width is set inline from the App resize handle. */
|
/* Session column. Width is set inline from the App resize handle; it stays
|
||||||
|
fixed while the collapsing container clips it. Carries the sidebar's right
|
||||||
|
hairline so the border is clipped away together with the content. */
|
||||||
.col {
|
.col {
|
||||||
flex: none;
|
flex: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -797,35 +818,38 @@ onBeforeUnmount(() => {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
container-name: sidebar-col;
|
container-name: sidebar-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header: logo + settings (no border — flows into the workspace list). */
|
/* Header: brand strip (no border — flows into the workspace list). On non-mac
|
||||||
|
platforms the brand sits on the left and the collapse button on the right
|
||||||
|
(justify-content: space-between); on macOS desktop the brand is hidden and
|
||||||
|
the header is a window-drag strip (see below). min-height keeps the 26px
|
||||||
|
control row (50px total with padding) so the list below starts at a stable
|
||||||
|
y. */
|
||||||
.ch {
|
.ch {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: var(--space-3) var(--space-3) var(--space-2);
|
padding: var(--space-3);
|
||||||
|
min-height: calc(26px + 2 * var(--space-3));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
/* macOS desktop: the window uses a hidden title bar, so the traffic lights float
|
/* macOS desktop: the window uses a hidden title bar, so the traffic lights
|
||||||
over the top-left of the sidebar. Push the header content right to clear them,
|
float over the top-left of the sidebar and the resident toggle sits beside
|
||||||
and turn the whole header into the window-drag region — matching the chat
|
them. The header renders no content here (brand hidden) — it is purely a
|
||||||
header. The action buttons and the logo opt out with no-drag so they stay
|
window-drag strip. */
|
||||||
clickable: this is the same no-drag-inside-drag pattern ChatHeader.vue relies
|
|
||||||
on (the previous "drag only the brand area" approach still captured the
|
|
||||||
sibling buttons, because Electron treats a flex-grown drag item's hit area as
|
|
||||||
covering the whole flex line). */
|
|
||||||
.side.macos-desktop .ch {
|
.side.macos-desktop .ch {
|
||||||
padding-left: 80px;
|
padding-left: 80px;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
.side.macos-desktop .ch button,
|
.side.macos-desktop .ch-brand {
|
||||||
.side.macos-desktop .ch-logo {
|
display: none;
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
}
|
||||||
.ch-logo {
|
.ch-logo {
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|
@ -880,22 +904,14 @@ onBeforeUnmount(() => {
|
||||||
.ch-name { display: none; }
|
.ch-name { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action buttons */
|
/* Action buttons — first row of the actions group (New chat + search): rows
|
||||||
.btn-wrap {
|
inside the group stack flush (0 gap, same rhythm as the session list rows);
|
||||||
|
the group's bottom gap lives on .search-wrap. */
|
||||||
|
.btn-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 var(--space-2) var(--space-2);
|
padding: 0 var(--sb-inset);
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
background: var(--panel);
|
|
||||||
border-bottom: 1px solid transparent;
|
|
||||||
transition: border-color var(--duration-base) var(--ease-out),
|
|
||||||
box-shadow var(--duration-base) var(--ease-out);
|
|
||||||
}
|
|
||||||
.btn-wrap--scrolled {
|
|
||||||
border-bottom-color: var(--line);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
.btn-new-chat {
|
.btn-new-chat {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -903,45 +919,61 @@ onBeforeUnmount(() => {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 26px;
|
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
|
||||||
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size-sm);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.btn-new-chat:hover { background: var(--color-surface-sunken); }
|
.btn-new-chat:hover { background: var(--sb-hover); }
|
||||||
.btn-new-chat:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
|
.btn-new-chat:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
|
||||||
.btn-new-chat svg { flex: none; width: 16px; height: 16px; }
|
.btn-new-chat svg { flex: none; }
|
||||||
.btn-new-chat span {
|
.btn-new-chat span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Session search */
|
/* Session search — the wrapper is the last fixed row above the list and
|
||||||
|
carries the scroll-linked seam: its bottom border/shadow only appear once
|
||||||
|
the session list has actually scrolled, so an unscrolled list shows no
|
||||||
|
abrupt boundary. */
|
||||||
|
.search-wrap {
|
||||||
|
padding: 0 var(--sb-inset);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--color-sidebar-bg);
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition: border-color var(--duration-base) var(--ease-out),
|
||||||
|
box-shadow var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
.search-wrap--scrolled {
|
||||||
|
border-bottom-color: var(--line);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
.search {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 26px;
|
width: 100%;
|
||||||
margin: 0 var(--space-2) var(--space-2);
|
margin: 0;
|
||||||
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
|
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.search:hover { background: var(--color-surface-sunken); }
|
.search:hover { background: var(--sb-hover); }
|
||||||
.search:focus-visible {
|
.search:focus-visible {
|
||||||
background: var(--color-surface-sunken);
|
background: var(--sb-hover);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
outline: 2px solid var(--color-accent-bd);
|
outline: 2px solid var(--color-accent-bd);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
|
|
@ -953,29 +985,68 @@ onBeforeUnmount(() => {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: var(--mono);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size-sm);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sessions */
|
/* Sessions — owns the vertical padding around the list (the 12px gap to the
|
||||||
|
search row above and the bottom breathing room). Scrolled content passes
|
||||||
|
through the top padding and clips at the .search-wrap seam. Scrollbar: the
|
||||||
|
4px ::-webkit-scrollbar below; standard scrollbar-width would kill it on
|
||||||
|
Chromium (see the global scrollbar block in style.css). */
|
||||||
.sessions {
|
.sessions {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 var(--space-2) var(--space-2);
|
padding: var(--space-3) var(--sb-inset);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--line) transparent;
|
|
||||||
}
|
}
|
||||||
.sessions::-webkit-scrollbar { width: 4px; }
|
.sessions::-webkit-scrollbar { width: 4px; }
|
||||||
.sessions::-webkit-scrollbar-track { background: transparent; }
|
.sessions::-webkit-scrollbar-track { background: transparent; }
|
||||||
.sessions::-webkit-scrollbar-thumb {
|
.sessions::-webkit-scrollbar-thumb {
|
||||||
background: var(--line);
|
/* Neutral, text-derived translucency — adapts to both schemes and sits
|
||||||
border-radius: var(--radius-xs);
|
quietly on the sidebar surface (no accent tint on hover). */
|
||||||
|
background: color-mix(in srgb, var(--color-text) 12%, transparent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
.sessions::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--color-text) 25%, transparent); }
|
||||||
|
|
||||||
|
/* Footer — settings entry pinned under the session list. Same list-style
|
||||||
|
control family as search / New chat (full-width, left-aligned, hover
|
||||||
|
sunken — not a Button). */
|
||||||
|
.side-footer {
|
||||||
|
flex: none;
|
||||||
|
padding: var(--space-2) var(--sb-inset);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.btn-settings {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--ui-font-size-sm);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.btn-settings:hover { background: var(--sb-hover); }
|
||||||
|
.btn-settings:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
|
||||||
|
.btn-settings svg { flex: none; }
|
||||||
|
.btn-settings span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sessions::-webkit-scrollbar-thumb:hover { background: var(--color-accent-bd); }
|
|
||||||
|
|
||||||
/* Section label — heads the workspace list below the action buttons. Aligns
|
/* Section label — heads the workspace list below the action buttons. Aligns
|
||||||
with the rows' leading inset (--sb-pad-x) so it reads as the list's title. */
|
with the rows' leading inset (--sb-pad-x) so it reads as the list's title. */
|
||||||
|
|
@ -985,9 +1056,9 @@ onBeforeUnmount(() => {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 var(--space-3) var(--space-1) var(--space-2);
|
padding: 0 var(--space-3) var(--space-1) var(--space-2);
|
||||||
font-size: var(--text-sm);
|
font-family: var(--font-ui);
|
||||||
font-weight: 500;
|
font-size: var(--text-xs);
|
||||||
letter-spacing: .08em;
|
font-weight: var(--weight-regular);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,6 @@ const props = defineProps<{
|
||||||
wsMenuOpenId: string | null;
|
wsMenuOpenId: string | null;
|
||||||
/** True while this group is the active drag source (drag-to-reorder). */
|
/** True while this group is the active drag source (drag-to-reorder). */
|
||||||
dragging: boolean;
|
dragging: boolean;
|
||||||
/** When true, render the workspace root path as a stable subtitle line. */
|
|
||||||
showPath: boolean;
|
|
||||||
isCollapsed: (id: string) => boolean;
|
isCollapsed: (id: string) => boolean;
|
||||||
/** When true, render all loaded sessions; otherwise only the first page
|
/** When true, render all loaded sessions; otherwise only the first page
|
||||||
* (`group.initialCount`). Drives the in-group show-more / show-less toggle. */
|
* (`group.initialCount`). Drives the in-group show-more / show-less toggle. */
|
||||||
|
|
@ -109,7 +107,7 @@ function onHeaderDragStart(event: DragEvent): void {
|
||||||
<div class="group" :class="{ dragging }">
|
<div class="group" :class="{ dragging }">
|
||||||
<div
|
<div
|
||||||
class="gh"
|
class="gh"
|
||||||
:class="{ on: group.workspace.id === activeWorkspaceId, collapsed: isCollapsed(group.workspace.id), 'show-path': showPath }"
|
:class="{ on: group.workspace.id === activeWorkspaceId, collapsed: isCollapsed(group.workspace.id) }"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@click.stop="emit('groupClick', group.workspace.id, $event)"
|
@click.stop="emit('groupClick', group.workspace.id, $event)"
|
||||||
@contextmenu="emit('groupContextmenu', group.workspace, $event)"
|
@contextmenu="emit('groupContextmenu', group.workspace, $event)"
|
||||||
|
|
@ -118,14 +116,13 @@ function onHeaderDragStart(event: DragEvent): void {
|
||||||
>
|
>
|
||||||
<div class="gh-top">
|
<div class="gh-top">
|
||||||
<!-- Folder icon -->
|
<!-- Folder icon -->
|
||||||
<Icon v-if="isCollapsed(group.workspace.id)" class="gh-folder" name="folder-closed" size="sm" />
|
<Icon v-if="isCollapsed(group.workspace.id)" class="gh-folder" name="folder-closed" />
|
||||||
<Icon v-else class="gh-folder" name="folder" size="sm" />
|
<Icon v-else class="gh-folder" name="folder" />
|
||||||
|
|
||||||
<!-- Workspace name -->
|
<!-- Workspace name — hover reveals the full root path -->
|
||||||
<span
|
<Tooltip v-if="renamingId !== group.workspace.id" :text="group.workspace.root">
|
||||||
v-if="renamingId !== group.workspace.id"
|
<span class="gh-name">{{ group.workspace.name }}</span>
|
||||||
class="gh-name"
|
</Tooltip>
|
||||||
>{{ group.workspace.name }}</span>
|
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
:ref="setRenameInputRef"
|
:ref="setRenameInputRef"
|
||||||
|
|
@ -138,31 +135,36 @@ function onHeaderDragStart(event: DragEvent): void {
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<IconButton
|
<!-- Hover actions — float over the row's right edge (no reserved
|
||||||
class="gh-more"
|
layout space, the name gets the full row width when idle). Hidden
|
||||||
|
while renaming so the floating buttons can't cover the input. -->
|
||||||
|
<div
|
||||||
|
v-if="renamingId !== group.workspace.id"
|
||||||
|
class="gh-actions"
|
||||||
:class="{ open: wsMenuOpenId === group.workspace.id }"
|
:class="{ open: wsMenuOpenId === group.workspace.id }"
|
||||||
size="sm"
|
|
||||||
:label="t('sidebar.options')"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
:aria-expanded="wsMenuOpenId === group.workspace.id"
|
|
||||||
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
|
|
||||||
>
|
>
|
||||||
<Icon name="dots-horizontal" size="sm" />
|
<IconButton
|
||||||
</IconButton>
|
class="gh-more"
|
||||||
|
:class="{ open: wsMenuOpenId === group.workspace.id }"
|
||||||
|
size="sm"
|
||||||
|
:label="t('sidebar.options')"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="wsMenuOpenId === group.workspace.id"
|
||||||
|
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
|
||||||
|
>
|
||||||
|
<Icon name="dots-horizontal" />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
class="gh-add"
|
class="gh-add"
|
||||||
size="sm"
|
size="sm"
|
||||||
:label="t('workspace.newInGroup')"
|
:label="t('workspace.newInGroup')"
|
||||||
@click.stop="emit('createInWorkspace', group.workspace.id)"
|
@click.stop="emit('createInWorkspace', group.workspace.id)"
|
||||||
>
|
>
|
||||||
<Icon name="chat-new" />
|
<Icon name="chat-new" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tooltip :text="group.workspace.root">
|
|
||||||
<div class="gh-path">{{ group.workspace.shortPath || group.workspace.root }}</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="group-sessions"
|
class="group-sessions"
|
||||||
|
|
@ -212,8 +214,8 @@ function onHeaderDragStart(event: DragEvent): void {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Workspace group. The --sb-* custom properties are inherited from .side in
|
/* Workspace group. The --sb-* custom properties are inherited from .side in
|
||||||
Sidebar.vue, so they don't need to be redeclared here. */
|
Sidebar.vue, so they don't need to be redeclared here. Groups stack flush —
|
||||||
.group { padding-bottom: var(--space-2); }
|
no bottom gap. */
|
||||||
.group.dragging { opacity: 0.45; }
|
.group.dragging { opacity: 0.45; }
|
||||||
|
|
||||||
/* Session list: collapses/expands via a height transition. `interpolate-size:
|
/* Session list: collapses/expands via a height transition. `interpolate-size:
|
||||||
|
|
@ -230,15 +232,14 @@ function onHeaderDragStart(event: DragEvent): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Workspace header — an inset rounded row that mirrors the session-row inset
|
/* Workspace header — an inset rounded row that mirrors the session-row inset
|
||||||
(6px margin + 10px padding), so the folder icon lands at --sb-pad-x and the
|
(container --sb-inset + row padding), so the folder icon lands at --sb-pad-x
|
||||||
name lines up with the session titles below. Hover washes the whole header
|
and the name lines up with the session titles below. Hover washes the whole
|
||||||
(name row + path) in the sunken surface. */
|
header in the row hover fill. */
|
||||||
.gh {
|
.gh {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-1) var(--space-2);
|
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|
@ -249,24 +250,28 @@ function onHeaderDragStart(event: DragEvent): void {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
.gh:active { cursor: grabbing; }
|
.gh:active { cursor: grabbing; }
|
||||||
.gh:hover { background: var(--color-surface-sunken); }
|
.gh:hover { background: var(--sb-hover, var(--color-surface-sunken)); }
|
||||||
.gh-top {
|
.gh-top {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sb-gap);
|
gap: var(--sb-gap);
|
||||||
|
/* Header height is font-driven: name line-height (13×1.25≈16px) + 2×5px
|
||||||
|
.gh padding ≈ 26px. The floating .gh-actions never contribute to height. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.gh-folder {
|
.gh-folder {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
/* 14px icon + margin fills the --sb-gutter icon slot */
|
|
||||||
margin-right: calc(var(--sb-gutter) - 14px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Group title — quiet by design: regular weight (no bold), muted color (one
|
||||||
|
step lighter than the session titles), so group heads read as grouping
|
||||||
|
labels rather than list content. */
|
||||||
.gh-name {
|
.gh-name {
|
||||||
font-size: var(--ui-font-size-lg);
|
font-size: var(--ui-font-size-sm);
|
||||||
font-weight: var(--weight-medium);
|
line-height: var(--leading-tight);
|
||||||
color: var(--color-text);
|
color: var(--color-text-muted);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -274,77 +279,86 @@ function onHeaderDragStart(event: DragEvent): void {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.gh-path {
|
|
||||||
color: var(--color-text-faint);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding-left: calc(var(--sb-gutter) + var(--sb-gap));
|
|
||||||
font-size: var(--ui-font-size-xs);
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
transition: max-height var(--duration-base) var(--ease-out),
|
|
||||||
opacity var(--duration-base) var(--ease-out);
|
|
||||||
}
|
|
||||||
/* Path subtitle — revealed only when the section-header "show paths" toggle is
|
|
||||||
on (`.show-path`) or the group is collapsed. Driven by explicit toggle state
|
|
||||||
rather than hover/focus, so the header height never shifts under the pointer
|
|
||||||
(a11y: stable layout) and the path stays reachable for touch/keyboard users. */
|
|
||||||
.gh.show-path .gh-path,
|
|
||||||
.gh.collapsed .gh-path {
|
|
||||||
max-height: 1.4em;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* More + add buttons — hidden until hover (or while the more menu is open /
|
/* More + add buttons — float over the row's right edge instead of reserving
|
||||||
focused). `.gh .gh-more` / `.gh .gh-add` out-specificity IconButton's display
|
layout space, so the name can use the full row width when idle (no
|
||||||
so the hidden default wins. */
|
truncation caused by invisible buttons). Revealed on hover / keyboard focus
|
||||||
.gh .gh-more,
|
/ while the more menu is open; the backing stacks the row hover wash on the
|
||||||
.gh .gh-add {
|
sidebar surface so the overlapped title tail doesn't bleed through. */
|
||||||
|
.gh-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding-left: var(--space-1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
isolation: isolate;
|
||||||
|
/* Opaque sidebar surface — hides the overlapped name tail. The ::after
|
||||||
|
hover wash sits above this (still behind the buttons) so the layer reads
|
||||||
|
seamless with the row. */
|
||||||
|
background: var(--color-sidebar-bg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.gh:hover .gh-more,
|
/* Row hover wash — only while the row is actually hovered. Painted above the
|
||||||
.gh:hover .gh-add,
|
element background (z-index 0) but below the buttons (z-index 1). */
|
||||||
.gh:focus-within .gh-more,
|
.gh-actions::after {
|
||||||
.gh:focus-within .gh-add,
|
content: '';
|
||||||
.gh-more.open,
|
position: absolute;
|
||||||
.gh-more:focus-visible,
|
inset: 0;
|
||||||
.gh-add:focus-visible {
|
z-index: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.gh:hover .gh-actions::after {
|
||||||
|
background: var(--sb-hover, var(--color-surface-sunken));
|
||||||
|
}
|
||||||
|
.gh-actions > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.gh:hover .gh-actions,
|
||||||
|
.gh:focus-within .gh-actions,
|
||||||
|
.gh-actions.open {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.gh-more.open { color: var(--color-text); background: var(--color-line); }
|
.gh-more.open { color: var(--color-text); background: var(--color-line); }
|
||||||
|
|
||||||
.group-empty {
|
.group-empty {
|
||||||
padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) + var(--sb-gutter) + var(--sb-gap));
|
/* Left padding lands the text at the same x as session titles / the
|
||||||
|
show-more label: (pad-x − inset) row padding + gutter + gap. */
|
||||||
|
padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) - var(--sb-inset) + var(--sb-gutter) + var(--sb-gap));
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--color-text-faint);
|
color: var(--color-text-faint);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
/* Show-more / show-less — a session-row-shaped compact list control (§07). The
|
/* Show-more / show-less — a session-row-shaped compact list control (§07). The
|
||||||
empty lead slot mirrors a session row's status gutter, so the label text lands
|
empty lead slot mirrors a session row's status gutter, so the label text lands
|
||||||
at the exact same x as the session titles (--sb-pad-x + --sb-gutter + --sb-gap
|
at the exact same x as the session titles (--sb-pad-x + --sb-gutter + --sb-gap
|
||||||
from the sidebar edge). Hover washes the row in the sunken surface, matching
|
from the sidebar edge). Hover washes the row in the shared row hover fill,
|
||||||
New chat / session rows; no text recolor. */
|
matching New chat / session rows; no text recolor. */
|
||||||
.show-more {
|
.show-more {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sb-gap);
|
gap: var(--sb-gap);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 26px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
|
padding: 8px calc(var(--sb-pad-x) - var(--sb-inset));
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text-muted);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.show-more:hover { background: var(--color-surface-sunken); }
|
.show-more:hover { background: var(--sb-hover, var(--color-surface-sunken)); }
|
||||||
.show-more:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
|
.show-more:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
|
||||||
.show-more-lead { width: var(--sb-gutter); flex: none; }
|
.show-more-lead { width: var(--sb-gutter); flex: none; }
|
||||||
.show-more-label {
|
.show-more-label {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import Badge from '../ui/Badge.vue';
|
||||||
import Button from '../ui/Button.vue';
|
import Button from '../ui/Button.vue';
|
||||||
import IconButton from '../ui/IconButton.vue';
|
import IconButton from '../ui/IconButton.vue';
|
||||||
import Icon from '../ui/Icon.vue';
|
import Icon from '../ui/Icon.vue';
|
||||||
|
import Kbd from '../ui/Kbd.vue';
|
||||||
import Tooltip from '../ui/Tooltip.vue';
|
import Tooltip from '../ui/Tooltip.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -314,20 +315,20 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
|
||||||
:loading="pendingAction === `option:${opt.label}`"
|
:loading="pendingAction === `option:${opt.label}`"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@click="approveOption(opt.label)"
|
@click="approveOption(opt.label)"
|
||||||
>{{ opt.label }}<span class="k">[{{ i + 1 }}]</span></Button>
|
>{{ opt.label }}<Kbd class="k" :keys="[String(i + 1)]" /></Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
<Button v-else class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approvePlan'" :disabled="busy" @click="approvePlan">{{ t('approval.approvePlan') }}<span class="k">[1]</span></Button>
|
<Button v-else class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approvePlan'" :disabled="busy" @click="approvePlan">{{ t('approval.approvePlan') }}<Kbd class="k" :keys="['1']" /></Button>
|
||||||
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="revisePlan">{{ t('approval.revise') }}<span v-if="planReview.options.length === 0" class="k">[2]</span></Button>
|
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="revisePlan">{{ t('approval.revise') }}<Kbd v-if="planReview.options.length === 0" class="k" :keys="['2']" /></Button>
|
||||||
<Button class="kbtn" size="sm" variant="danger-soft" :loading="pendingAction === 'rejectAndExit'" :disabled="busy" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<span v-if="planReview.options.length === 0" class="k">[3]</span></Button>
|
<Button class="kbtn" size="sm" variant="danger-soft" :loading="pendingAction === 'rejectAndExit'" :disabled="busy" @click="rejectAndExitPlan">{{ t('approval.rejectAndExit') }}<Kbd v-if="planReview.options.length === 0" class="k" :keys="['3']" /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- default actions row -->
|
<!-- default actions row -->
|
||||||
<div v-else class="abtn">
|
<div v-else class="abtn">
|
||||||
<Button class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approve'" :disabled="busy" @click="approve">{{ t('approval.approve') }}<span class="k">[1]</span></Button>
|
<Button class="kbtn" size="sm" variant="primary" :loading="pendingAction === 'approve'" :disabled="busy" @click="approve">{{ t('approval.approve') }}<Kbd class="k" :keys="['1']" /></Button>
|
||||||
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'approveSession'" :disabled="busy" @click="approveSession">{{ t('approval.approveSession') }}<span class="k">[2]</span></Button>
|
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'approveSession'" :disabled="busy" @click="approveSession">{{ t('approval.approveSession') }}<Kbd class="k" :keys="['2']" /></Button>
|
||||||
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'reject'" :disabled="busy" @click="reject">{{ t('approval.reject') }}<span class="k">[3]</span></Button>
|
<Button class="kbtn" size="sm" variant="secondary" :loading="pendingAction === 'reject'" :disabled="busy" @click="reject">{{ t('approval.reject') }}<Kbd class="k" :keys="['3']" /></Button>
|
||||||
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="openFeedback">{{ t('approval.feedback') }}<span class="k">[4]</span></Button>
|
<Button class="kbtn" size="sm" variant="secondary" :disabled="busy" @click="openFeedback">{{ t('approval.feedback') }}<Kbd class="k" :keys="['4']" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -554,7 +555,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.plan-actions { flex-wrap: wrap; }
|
.plan-actions { flex-wrap: wrap; }
|
||||||
.k { margin-left: var(--space-2); font: var(--text-xs) var(--font-mono); opacity: .7; }
|
.k { opacity: .75; }
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
MOBILE (≤640px): the card spans the full chat column, inner previews scroll
|
MOBILE (≤640px): the card spans the full chat column, inner previews scroll
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ import Pill from '../ui/Pill.vue';
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
|
/** True while the empty-composer first prompt is being created + submitted.
|
||||||
|
* Covers the gap where draft-session creation already selected the new
|
||||||
|
* session (empty state → dock) before the first prompt is submitted. */
|
||||||
|
starting?: boolean;
|
||||||
queued?: QueuedPromptView[];
|
queued?: QueuedPromptView[];
|
||||||
searchFiles?: (q: string) => Promise<FileItem[]>;
|
searchFiles?: (q: string) => Promise<FileItem[]>;
|
||||||
uploadImage?: (file: Blob, name?: string) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
|
uploadImage?: (file: Blob, name?: string) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
|
||||||
|
|
@ -251,10 +255,12 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
|
||||||
:plan-mode="planMode"
|
:plan-mode="planMode"
|
||||||
:swarm-mode="swarmMode"
|
:swarm-mode="swarmMode"
|
||||||
:goal-mode="goalMode"
|
:goal-mode="goalMode"
|
||||||
|
:goal="goal"
|
||||||
:activation-badges="activationBadges"
|
:activation-badges="activationBadges"
|
||||||
:models="models"
|
:models="models"
|
||||||
:starred-ids="starredIds"
|
:starred-ids="starredIds"
|
||||||
:skills="skills"
|
:skills="skills"
|
||||||
|
:starting="starting"
|
||||||
@submit="emit('submit', $event)"
|
@submit="emit('submit', $event)"
|
||||||
@steer="emit('steer', $event)"
|
@steer="emit('steer', $event)"
|
||||||
@command="emit('command', $event)"
|
@command="emit('command', $event)"
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,25 @@ const behind = computed(() => props.behind ?? 0);
|
||||||
const adds = computed(() => props.gitDiffStats?.totalAdditions ?? 0);
|
const adds = computed(() => props.gitDiffStats?.totalAdditions ?? 0);
|
||||||
const dels = computed(() => props.gitDiffStats?.totalDeletions ?? 0);
|
const dels = computed(() => props.gitDiffStats?.totalDeletions ?? 0);
|
||||||
const hasLineStats = computed(() => adds.value > 0 || dels.value > 0);
|
const hasLineStats = computed(() => adds.value > 0 || dels.value > 0);
|
||||||
|
const PR_STATE_LABEL_KEYS: Record<string, string> = {
|
||||||
|
open: 'header.prStatusOpen',
|
||||||
|
closed: 'header.prStatusClosed',
|
||||||
|
merged: 'header.prStatusMerged',
|
||||||
|
draft: 'header.prStatusDraft',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizedPrState(state: string): string {
|
||||||
|
return state.trim().toLowerCase().replaceAll('_', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function prStateClass(state: string): string {
|
||||||
|
const stateClass = normalizedPrState(state);
|
||||||
|
return PR_STATE_LABEL_KEYS[stateClass] ? `pr-${stateClass}` : 'pr-unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function prStateLabel(state: string): string {
|
||||||
|
return t(PR_STATE_LABEL_KEYS[normalizedPrState(state)] ?? 'header.prStatusUnknown');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// More-menu (kebab dropdown)
|
// More-menu (kebab dropdown)
|
||||||
|
|
@ -295,11 +314,11 @@ async function startArchive(): Promise<void> {
|
||||||
v-if="pr"
|
v-if="pr"
|
||||||
type="button"
|
type="button"
|
||||||
class="ch-pill ch-pr"
|
class="ch-pill ch-pr"
|
||||||
:class="`pr-${pr.state}`"
|
:class="prStateClass(pr.state)"
|
||||||
@click="pr && emit('openPr', pr.url)"
|
@click="pr && emit('openPr', pr.url)"
|
||||||
>
|
>
|
||||||
<Icon name="git-pull-request" size="sm" />
|
<Icon name="git-pull-request" size="sm" />
|
||||||
<span>PR #{{ pr.number }} · {{ pr.state }}</span>
|
<span>PR #{{ pr.number }} · {{ prStateLabel(pr.state) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -420,6 +439,7 @@ async function startArchive(): Promise<void> {
|
||||||
.ch-pr.pr-merged { color: var(--color-done); border-color: var(--color-done-bd); background: var(--color-done-soft); }
|
.ch-pr.pr-merged { color: var(--color-done); border-color: var(--color-done-bd); background: var(--color-done-soft); }
|
||||||
.ch-pr.pr-closed { color: var(--color-danger); border-color: var(--color-danger-bd); background: var(--color-danger-soft); }
|
.ch-pr.pr-closed { color: var(--color-danger); border-color: var(--color-danger-bd); background: var(--color-danger-soft); }
|
||||||
.ch-pr.pr-draft { color: var(--color-text-muted); border-color: var(--color-line-strong); background: var(--color-surface-sunken); }
|
.ch-pr.pr-draft { color: var(--color-text-muted); border-color: var(--color-line-strong); background: var(--color-surface-sunken); }
|
||||||
|
.ch-pr.pr-unknown { color: var(--color-text-muted); border-color: var(--color-line-strong); background: var(--color-surface-sunken); }
|
||||||
.ch-pr:hover { border-color: var(--color-line-strong); }
|
.ch-pr:hover { border-color: var(--color-line-strong); }
|
||||||
|
|
||||||
/* Fixed more-menu, anchored to the kebab trigger. Surface / items come from
|
/* Fixed more-menu, anchored to the kebab trigger. Surface / items come from
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<!-- apps/kimi-web/src/components/chat/Composer.vue -->
|
<!-- apps/kimi-web/src/components/chat/Composer.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { measureNaturalWidth, prepareWithSegments } from '@chenglou/pretext';
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import SlashMenu from './SlashMenu.vue';
|
import SlashMenu from './SlashMenu.vue';
|
||||||
|
|
@ -7,7 +8,7 @@ import MentionMenu from './MentionMenu.vue';
|
||||||
import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands';
|
import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands';
|
||||||
import type { FileItem } from './MentionMenu.vue';
|
import type { FileItem } from './MentionMenu.vue';
|
||||||
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
|
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
|
||||||
import type { AppModel, AppSkill, ThinkingLevel } from '../../api/types';
|
import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types';
|
||||||
import {
|
import {
|
||||||
coerceThinkingForModel,
|
coerceThinkingForModel,
|
||||||
commitLevel,
|
commitLevel,
|
||||||
|
|
@ -22,6 +23,7 @@ import { useMentionMenu } from '../../composables/useMentionMenu';
|
||||||
import { useComposerDraft } from '../../composables/useComposerDraft';
|
import { useComposerDraft } from '../../composables/useComposerDraft';
|
||||||
import { useAttachmentUpload } from '../../composables/useAttachmentUpload';
|
import { useAttachmentUpload } from '../../composables/useAttachmentUpload';
|
||||||
import Spinner from '../ui/Spinner.vue';
|
import Spinner from '../ui/Spinner.vue';
|
||||||
|
import Button from '../ui/Button.vue';
|
||||||
import IconButton from '../ui/IconButton.vue';
|
import IconButton from '../ui/IconButton.vue';
|
||||||
import Icon from '../ui/Icon.vue';
|
import Icon from '../ui/Icon.vue';
|
||||||
import ContextRing from '../ui/ContextRing.vue';
|
import ContextRing from '../ui/ContextRing.vue';
|
||||||
|
|
@ -33,6 +35,9 @@ import Tooltip from '../ui/Tooltip.vue';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
|
/** True while the empty-composer first prompt is being created + submitted.
|
||||||
|
* Disables the textarea and swaps the send button for a spinner. */
|
||||||
|
starting?: boolean;
|
||||||
/** Active session id — scopes the persisted unsent draft (per session). */
|
/** Active session id — scopes the persisted unsent draft (per session). */
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
queued?: QueuedPromptView[];
|
queued?: QueuedPromptView[];
|
||||||
|
|
@ -45,6 +50,7 @@ const props = withDefaults(defineProps<{
|
||||||
planMode?: boolean;
|
planMode?: boolean;
|
||||||
swarmMode?: boolean;
|
swarmMode?: boolean;
|
||||||
goalMode?: boolean;
|
goalMode?: boolean;
|
||||||
|
goal?: AppGoal | null;
|
||||||
activationBadges?: ActivationBadges;
|
activationBadges?: ActivationBadges;
|
||||||
/** Available models for the quick-switch dropdown. */
|
/** Available models for the quick-switch dropdown. */
|
||||||
models?: AppModel[];
|
models?: AppModel[];
|
||||||
|
|
@ -56,6 +62,7 @@ const props = withDefaults(defineProps<{
|
||||||
hideContext?: boolean;
|
hideContext?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
running: false,
|
running: false,
|
||||||
|
starting: false,
|
||||||
queued: () => [],
|
queued: () => [],
|
||||||
searchFiles: undefined,
|
searchFiles: undefined,
|
||||||
uploadImage: undefined,
|
uploadImage: undefined,
|
||||||
|
|
@ -65,11 +72,13 @@ const props = withDefaults(defineProps<{
|
||||||
});
|
});
|
||||||
|
|
||||||
const placeholder = computed(() =>
|
const placeholder = computed(() =>
|
||||||
props.running
|
props.starting
|
||||||
? t('composer.placeholderRunning')
|
? t('composer.starting')
|
||||||
: props.goalMode
|
: props.running
|
||||||
? t('status.goalPlaceholder')
|
? t('composer.placeholderRunning')
|
||||||
: t('composer.placeholder')
|
: props.goalMode
|
||||||
|
? t('status.goalPlaceholder')
|
||||||
|
: t('composer.placeholder')
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -94,7 +103,7 @@ const emit = defineEmits<{
|
||||||
selectModel: [modelId: string];
|
selectModel: [modelId: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Textarea + per-session draft persistence — see useComposerDraft.
|
// Textarea + per-session draft persistence — see useComposerDraft.
|
||||||
|
|
@ -646,12 +655,20 @@ function setThinkingSegment(draft: string): void {
|
||||||
if (thinkingReadonly.value) return;
|
if (thinkingReadonly.value) return;
|
||||||
emit('setThinking', commitLevel(currentModel.value, draft));
|
emit('setThinking', commitLevel(currentModel.value, draft));
|
||||||
}
|
}
|
||||||
|
function thinkingSegmentLabel(segment: string): string {
|
||||||
|
if (segment === 'on') return t('status.thinkingOn');
|
||||||
|
if (segment === 'off') return t('status.thinkingOff');
|
||||||
|
return effortLabel(segment);
|
||||||
|
}
|
||||||
|
|
||||||
// Plan toggle
|
// Plan toggle
|
||||||
const planOn = computed(() => props.planMode === true);
|
const planOn = computed(() => props.planMode === true);
|
||||||
const swarmOn = computed(() => props.swarmMode === true);
|
const swarmOn = computed(() => props.swarmMode === true);
|
||||||
const goalActive = computed(() => props.activationBadges?.goal !== null);
|
const goalStatus = computed(() => props.goal?.status ?? props.activationBadges?.goal?.status ?? null);
|
||||||
|
const goalActive = computed(() => goalStatus.value !== null && goalStatus.value !== 'complete');
|
||||||
const goalArmed = computed(() => goalActive.value || props.goalMode === true);
|
const goalArmed = computed(() => goalActive.value || props.goalMode === true);
|
||||||
|
const goalCanPause = computed(() => goalStatus.value === 'active');
|
||||||
|
const goalCanResume = computed(() => goalStatus.value === 'paused' || goalStatus.value === 'blocked');
|
||||||
|
|
||||||
// Modes selector (plan / goal / swarm) — the popover that replaces the bare
|
// Modes selector (plan / goal / swarm) — the popover that replaces the bare
|
||||||
// "plan" pill. Plan/Swarm are real client toggles; goal reflects agent-driven
|
// "plan" pill. Plan/Swarm are real client toggles; goal reflects agent-driven
|
||||||
|
|
@ -696,6 +713,87 @@ const PERM_MODES: { mode: PermissionMode; color: string; labelKey: string; descK
|
||||||
{ mode: 'yolo', color: 'var(--color-warning)', labelKey: 'status.permissionYolo', descKey: 'status.permissionYoloDesc' },
|
{ mode: 'yolo', color: 'var(--color-warning)', labelKey: 'status.permissionYolo', descKey: 'status.permissionYoloDesc' },
|
||||||
{ mode: 'auto', color: 'var(--color-danger)', labelKey: 'status.permissionAuto', descKey: 'status.permissionAutoDesc' },
|
{ mode: 'auto', color: 'var(--color-danger)', labelKey: 'status.permissionAuto', descKey: 'status.permissionAutoDesc' },
|
||||||
];
|
];
|
||||||
|
const MODE_DESC_KEYS = ['status.planDesc', 'status.swarmDesc', 'status.goalDesc'] as const;
|
||||||
|
|
||||||
|
const menuMeasureRef = ref<HTMLElement | null>(null);
|
||||||
|
const permissionDescriptionWidth = ref('');
|
||||||
|
const modeDescriptionWidth = ref('');
|
||||||
|
function menuDescStyle(width: string): Record<string, string> {
|
||||||
|
const style: Record<string, string> = {};
|
||||||
|
if (width) style['--composer-menu-desc-width'] = width;
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
const permissionMenuStyle = computed<Record<string, string>>(() => menuDescStyle(permissionDescriptionWidth.value));
|
||||||
|
const modeMenuMeasureStyle = computed<Record<string, string>>(() => menuDescStyle(modeDescriptionWidth.value));
|
||||||
|
const modesMenuInlineStyle = computed<Record<string, string>>(() => ({
|
||||||
|
...modesMenuStyle.value,
|
||||||
|
...modeMenuMeasureStyle.value,
|
||||||
|
}));
|
||||||
|
let menuMeasureFrame: number | null = null;
|
||||||
|
|
||||||
|
function cssPx(value: string): number {
|
||||||
|
const n = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasFont(style: CSSStyleDeclaration): string {
|
||||||
|
return `${style.fontStyle || 'normal'} ${style.fontWeight || '400'} ${style.fontSize} ${style.fontFamily}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function letterSpacingPx(style: CSSStyleDeclaration): number {
|
||||||
|
return style.letterSpacing === 'normal' ? 0 : cssPx(style.letterSpacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureTextWidth(text: string, style: CSSStyleDeclaration): number {
|
||||||
|
if (!text) return 0;
|
||||||
|
const prepared = prepareWithSegments(text, canvasFont(style), {
|
||||||
|
letterSpacing: letterSpacingPx(style),
|
||||||
|
});
|
||||||
|
return measureNaturalWidth(prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureMenuDescriptions(): void {
|
||||||
|
const probe = menuMeasureRef.value?.querySelector<HTMLElement>('.pd-desc');
|
||||||
|
if (!probe) return;
|
||||||
|
const style = getComputedStyle(probe);
|
||||||
|
const permissionWidth = Math.max(
|
||||||
|
0,
|
||||||
|
...PERM_MODES.map((opt) => measureTextWidth(t(opt.descKey), style)),
|
||||||
|
);
|
||||||
|
const modeWidth = Math.max(
|
||||||
|
0,
|
||||||
|
...MODE_DESC_KEYS.map((key) => measureTextWidth(t(key), style)),
|
||||||
|
);
|
||||||
|
permissionDescriptionWidth.value = permissionWidth > 0 ? `${Math.ceil(permissionWidth)}px` : '';
|
||||||
|
modeDescriptionWidth.value = modeWidth > 0 ? `${Math.ceil(modeWidth)}px` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMenuDescriptionMeasure(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (menuMeasureFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(menuMeasureFrame);
|
||||||
|
}
|
||||||
|
void nextTick(() => {
|
||||||
|
menuMeasureFrame = window.requestAnimationFrame(() => {
|
||||||
|
menuMeasureFrame = null;
|
||||||
|
measureMenuDescriptions();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(locale, scheduleMenuDescriptionMeasure, { immediate: true });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scheduleMenuDescriptionMeasure();
|
||||||
|
void document.fonts?.ready.then(scheduleMenuDescriptionMeasure);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (menuMeasureFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(menuMeasureFrame);
|
||||||
|
menuMeasureFrame = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function choosePermission(mode: PermissionMode): void {
|
function choosePermission(mode: PermissionMode): void {
|
||||||
emit('setPermission', mode);
|
emit('setPermission', mode);
|
||||||
|
|
@ -821,6 +919,7 @@ function selectModel(modelId: string): void {
|
||||||
v-model="text"
|
v-model="text"
|
||||||
class="ph"
|
class="ph"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
|
:disabled="starting"
|
||||||
rows="1"
|
rows="1"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
@compositionstart="handleCompositionStart"
|
@compositionstart="handleCompositionStart"
|
||||||
|
|
@ -853,6 +952,10 @@ function selectModel(modelId: string): void {
|
||||||
|
|
||||||
<!-- Bottom toolbar — split into individual controls -->
|
<!-- Bottom toolbar — split into individual controls -->
|
||||||
<div ref="toolbarRef" class="toolbar">
|
<div ref="toolbarRef" class="toolbar">
|
||||||
|
<div ref="menuMeasureRef" class="menu-measure" aria-hidden="true">
|
||||||
|
<span class="pd-desc" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Left: attach + permission + plan -->
|
<!-- Left: attach + permission + plan -->
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
@ -877,7 +980,13 @@ function selectModel(modelId: string): void {
|
||||||
>{{ permLabel }}</span>
|
>{{ permLabel }}</span>
|
||||||
|
|
||||||
<!-- Permission dropdown — anchored to the toolbar left side -->
|
<!-- Permission dropdown — anchored to the toolbar left side -->
|
||||||
<div v-if="permDropdownOpen && status" class="perm-dropdown" role="menu" @click.stop>
|
<div
|
||||||
|
v-if="permDropdownOpen && status"
|
||||||
|
class="perm-dropdown"
|
||||||
|
:style="permissionMenuStyle"
|
||||||
|
role="menu"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="opt in PERM_MODES"
|
v-for="opt in PERM_MODES"
|
||||||
:key="opt.mode"
|
:key="opt.mode"
|
||||||
|
|
@ -908,15 +1017,23 @@ function selectModel(modelId: string): void {
|
||||||
<span v-if="goalArmed" class="mode-tag">{{ t('status.goalLabel') }}</span>
|
<span v-if="goalArmed" class="mode-tag">{{ t('status.goalLabel') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="modesOpen" ref="modesMenuRef" class="modes-menu" :style="modesMenuStyle">
|
<div v-if="modesOpen" ref="modesMenuRef" class="modes-menu" :style="modesMenuInlineStyle" role="menu">
|
||||||
<!-- Plan — functional client toggle -->
|
<!-- Plan — functional client toggle -->
|
||||||
<button type="button" class="mode-row" :class="{ on: planOn }" @click="emit('togglePlan')">
|
<button type="button" class="mode-row" :class="{ on: planOn }" role="menuitem" @click="emit('togglePlan')">
|
||||||
<span class="mode-row-name">{{ t('status.planLabel') }}</span>
|
<span class="mode-row-icon"><Icon name="file-edit" size="sm" /></span>
|
||||||
|
<span class="mode-row-info">
|
||||||
|
<span class="mode-row-name">{{ t('status.planLabel') }}</span>
|
||||||
|
<span class="mode-row-desc">{{ t('status.planDesc') }}</span>
|
||||||
|
</span>
|
||||||
<span class="mode-switch" :class="{ on: planOn }"><span class="mode-knob" /></span>
|
<span class="mode-switch" :class="{ on: planOn }"><span class="mode-knob" /></span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Swarm — functional client toggle -->
|
<!-- Swarm — functional client toggle -->
|
||||||
<button type="button" class="mode-row" :class="{ on: swarmOn }" @click="emit('toggleSwarm')">
|
<button type="button" class="mode-row" :class="{ on: swarmOn }" role="menuitem" @click="emit('toggleSwarm')">
|
||||||
<span class="mode-row-name">{{ t('status.swarmLabel') }}</span>
|
<span class="mode-row-icon"><Icon name="sparkles" size="sm" /></span>
|
||||||
|
<span class="mode-row-info">
|
||||||
|
<span class="mode-row-name">{{ t('status.swarmLabel') }}</span>
|
||||||
|
<span class="mode-row-desc">{{ t('status.swarmDesc') }}</span>
|
||||||
|
</span>
|
||||||
<span class="mode-switch" :class="{ on: swarmOn }"><span class="mode-knob" /></span>
|
<span class="mode-switch" :class="{ on: swarmOn }"><span class="mode-knob" /></span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Goal — lifecycle controls when active; switch is on when active or armed. -->
|
<!-- Goal — lifecycle controls when active; switch is on when active or armed. -->
|
||||||
|
|
@ -924,22 +1041,46 @@ function selectModel(modelId: string): void {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mode-row-main"
|
class="mode-row-main"
|
||||||
@click="goalActive ? emit('controlGoal', 'cancel') : emit('toggleGoal')"
|
role="menuitem"
|
||||||
|
@click="goalActive ? emit('focusGoal') : emit('toggleGoal')"
|
||||||
>
|
>
|
||||||
<span class="mode-row-name">{{ t('status.goalLabel') }}</span>
|
<span class="mode-row-icon"><Icon name="target" size="sm" /></span>
|
||||||
|
<span class="mode-row-info">
|
||||||
|
<span class="mode-row-name">{{ t('status.goalLabel') }}</span>
|
||||||
|
<span class="mode-row-desc">{{ t('status.goalDesc') }}</span>
|
||||||
|
</span>
|
||||||
<span v-if="!goalActive" class="mode-switch" :class="{ on: props.goalMode }"><span class="mode-knob" /></span>
|
<span v-if="!goalActive" class="mode-switch" :class="{ on: props.goalMode }"><span class="mode-knob" /></span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="goalActive" class="mode-row-actions">
|
<div v-if="goalActive" class="mode-row-actions">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
v-if="goalCanPause"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
class="mode-row-action"
|
class="mode-row-action"
|
||||||
@click="emit('controlGoal', 'pause')"
|
@click="emit('controlGoal', 'pause')"
|
||||||
>{{ t('status.goalPause') }}</button>
|
>
|
||||||
<button
|
<Icon name="pause" size="sm" />
|
||||||
type="button"
|
<span>{{ t('status.goalPause') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="goalCanResume"
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
class="mode-row-action"
|
class="mode-row-action"
|
||||||
@click="emit('controlGoal', 'resume')"
|
@click="emit('controlGoal', 'resume')"
|
||||||
>{{ t('status.goalResume') }}</button>
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
<span>{{ t('status.goalResume') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger-soft"
|
||||||
|
class="mode-row-action"
|
||||||
|
@click="emit('controlGoal', 'cancel')"
|
||||||
|
>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
<span>{{ t('status.goalCancel') }}</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -997,10 +1138,13 @@ function selectModel(modelId: string): void {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<button
|
<button
|
||||||
class="send"
|
class="send"
|
||||||
|
:class="{ 'is-starting': starting }"
|
||||||
:aria-label="sendLabel"
|
:aria-label="sendLabel"
|
||||||
|
:disabled="starting"
|
||||||
@click="handleSubmit()"
|
@click="handleSubmit()"
|
||||||
>
|
>
|
||||||
<Icon name="send" size="sm" />
|
<Spinner v-if="starting" size="sm" />
|
||||||
|
<Icon v-else name="send" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1063,7 +1207,7 @@ function selectModel(modelId: string): void {
|
||||||
:class="{ 'is-active': seg === activeThinkingSegment }"
|
:class="{ 'is-active': seg === activeThinkingSegment }"
|
||||||
:disabled="thinkingReadonly"
|
:disabled="thinkingReadonly"
|
||||||
@click="setThinkingSegment(seg)"
|
@click="setThinkingSegment(seg)"
|
||||||
>{{ effortLabel(seg) }}</button>
|
>{{ thinkingSegmentLabel(seg) }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1092,9 +1236,11 @@ function selectModel(modelId: string): void {
|
||||||
|
|
||||||
/* Main composer card */
|
/* Main composer card */
|
||||||
.composer-card {
|
.composer-card {
|
||||||
|
--composer-send-size: 32px;
|
||||||
|
--composer-send-inset: var(--space-2);
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 16px;
|
border-radius: calc((var(--composer-send-size) / 2) + var(--composer-send-inset));
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
|
@ -1358,8 +1504,8 @@ function selectModel(modelId: string): void {
|
||||||
(handled upstream). Interrupt is a separate Stop button so the two are never
|
(handled upstream). Interrupt is a separate Stop button so the two are never
|
||||||
confused. */
|
confused. */
|
||||||
.send {
|
.send {
|
||||||
width: 32px;
|
width: var(--composer-send-size);
|
||||||
height: 32px;
|
height: var(--composer-send-size);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: var(--color-text-on-accent); /* white on accent — readable in light and dark */
|
color: var(--color-text-on-accent); /* white on accent — readable in light and dark */
|
||||||
|
|
@ -1384,16 +1530,37 @@ function selectModel(modelId: string): void {
|
||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.send:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send:disabled:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner-on-accent: recolor the ring so the arc reads on the accent fill.
|
||||||
|
Spinner.vue styles are scoped, so pierce them with :deep(). */
|
||||||
|
.send.is-starting :deep(.ui-spinner) {
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send.is-starting :deep(.ui-spinner__track) {
|
||||||
|
stroke: rgba(255, 255, 255, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
.send svg {
|
.send svg {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
width: var(--p-ic-lg);
|
||||||
|
height: var(--p-ic-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stop button — sibling of Send, shown only while running. Red at rest so the
|
/* Stop button — sibling of Send, shown only while running. Red at rest so the
|
||||||
destructive action is easy to spot; fills solid danger on hover. Kept softer
|
destructive action is easy to spot; fills solid danger on hover. Kept softer
|
||||||
than the accent Send so Send stays the primary action. */
|
than the accent Send so Send stays the primary action. */
|
||||||
.stop {
|
.stop {
|
||||||
width: 32px;
|
width: var(--composer-send-size);
|
||||||
height: 32px;
|
height: var(--composer-send-size);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-danger-soft);
|
background: var(--color-danger-soft);
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
|
|
@ -1418,6 +1585,8 @@ function selectModel(modelId: string): void {
|
||||||
}
|
}
|
||||||
.stop svg {
|
.stop svg {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
width: var(--p-ic-lg);
|
||||||
|
height: var(--p-ic-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom toolbar */
|
/* Bottom toolbar */
|
||||||
|
|
@ -1425,10 +1594,19 @@ function selectModel(modelId: string): void {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 6px 8px 8px;
|
padding: 6px var(--composer-send-inset) var(--composer-send-inset);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-measure {
|
||||||
|
position: absolute;
|
||||||
|
width: max-content;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-left,
|
.toolbar-left,
|
||||||
.toolbar-right {
|
.toolbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1450,7 +1628,8 @@ function selectModel(modelId: string): void {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: background 0.1s, color 0.15s;
|
transition: background 0.1s, color 0.15s;
|
||||||
font-family: var(--sans);
|
font-family: var(--font-ui);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
}
|
}
|
||||||
.perm-pill:hover {
|
.perm-pill:hover {
|
||||||
background: var(--color-surface-sunken);
|
background: var(--color-surface-sunken);
|
||||||
|
|
@ -1486,7 +1665,10 @@ function selectModel(modelId: string): void {
|
||||||
.ctx-num {
|
.ctx-num {
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-family: var(--mono);
|
font-family: var(--font-ui);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-feature-settings: "tnum";
|
||||||
|
letter-spacing: 0;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1498,8 +1680,10 @@ function selectModel(modelId: string): void {
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size);
|
||||||
line-height: 16px;
|
line-height: var(--leading-normal);
|
||||||
color: var(--dim);
|
color: var(--dim);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
|
|
@ -1551,15 +1735,16 @@ function selectModel(modelId: string): void {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-section {
|
.md-section {
|
||||||
padding: 4px 7px 2px;
|
padding: 4px 7px 2px;
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--text-xs);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0;
|
||||||
font-weight: 500;
|
font-weight: var(--weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-row {
|
.md-row {
|
||||||
|
|
@ -1570,7 +1755,7 @@ function selectModel(modelId: string): void {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--mono);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
padding: 5px 7px;
|
padding: 5px 7px;
|
||||||
|
|
@ -1637,7 +1822,7 @@ function selectModel(modelId: string): void {
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.md-thinking .md-name {
|
.md-thinking .md-name {
|
||||||
font-family: var(--mono);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|
@ -1660,7 +1845,7 @@ function selectModel(modelId: string): void {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--mono);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--ui-font-size-xs);
|
font-size: var(--ui-font-size-xs);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
@ -1702,7 +1887,8 @@ function selectModel(modelId: string): void {
|
||||||
left: 10px;
|
left: 10px;
|
||||||
z-index: var(--z-dropdown);
|
z-index: var(--z-dropdown);
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
max-width: 280px;
|
width: max-content;
|
||||||
|
max-width: calc(100vw - var(--space-8));
|
||||||
background: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--color-line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|
@ -1714,9 +1900,11 @@ function selectModel(modelId: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
.pd-row {
|
.pd-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: flex-start;
|
grid-template-columns: 14px var(--composer-menu-desc-width, max-content);
|
||||||
gap: 7px;
|
column-gap: 7px;
|
||||||
|
row-gap: 2px;
|
||||||
|
align-items: start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -1729,34 +1917,41 @@ function selectModel(modelId: string): void {
|
||||||
.pd-row.is-current { background: var(--color-accent-soft); }
|
.pd-row.is-current { background: var(--color-accent-soft); }
|
||||||
|
|
||||||
.pd-check {
|
.pd-check {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
flex: none;
|
min-height: 1lh;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-weight: 500;
|
font-size: var(--ui-font-size);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 1px;
|
line-height: var(--leading-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pd-info {
|
.pd-info {
|
||||||
display: flex;
|
display: contents;
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pd-name {
|
.pd-name {
|
||||||
font-family: var(--sans);
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size);
|
||||||
font-weight: 500;
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pd-desc {
|
.pd-desc {
|
||||||
font-family: var(--sans);
|
grid-column: 2;
|
||||||
font-size: var(--ui-font-size);
|
grid-row: 2;
|
||||||
|
width: var(--composer-menu-desc-width, auto);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.4;
|
line-height: var(--leading-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle pills (Thinking / Plan) */
|
/* Toggle pills (Thinking / Plan) */
|
||||||
|
|
@ -1773,7 +1968,8 @@ function selectModel(modelId: string): void {
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size);
|
||||||
font-family: var(--sans);
|
font-family: var(--font-ui);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
@ -1785,7 +1981,7 @@ function selectModel(modelId: string): void {
|
||||||
.mode-label { flex: none; }
|
.mode-label { flex: none; }
|
||||||
.mode-tag {
|
.mode-tag {
|
||||||
flex: none;
|
flex: none;
|
||||||
font-family: var(--mono);
|
font-family: var(--font-ui);
|
||||||
font-size: calc(var(--ui-font-size) - 3px);
|
font-size: calc(var(--ui-font-size) - 3px);
|
||||||
color: var(--color-accent-hover);
|
color: var(--color-accent-hover);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|
@ -1800,39 +1996,82 @@ function selectModel(modelId: string): void {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: var(--z-dropdown);
|
z-index: var(--z-dropdown);
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - var(--space-8));
|
||||||
background: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--color-line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
padding: 4px;
|
padding: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
}
|
}
|
||||||
.mode-row {
|
.mode-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: 14px var(--composer-menu-desc-width, max-content);
|
||||||
justify-content: space-between;
|
column-gap: 7px;
|
||||||
gap: 10px;
|
row-gap: 2px;
|
||||||
|
align-items: start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 7px 10px;
|
padding: 6px 7px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--sans);
|
font-family: var(--font-ui);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.mode-row:hover:not(:disabled) { background: var(--panel2); }
|
.mode-row:hover:not(:disabled) { background: var(--color-surface-sunken); }
|
||||||
.mode-row:disabled { cursor: not-allowed; opacity: 0.45; }
|
.mode-row:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||||
.mode-row-name { font-size: var(--ui-font-size-sm); color: var(--color-text); }
|
.mode-row-info {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.mode-row-icon {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
width: 14px;
|
||||||
|
min-height: 1lh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--ui-font-size);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
.mode-row-name {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
font-size: var(--ui-font-size);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
.mode-row-desc {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
width: var(--composer-menu-desc-width, auto);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
.mode-row-not-supported {
|
.mode-row-not-supported {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: var(--ui-font-size-xs);
|
font-size: var(--ui-font-size-xs);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.mode-row.on .mode-row-name { color: var(--color-accent-hover); font-weight: 500; }
|
.mode-row.on {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
.mode-row.on .mode-row-name { color: var(--color-accent-hover); }
|
||||||
|
.mode-row.on .mode-row-icon { color: var(--color-accent-hover); }
|
||||||
.mode-row-meta { font-family: var(--mono); font-size: calc(var(--ui-font-size) - 3px); color: var(--muted); }
|
.mode-row-meta { font-family: var(--mono); font-size: calc(var(--ui-font-size) - 3px); color: var(--muted); }
|
||||||
.mode-row:disabled .mode-row-meta { color: var(--faint); }
|
.mode-row:disabled .mode-row-meta { color: var(--faint); }
|
||||||
.mode-switch {
|
.mode-switch {
|
||||||
flex: none;
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
justify-self: end;
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 19px;
|
height: 19px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
@ -1856,45 +2095,49 @@ function selectModel(modelId: string): void {
|
||||||
.mode-switch.on .mode-knob { transform: translateX(15px); }
|
.mode-switch.on .mode-knob { transform: translateX(15px); }
|
||||||
|
|
||||||
.mode-row-goal {
|
.mode-row-goal {
|
||||||
flex-wrap: wrap;
|
--mode-row-icon-col: 14px;
|
||||||
|
--mode-row-col-gap: 7px;
|
||||||
|
--mode-row-pad-x: 7px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
.mode-row-goal:hover { background: transparent; }
|
.mode-row-goal:hover { background: transparent; }
|
||||||
|
.mode-row-goal.on {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
.mode-row-main {
|
.mode-row-main {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: var(--mode-row-icon-col) var(--composer-menu-desc-width, max-content);
|
||||||
justify-content: space-between;
|
column-gap: var(--mode-row-col-gap);
|
||||||
gap: 10px;
|
row-gap: 2px;
|
||||||
|
align-items: start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 7px 10px;
|
padding: 6px var(--mode-row-pad-x);
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--sans);
|
font-family: var(--font-ui);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.mode-row-main:hover { background: var(--panel2); }
|
.mode-row-main:hover { background: var(--color-surface-sunken); }
|
||||||
.mode-row-goal.on .mode-row-main .mode-row-name { color: var(--color-accent-hover); font-weight: 500; }
|
.mode-row-goal.on .mode-row-main .mode-row-name { color: var(--color-accent-hover); }
|
||||||
.mode-row-actions {
|
.mode-row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
flex-wrap: wrap;
|
||||||
flex: 1 1 100%;
|
gap: var(--space-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
|
padding: 0 var(--mode-row-pad-x) var(--mode-row-pad-x)
|
||||||
|
calc(var(--mode-row-pad-x) + var(--mode-row-icon-col) + var(--mode-row-col-gap));
|
||||||
}
|
}
|
||||||
.mode-row-action {
|
.mode-row-action {
|
||||||
padding: 3px 8px;
|
flex: none;
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: calc(var(--ui-font-size) - 3px);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.mode-row-action:hover:not(:disabled) { background: var(--panel2); }
|
.mode-row-action :deep(.ui-button__content) { gap: var(--space-1); }
|
||||||
.mode-row-action:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.mode-row-input {
|
.mode-row-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -1949,7 +2192,7 @@ function selectModel(modelId: string): void {
|
||||||
var(--dock-inline-left, max(12px, env(safe-area-inset-left)));
|
var(--dock-inline-left, max(12px, env(safe-area-inset-left)));
|
||||||
}
|
}
|
||||||
.composer-card {
|
.composer-card {
|
||||||
border-radius: var(--radius-xl);
|
--composer-send-size: 36px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.input-row {
|
.input-row {
|
||||||
|
|
@ -1958,9 +2201,9 @@ function selectModel(modelId: string): void {
|
||||||
}
|
}
|
||||||
/* Send → 36px round (hide the SVG arrow, show only the ::after glyph) */
|
/* Send → 36px round (hide the SVG arrow, show only the ::after glyph) */
|
||||||
.send {
|
.send {
|
||||||
width: 36px;
|
width: var(--composer-send-size);
|
||||||
height: 36px;
|
height: var(--composer-send-size);
|
||||||
min-width: 36px;
|
min-width: var(--composer-send-size);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
|
|
@ -1979,9 +2222,9 @@ function selectModel(modelId: string): void {
|
||||||
}
|
}
|
||||||
/* Stop → 36px round "■" glyph to match the mobile Send sizing. */
|
/* Stop → 36px round "■" glyph to match the mobile Send sizing. */
|
||||||
.stop {
|
.stop {
|
||||||
width: 36px;
|
width: var(--composer-send-size);
|
||||||
height: 36px;
|
height: var(--composer-send-size);
|
||||||
min-width: 36px;
|
min-width: var(--composer-send-size);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
|
|
@ -1994,7 +2237,7 @@ function selectModel(modelId: string): void {
|
||||||
.stop::after {
|
.stop::after {
|
||||||
content: "■";
|
content: "■";
|
||||||
/* Fixed icon glyph size — not part of the UI font scale. */
|
/* Fixed icon glyph size — not part of the UI font scale. */
|
||||||
font-size: 14px;
|
font-size: 17px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2065,7 +2308,7 @@ function selectModel(modelId: string): void {
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size);
|
||||||
}
|
}
|
||||||
.pd-desc {
|
.pd-desc {
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<!-- apps/kimi-web/src/components/chat/ConversationPane.vue -->
|
<!-- apps/kimi-web/src/components/chat/ConversationPane.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { measureNaturalWidth, prepareWithSegments } from '@chenglou/pretext';
|
||||||
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch, type ComponentPublicInstance } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch, type ComponentPublicInstance } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoView, ToolMedia, UIQuestion, WorkspaceView } from '../../types';
|
import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoView, ToolMedia, UIQuestion, WorkspaceView } from '../../types';
|
||||||
|
|
@ -11,10 +12,13 @@ import Composer from './Composer.vue';
|
||||||
import ChatDock from './ChatDock.vue';
|
import ChatDock from './ChatDock.vue';
|
||||||
import ConversationToc, { type ConversationTocItem } from './ConversationToc.vue';
|
import ConversationToc, { type ConversationTocItem } from './ConversationToc.vue';
|
||||||
import Icon from '../ui/Icon.vue';
|
import Icon from '../ui/Icon.vue';
|
||||||
|
import Spinner from '../ui/Spinner.vue';
|
||||||
import Tooltip from '../ui/Tooltip.vue';
|
import Tooltip from '../ui/Tooltip.vue';
|
||||||
import { getVisibleWorkspaces } from '../../lib/workspacePicker';
|
import { getVisibleWorkspaces } from '../../lib/workspacePicker';
|
||||||
import { safeRemove, STORAGE_KEYS } from '../../lib/storage';
|
import { safeRemove, STORAGE_KEYS } from '../../lib/storage';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
turns: ChatTurn[];
|
turns: ChatTurn[];
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
|
@ -45,6 +49,9 @@ const props = defineProps<{
|
||||||
/** Cache-buster that remounts the chat pane when the active session changes. */
|
/** Cache-buster that remounts the chat pane when the active session changes. */
|
||||||
fileReloadKey?: string | number;
|
fileReloadKey?: string | number;
|
||||||
sending?: boolean;
|
sending?: boolean;
|
||||||
|
/** True while the empty-composer first prompt is being created + submitted.
|
||||||
|
* Drives the empty-session "starting conversation…" loading state. */
|
||||||
|
starting?: boolean;
|
||||||
fastMoon?: boolean;
|
fastMoon?: boolean;
|
||||||
/** Mobile shell: compact chrome. */
|
/** Mobile shell: compact chrome. */
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
|
|
@ -134,6 +141,13 @@ const emit = defineEmits<{
|
||||||
// Empty-composer workspace picker.
|
// Empty-composer workspace picker.
|
||||||
const wsPickOpen = ref(false);
|
const wsPickOpen = ref(false);
|
||||||
const wsPickExpanded = ref(false);
|
const wsPickExpanded = ref(false);
|
||||||
|
const contentWrapRef = ref<HTMLElement | null>(null);
|
||||||
|
const wsPickMeasureRef = ref<HTMLElement | null>(null);
|
||||||
|
const wsPickMenuWidth = ref<string>('');
|
||||||
|
const wsPickStyle = computed<Record<string, string> | undefined>(() =>
|
||||||
|
wsPickMenuWidth.value ? { '--ws-pick-menu-width': wsPickMenuWidth.value } : undefined,
|
||||||
|
);
|
||||||
|
let wsPickMeasureFrame: number | null = null;
|
||||||
|
|
||||||
const activeWorkspaceLabel = computed(() => {
|
const activeWorkspaceLabel = computed(() => {
|
||||||
const w = props.workspaces?.find((ws) => ws.id === props.activeWorkspaceId);
|
const w = props.workspaces?.find((ws) => ws.id === props.activeWorkspaceId);
|
||||||
|
|
@ -161,7 +175,81 @@ function pickWorkspace(id: string): void {
|
||||||
if (id !== props.activeWorkspaceId) emit('selectWorkspace', id);
|
if (id !== props.activeWorkspaceId) emit('selectWorkspace', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n();
|
function cssPx(value: string): number {
|
||||||
|
const n = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasFont(style: CSSStyleDeclaration): string {
|
||||||
|
return `${style.fontStyle || 'normal'} ${style.fontWeight || '400'} ${style.fontSize} ${style.fontFamily}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function letterSpacingPx(style: CSSStyleDeclaration): number {
|
||||||
|
return style.letterSpacing === 'normal' ? 0 : cssPx(style.letterSpacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureText(text: string, style: CSSStyleDeclaration): number {
|
||||||
|
if (!text) return 0;
|
||||||
|
const prepared = prepareWithSegments(text, canvasFont(style), { letterSpacing: letterSpacingPx(style) });
|
||||||
|
return measureNaturalWidth(prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspacePickerMaxWidth(): number {
|
||||||
|
const containerWidth = contentWrapRef.value?.getBoundingClientRect().width ?? window.innerWidth;
|
||||||
|
return Math.max(0, containerWidth * 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWorkspacePickerWidth(): void {
|
||||||
|
const probe = wsPickMeasureRef.value;
|
||||||
|
if (!probe) return;
|
||||||
|
|
||||||
|
const itemPath = probe.querySelector<HTMLElement>('.ws-pick-item-path');
|
||||||
|
if (!itemPath) return;
|
||||||
|
|
||||||
|
const itemPathStyle = getComputedStyle(itemPath);
|
||||||
|
|
||||||
|
const measuredPathWidth = (props.workspaces ?? []).reduce(
|
||||||
|
(max, workspace) => Math.max(max, measureText(workspace.shortPath, itemPathStyle)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
wsPickMenuWidth.value = measuredPathWidth
|
||||||
|
? `${Math.ceil(Math.min(measuredPathWidth, workspacePickerMaxWidth()))}px`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWorkspacePickerMeasure(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
void nextTick(() => {
|
||||||
|
if (wsPickMeasureFrame !== null) window.cancelAnimationFrame(wsPickMeasureFrame);
|
||||||
|
wsPickMeasureFrame = window.requestAnimationFrame(() => {
|
||||||
|
wsPickMeasureFrame = null;
|
||||||
|
updateWorkspacePickerWidth();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
props.activeWorkspaceId,
|
||||||
|
props.workspaceName,
|
||||||
|
props.workspaces?.map((w) => `${w.id}\u0000${w.name}\u0000${w.shortPath}`).join('\u0001') ?? '',
|
||||||
|
hiddenWorkspaceCount.value,
|
||||||
|
locale.value,
|
||||||
|
],
|
||||||
|
scheduleWorkspacePickerMeasure,
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scheduleWorkspacePickerMeasure();
|
||||||
|
window.addEventListener('resize', scheduleWorkspacePickerMeasure);
|
||||||
|
void document.fonts?.ready.then(scheduleWorkspacePickerMeasure);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (wsPickMeasureFrame !== null) window.cancelAnimationFrame(wsPickMeasureFrame);
|
||||||
|
window.removeEventListener('resize', scheduleWorkspacePickerMeasure);
|
||||||
|
});
|
||||||
|
|
||||||
// The align toggle was removed with its UI (6e50cb7) — reading layout is
|
// The align toggle was removed with its UI (6e50cb7) — reading layout is
|
||||||
// always centered now. Drop the old persisted preference so users who once
|
// always centered now. Drop the old persisted preference so users who once
|
||||||
|
|
@ -1045,15 +1133,39 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
class="panes chat-scroll"
|
class="panes chat-scroll"
|
||||||
@scroll.passive="onPanesScroll"
|
@scroll.passive="onPanesScroll"
|
||||||
>
|
>
|
||||||
<div class="content-wrap" :class="[mobile ? 'align-mobile' : 'align-center']">
|
<div ref="contentWrapRef" class="content-wrap" :class="[mobile ? 'align-mobile' : 'align-center']">
|
||||||
<template v-if="turns.length === 0 && !sessionLoading">
|
<template v-if="turns.length === 0 && !sessionLoading">
|
||||||
<!-- Empty session: Composer rendered in the centre of the pane -->
|
<!-- Empty session: Composer rendered in the centre of the pane -->
|
||||||
<div class="empty-spacer" />
|
<div class="empty-spacer" />
|
||||||
<div class="empty-hint">
|
<div class="empty-hint">
|
||||||
<span class="empty-hint-title">{{ t('composer.emptyConversationTitle') }}</span>
|
<span class="empty-hint-title" :class="{ 'is-starting': starting }">
|
||||||
<span class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
|
<Spinner v-if="starting" size="sm" />
|
||||||
<!-- Workspace picker: choose where this new conversation starts. -->
|
<span>{{ starting ? t('conversation.starting') : t('composer.emptyConversationTitle') }}</span>
|
||||||
<div v-if="hasWorkspaces" class="ws-pick">
|
</span>
|
||||||
|
<span v-if="!starting" class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
|
||||||
|
<!-- Workspace picker: choose where this new conversation starts.
|
||||||
|
Hidden while starting — a workspace is already committed. -->
|
||||||
|
<div v-if="hasWorkspaces && !starting" class="ws-pick" :style="wsPickStyle">
|
||||||
|
<div ref="wsPickMeasureRef" class="ws-pick-measure" aria-hidden="true">
|
||||||
|
<button type="button" class="ws-pick-btn" tabindex="-1">
|
||||||
|
<Icon name="folder" size="sm" />
|
||||||
|
<span class="ws-pick-name">{{ activeWorkspaceLabel }}</span>
|
||||||
|
<Icon class="ws-pick-chev" name="chevron-down" size="sm" />
|
||||||
|
</button>
|
||||||
|
<div class="ws-pick-menu">
|
||||||
|
<button type="button" class="ws-pick-item" tabindex="-1">
|
||||||
|
<span class="ws-pick-item-name" />
|
||||||
|
<span class="ws-pick-item-path" />
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ws-pick-item ws-pick-more" tabindex="-1">
|
||||||
|
<span>{{ t('conversation.moreWorkspaces', { count: hiddenWorkspaceCount }) }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ws-pick-action" tabindex="-1">
|
||||||
|
<Icon name="plus" size="sm" />
|
||||||
|
<span>{{ t('conversation.addWorkspace') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Tooltip :text="t('conversation.switchWorkspace')">
|
<Tooltip :text="t('conversation.switchWorkspace')">
|
||||||
<button type="button" class="ws-pick-btn" @click.stop="wsPickOpen = !wsPickOpen">
|
<button type="button" class="ws-pick-btn" @click.stop="wsPickOpen = !wsPickOpen">
|
||||||
<Icon name="folder" size="sm" />
|
<Icon name="folder" size="sm" />
|
||||||
|
|
@ -1094,7 +1206,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else-if="!starting"
|
||||||
type="button"
|
type="button"
|
||||||
class="empty-add-workspace"
|
class="empty-add-workspace"
|
||||||
@click="emit('addWorkspace')"
|
@click="emit('addWorkspace')"
|
||||||
|
|
@ -1116,10 +1228,12 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
:plan-mode="planMode"
|
:plan-mode="planMode"
|
||||||
:swarm-mode="swarmMode"
|
:swarm-mode="swarmMode"
|
||||||
:goal-mode="goalMode"
|
:goal-mode="goalMode"
|
||||||
|
:goal="goal"
|
||||||
:activation-badges="activationBadges"
|
:activation-badges="activationBadges"
|
||||||
:models="models"
|
:models="models"
|
||||||
:starred-ids="starredIds"
|
:starred-ids="starredIds"
|
||||||
:skills="skills"
|
:skills="skills"
|
||||||
|
:starting="starting"
|
||||||
hide-context
|
hide-context
|
||||||
@submit="handleComposerSubmit"
|
@submit="handleComposerSubmit"
|
||||||
@steer="emit('steer', $event)"
|
@steer="emit('steer', $event)"
|
||||||
|
|
@ -1181,6 +1295,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
:style="chatDockStyle"
|
:style="chatDockStyle"
|
||||||
:session-id="sessionId"
|
:session-id="sessionId"
|
||||||
:running="running"
|
:running="running"
|
||||||
|
:starting="starting"
|
||||||
:queued="queued"
|
:queued="queued"
|
||||||
:search-files="searchFiles"
|
:search-files="searchFiles"
|
||||||
:upload-image="uploadImage"
|
:upload-image="uploadImage"
|
||||||
|
|
@ -1337,11 +1452,19 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 16px 16px;
|
padding: 0 16px 16px;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: var(--sans);
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
.empty-hint-title {
|
.empty-hint-title {
|
||||||
font-size: calc(var(--ui-font-size) + 16px);
|
font-size: calc(var(--ui-font-size) + 16px);
|
||||||
font-weight: 500;
|
font-optical-sizing: auto;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.empty-hint-title.is-starting {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.empty-hint-text {
|
.empty-hint-text {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
@ -1382,13 +1505,31 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
/* Empty-composer workspace picker */
|
/* Empty-composer workspace picker */
|
||||||
.ws-pick {
|
.ws-pick {
|
||||||
position: relative;
|
position: relative;
|
||||||
font-family: var(--mono);
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
.ws-pick-measure {
|
||||||
|
position: absolute;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
width: max-content;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ws-pick-measure .ws-pick-menu {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.ws-pick-btn {
|
.ws-pick-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
max-width: 320px;
|
width: max-content;
|
||||||
|
max-width: min(100%, calc(100vw - var(--space-8)));
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|
@ -1399,7 +1540,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.ws-pick-btn:hover { border-color: var(--color-accent-bd); color: var(--color-text); }
|
.ws-pick-btn:hover { border-color: var(--color-accent-bd); color: var(--color-text); }
|
||||||
.ws-pick-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ws-pick-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.ws-pick-chev { flex: none; color: var(--muted); transition: transform 0.15s; }
|
.ws-pick-chev { flex: none; color: var(--muted); transition: transform 0.15s; }
|
||||||
.ws-pick-chev.open { transform: rotate(180deg); }
|
.ws-pick-chev.open { transform: rotate(180deg); }
|
||||||
.ws-pick-backdrop {
|
.ws-pick-backdrop {
|
||||||
|
|
@ -1413,8 +1554,9 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + 6px);
|
||||||
z-index: var(--z-dropdown);
|
z-index: var(--z-dropdown);
|
||||||
min-width: 220px;
|
width: var(--ws-pick-menu-width, max-content);
|
||||||
max-width: min(86vw, 340px);
|
min-width: var(--ws-pick-menu-width, max-content);
|
||||||
|
max-width: calc(100vw - var(--space-8));
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
|
|
@ -1435,16 +1577,23 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--mono);
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
.ws-pick-item:hover { background: var(--panel2); }
|
.ws-pick-item:hover { background: var(--panel2); }
|
||||||
.ws-pick-item.on { background: var(--color-accent-soft); }
|
.ws-pick-item.on { background: var(--color-accent-soft); }
|
||||||
.ws-pick-item-name { font-size: var(--ui-font-size-sm); color: var(--color-text); }
|
.ws-pick-item-name {
|
||||||
.ws-pick-item.on .ws-pick-item-name { color: var(--color-accent-hover); font-weight: 500; }
|
font-size: var(--text-base);
|
||||||
.ws-pick-item-path { font-size: var(--text-base); color: var(--muted); }
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.ws-pick-item.on .ws-pick-item-name { color: var(--color-accent-hover); }
|
||||||
|
.ws-pick-item-path { font-size: var(--text-xs); font-weight: 475; color: var(--muted); }
|
||||||
.ws-pick-item.ws-pick-more {
|
.ws-pick-item.ws-pick-more {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
color: var(--dim);
|
color: var(--dim);
|
||||||
}
|
}
|
||||||
.ws-pick-item.ws-pick-more:hover { color: var(--color-text); }
|
.ws-pick-item.ws-pick-more:hover { color: var(--color-text); }
|
||||||
|
|
@ -1464,8 +1613,9 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--mono);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--ui-font-size-sm);
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
color: var(--dim);
|
color: var(--dim);
|
||||||
}
|
}
|
||||||
.ws-pick-action:hover { background: var(--panel2); color: var(--color-text); }
|
.ws-pick-action:hover { background: var(--panel2); color: var(--color-text); }
|
||||||
|
|
@ -1500,7 +1650,9 @@ defineExpose({ loadComposerForEdit, focusComposer });
|
||||||
font-size: var(--ui-font-size-sm);
|
font-size: var(--ui-font-size-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
z-index: var(--z-sticky);
|
/* Positioned after the message flow, so base z-index is enough to float above
|
||||||
|
content while staying below composer dropdowns. */
|
||||||
|
z-index: var(--z-base);
|
||||||
}
|
}
|
||||||
.newmsg-pill:hover { background: var(--panel2); }
|
.newmsg-pill:hover { background: var(--panel2); }
|
||||||
.pill-chevron {
|
.pill-chevron {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,15 @@ const tokenPct = computed(() => {
|
||||||
return Math.max(0, Math.min(100, Math.round((props.goal.tokensUsed / budget) * 100)));
|
return Math.max(0, Math.min(100, Math.round((props.goal.tokensUsed / budget) * 100)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function goalStatusLabel(status: AppGoal['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return t('status.goalStatusActive');
|
||||||
|
case 'paused': return t('status.goalStatusPaused');
|
||||||
|
case 'blocked': return t('status.goalStatusBlocked');
|
||||||
|
case 'complete': return t('status.goalStatusComplete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatMs(ms: number): string {
|
function formatMs(ms: number): string {
|
||||||
const sec = Math.max(0, Math.round(ms / 1000));
|
const sec = Math.max(0, Math.round(ms / 1000));
|
||||||
const min = Math.floor(sec / 60);
|
const min = Math.floor(sec / 60);
|
||||||
|
|
@ -42,13 +51,13 @@ function formatMs(ms: number): string {
|
||||||
<Card class="goal-strip" :class="{ expanded }">
|
<Card class="goal-strip" :class="{ expanded }">
|
||||||
<template #head>
|
<template #head>
|
||||||
<button class="goal-row" type="button" @click="expanded = !expanded">
|
<button class="goal-row" type="button" @click="expanded = !expanded">
|
||||||
<span class="goal-kicker">Goal</span>
|
<span class="goal-kicker">{{ t('status.goalLabel') }}</span>
|
||||||
<span class="goal-objective" :class="{ 'expanded-hidden': expanded }">{{ goal.objective }}</span>
|
<span class="goal-objective" :class="{ 'expanded-hidden': expanded }">{{ goal.objective }}</span>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="goal.status === 'active' ? 'success' : goal.status === 'blocked' ? 'danger' : goal.status === 'paused' ? 'warning' : 'neutral'"
|
:variant="goal.status === 'active' ? 'success' : goal.status === 'blocked' ? 'danger' : goal.status === 'paused' ? 'warning' : 'neutral'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="goal-status"
|
class="goal-status"
|
||||||
>{{ goal.status }}</Badge>
|
>{{ goalStatusLabel(goal.status) }}</Badge>
|
||||||
<span class="goal-progress" aria-hidden="true">
|
<span class="goal-progress" aria-hidden="true">
|
||||||
<span class="goal-progress-fill" :style="{ width: `${tokenPct}%` }"></span>
|
<span class="goal-progress-fill" :style="{ width: `${tokenPct}%` }"></span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -62,36 +71,47 @@ function formatMs(ms: number): string {
|
||||||
<span>Done when</span>
|
<span>Done when</span>
|
||||||
<p>{{ goal.completionCriterion }}</p>
|
<p>{{ goal.completionCriterion }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="goal-stats">
|
|
||||||
<Badge variant="neutral" size="sm">{{ goal.turnsUsed }} turns</Badge>
|
|
||||||
<Badge variant="neutral" size="sm">{{ goal.tokensUsed.toLocaleString() }} tokens</Badge>
|
|
||||||
<Badge variant="neutral" size="sm">{{ formatMs(goal.wallClockMs) }}</Badge>
|
|
||||||
<Badge v-if="goal.budget.tokenBudget !== null" variant="neutral" size="sm">{{ tokenPct }}% token budget</Badge>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="expanded" #foot>
|
<template v-if="expanded" #foot>
|
||||||
<div class="goal-actions">
|
<div class="goal-footer">
|
||||||
<Button
|
<div class="goal-meta">
|
||||||
v-if="goal.status !== 'paused'"
|
<span>{{ goal.turnsUsed }} turns</span>
|
||||||
size="sm"
|
<span>{{ goal.tokensUsed.toLocaleString() }} tokens</span>
|
||||||
variant="secondary"
|
<span>{{ formatMs(goal.wallClockMs) }}</span>
|
||||||
class="goal-action"
|
<span v-if="goal.budget.tokenBudget !== null">{{ tokenPct }}% token budget</span>
|
||||||
@click.stop="emit('controlGoal', 'pause')"
|
</div>
|
||||||
>{{ t('status.goalPause') }}</Button>
|
<div class="goal-actions">
|
||||||
<Button
|
<Button
|
||||||
v-if="goal.status === 'paused'"
|
v-if="goal.status === 'active'"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
class="goal-action"
|
class="goal-action"
|
||||||
@click.stop="emit('controlGoal', 'resume')"
|
@click.stop="emit('controlGoal', 'pause')"
|
||||||
>{{ t('status.goalResume') }}</Button>
|
>
|
||||||
<Button
|
<Icon name="pause" size="md" />
|
||||||
size="sm"
|
<span>{{ t('status.goalPause') }}</span>
|
||||||
variant="danger-soft"
|
</Button>
|
||||||
class="goal-action"
|
<Button
|
||||||
@click.stop="emit('controlGoal', 'cancel')"
|
v-if="goal.status === 'paused' || goal.status === 'blocked'"
|
||||||
>{{ t('status.goalCancel') }}</Button>
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
class="goal-action"
|
||||||
|
@click.stop="emit('controlGoal', 'resume')"
|
||||||
|
>
|
||||||
|
<Icon name="play" size="md" />
|
||||||
|
<span>{{ t('status.goalResume') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger-soft"
|
||||||
|
class="goal-action"
|
||||||
|
@click.stop="emit('controlGoal', 'cancel')"
|
||||||
|
>
|
||||||
|
<Icon name="close" size="md" />
|
||||||
|
<span>{{ t('status.goalCancel') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -99,8 +119,21 @@ function formatMs(ms: number): string {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.goal-strip {
|
.goal-strip {
|
||||||
|
--composer-send-size: 32px;
|
||||||
|
--composer-send-inset: var(--space-2);
|
||||||
margin: var(--space-2) var(--space-4) 0;
|
margin: var(--space-2) var(--space-4) 0;
|
||||||
}
|
}
|
||||||
|
.goal-strip.ui-card {
|
||||||
|
border-radius: calc((var(--composer-send-size) / 2) + var(--composer-send-inset));
|
||||||
|
}
|
||||||
|
.goal-strip :deep(.ui-card__foot) {
|
||||||
|
padding: var(--composer-send-inset);
|
||||||
|
}
|
||||||
|
.goal-strip :deep(.ui-card__head),
|
||||||
|
.goal-strip :deep(.ui-card__body),
|
||||||
|
.goal-strip :deep(.ui-card__foot) {
|
||||||
|
padding-left: calc((var(--composer-send-inset) + var(--composer-send-size)) / 2);
|
||||||
|
}
|
||||||
/* When collapsed the body/foot slots are not rendered; collapse the (always-
|
/* When collapsed the body/foot slots are not rendered; collapse the (always-
|
||||||
rendered) Card body and drop the head border so the strip is a single row. */
|
rendered) Card body and drop the head border so the strip is a single row. */
|
||||||
.goal-strip:not(.expanded) :deep(.ui-card__body) { display: none; }
|
.goal-strip:not(.expanded) :deep(.ui-card__body) { display: none; }
|
||||||
|
|
@ -116,13 +149,14 @@ function formatMs(ms: number): string {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font: var(--text-base)/var(--leading-normal) var(--font-ui);
|
font: var(--text-base)/var(--leading-normal) var(--font-ui);
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.goal-kicker {
|
.goal-kicker {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
font: var(--weight-bold) var(--text-xs) var(--font-mono);
|
font: var(--text-base)/var(--leading-normal) var(--font-ui);
|
||||||
text-transform: uppercase;
|
font-weight: var(--weight-semibold);
|
||||||
}
|
}
|
||||||
.goal-objective {
|
.goal-objective {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -132,6 +166,7 @@ function formatMs(ms: number): string {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
.goal-objective.expanded-hidden {
|
.goal-objective.expanded-hidden {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
|
@ -183,25 +218,43 @@ function formatMs(ms: number): string {
|
||||||
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
|
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
.goal-stats {
|
.goal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.goal-meta {
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-top: var(--space-3);
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font: var(--text-xs) var(--font-mono);
|
font: 12px/var(--leading-normal) var(--font-ui);
|
||||||
|
font-weight: 450;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.goal-actions {
|
.goal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
width: 100%;
|
justify-content: flex-end;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
.goal-action {
|
.goal-action {
|
||||||
flex: 1;
|
flex: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
height: var(--composer-send-size);
|
||||||
|
border-radius: calc(var(--composer-send-size) / 2);
|
||||||
|
padding-inline: var(--space-4);
|
||||||
|
}
|
||||||
|
.goal-action :deep(.ui-button__content) {
|
||||||
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.goal-strip {
|
.goal-strip {
|
||||||
|
--composer-send-size: 36px;
|
||||||
margin: var(--space-2) var(--space-3) 0;
|
margin: var(--space-2) var(--space-3) 0;
|
||||||
}
|
}
|
||||||
.goal-progress {
|
.goal-progress {
|
||||||
|
|
|
||||||
|
|
@ -474,12 +474,12 @@ function copyDiff(code: string, idx: number) {
|
||||||
|
|
||||||
/* Base prose — assistant message text. */
|
/* Base prose — assistant message text. */
|
||||||
.md {
|
.md {
|
||||||
font: 500 15px/1.6 var(--font-ui);
|
font: 400 15px/1.6 var(--font-ui);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.md :deep(.markdown-renderer) {
|
.md :deep(.markdown-renderer) {
|
||||||
font: 500 15px/1.6 var(--font-ui);
|
font: 400 15px/1.6 var(--font-ui);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.md :deep(.markstream-vue),
|
.md :deep(.markstream-vue),
|
||||||
|
|
@ -523,8 +523,9 @@ function copyDiff(code: string, idx: number) {
|
||||||
font-size: var(--content-font-size);
|
font-size: var(--content-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Emphasis — bold steps up from the body (medium/500) to semibold (700). */
|
/* Emphasis — keep the weight strong, but soften the ink slightly. */
|
||||||
.md :deep(strong) {
|
.md :deep(strong) {
|
||||||
|
color: color-mix(in srgb, var(--color-text) 86%, var(--color-text-muted));
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -534,7 +535,8 @@ function copyDiff(code: string, idx: number) {
|
||||||
.md :deep(h3),
|
.md :deep(h3),
|
||||||
.md :deep(h4) {
|
.md :deep(h4) {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-weight: var(--weight-medium);
|
font-optical-sizing: auto;
|
||||||
|
font-weight: 600;
|
||||||
margin: 0.85em 0 0.35em;
|
margin: 0.85em 0 0.35em;
|
||||||
line-height: var(--leading-tight);
|
line-height: var(--leading-tight);
|
||||||
}
|
}
|
||||||
|
|
@ -573,9 +575,15 @@ function copyDiff(code: string, idx: number) {
|
||||||
font: .9em var(--font-mono);
|
font: .9em var(--font-mono);
|
||||||
background: var(--color-surface-sunken);
|
background: var(--color-surface-sunken);
|
||||||
color: var(--color-fg);
|
color: var(--color-fg);
|
||||||
padding: 0 5px;
|
padding: 0 4px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
.md :deep(strong code),
|
||||||
|
.md :deep(strong .inline-code),
|
||||||
|
.md :deep(b code),
|
||||||
|
.md :deep(b .inline-code) {
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
Code blocks — sunken surface, 1px line border, radius md, soft shadow, plus
|
Code blocks — sunken surface, 1px line border, radius md, soft shadow, plus
|
||||||
|
|
@ -602,6 +610,9 @@ function copyDiff(code: string, idx: number) {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font: var(--text-xs) var(--font-mono);
|
font: var(--text-xs) var(--font-mono);
|
||||||
}
|
}
|
||||||
|
.md :deep(.code-block-header .code-header-main) {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
/* Copy button — mirrors the §03 IconButton: muted glyph, sunken hover, soft
|
/* Copy button — mirrors the §03 IconButton: muted glyph, sunken hover, soft
|
||||||
radius, and the shared focus ring. markstream renders its own button, so we
|
radius, and the shared focus ring. markstream renders its own button, so we
|
||||||
restyle it in place instead of swapping in the IconButton primitive. */
|
restyle it in place instead of swapping in the IconButton primitive. */
|
||||||
|
|
|
||||||
|
|
@ -452,7 +452,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
|
||||||
}
|
}
|
||||||
.qstep {
|
.qstep {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font: var(--text-xs) var(--font-mono);
|
font: var(--text-xs) var(--font-ui);
|
||||||
margin-left: var(--space-1);
|
margin-left: var(--space-1);
|
||||||
}
|
}
|
||||||
/* Minimize toggle — pinned to the right of the header row. */
|
/* Minimize toggle — pinned to the right of the header row. */
|
||||||
|
|
@ -482,6 +482,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
.qstep-dot {
|
.qstep-dot {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -493,7 +494,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--color-line);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font: var(--text-xs) var(--font-mono);
|
font: var(--text-xs) var(--font-ui);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
|
transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
|
||||||
|
|
@ -545,7 +546,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
|
||||||
|
|
||||||
.qopt-key {
|
.qopt-key {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font: var(--text-xs) var(--font-mono);
|
font: var(--text-xs) var(--font-ui);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
width: 12px;
|
width: 12px;
|
||||||
flex: none;
|
flex: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -560,8 +562,16 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.qopt-label { color: var(--color-text); }
|
.qopt-label {
|
||||||
.qopt-desc { color: var(--color-text-muted); font: var(--text-xs)/var(--leading-normal) var(--font-ui); }
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
.qopt-desc {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--text-xs)/var(--leading-normal) var(--font-ui);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
.chk, .rad { font: var(--text-base) var(--font-mono); }
|
.chk, .rad { font: var(--text-base) var(--font-mono); }
|
||||||
|
|
||||||
|
|
@ -603,7 +613,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
|
||||||
.qstep-dot {
|
.qstep-dot {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
font: var(--text-xs) var(--font-mono);
|
font: var(--text-xs) var(--font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Options → taller, finger-friendly rows. Label + description already stack
|
/* Options → taller, finger-friendly rows. Label + description already stack
|
||||||
|
|
|
||||||
|
|
@ -118,14 +118,16 @@ watch(
|
||||||
|
|
||||||
.prev {
|
.prev {
|
||||||
color: var(--color-text-faint);
|
color: var(--color-text-faint);
|
||||||
font: var(--text-base)/var(--leading-relaxed) var(--font-mono);
|
font: var(--text-base)/var(--leading-relaxed) var(--font-ui);
|
||||||
|
font-weight: 425;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tc {
|
.tc {
|
||||||
font: var(--text-base)/var(--leading-relaxed) var(--font-mono);
|
font: var(--text-base)/var(--leading-relaxed) var(--font-ui);
|
||||||
|
font-weight: 425;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,8 @@ watch(
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
font: var(--text-base)/var(--leading-relaxed) var(--font-mono);
|
font: var(--text-base)/var(--leading-relaxed) var(--font-ui);
|
||||||
|
font-weight: 425;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,12 @@ function onHeadClick(): void {
|
||||||
>
|
>
|
||||||
<div class="bh" ref="bhEl" @click="onHeadClick">
|
<div class="bh" ref="bhEl" @click="onHeadClick">
|
||||||
<span v-if="icon" class="gl" v-html="icon" aria-hidden="true" />
|
<span v-if="icon" class="gl" v-html="icon" aria-hidden="true" />
|
||||||
<span class="a">{{ name }}</span>
|
<span class="bh-text">
|
||||||
<Tooltip :text="arg">
|
<span class="a">{{ name }}</span>
|
||||||
<span v-if="arg" class="p">{{ arg }}</span>
|
<Tooltip :text="arg">
|
||||||
</Tooltip>
|
<span v-if="arg" class="p">{{ arg }}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
<span class="rt">
|
<span class="rt">
|
||||||
<span class="status" :class="status" role="status" :aria-label="status">
|
<span class="status" :class="status" role="status" :aria-label="status">
|
||||||
<Icon v-if="status === 'ok'" name="check" size="sm" />
|
<Icon v-if="status === 'ok'" name="check" size="sm" />
|
||||||
|
|
@ -133,6 +135,13 @@ function onHeadClick(): void {
|
||||||
color: var(--color-text-faint);
|
color: var(--color-text-faint);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
.bh-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: inherit;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.a {
|
.a {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
|
|
@ -140,6 +149,7 @@ function onHeadClick(): void {
|
||||||
}
|
}
|
||||||
.p {
|
.p {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -160,6 +170,7 @@ function onHeadClick(): void {
|
||||||
}
|
}
|
||||||
:slotted(.chip) {
|
:slotted(.chip) {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +207,7 @@ function onHeadClick(): void {
|
||||||
.bb-pad {
|
.bb-pad {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0 11px 11px;
|
padding: var(--space-2) var(--space-3) var(--space-3);
|
||||||
background: var(--color-surface-sunken);
|
background: var(--color-surface-sunken);
|
||||||
border-top: 1px solid var(--color-line);
|
border-top: 1px solid var(--color-line);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { diffStats } from '../../../lib/diffLines';
|
||||||
import { buildEditDiffLines } from '../../../lib/toolDiff';
|
import { buildEditDiffLines } from '../../../lib/toolDiff';
|
||||||
import { toolGlyph, toolLabel, toolSummary } from '../../../lib/toolMeta';
|
import { toolGlyph, toolLabel, toolSummary } from '../../../lib/toolMeta';
|
||||||
import ToolRow from '../ToolRow.vue';
|
import ToolRow from '../ToolRow.vue';
|
||||||
|
import ToolOutputBlock from './ToolOutputBlock.vue';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -69,10 +70,7 @@ function toggle(): void {
|
||||||
<span v-if="chip" class="chip">{{ chip }}</span>
|
<span v-if="chip" class="chip">{{ chip }}</span>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="summaryFull" class="bb-summary">{{ summaryFull }}</div>
|
<div v-if="summaryFull" class="bb-summary">{{ summaryFull }}</div>
|
||||||
<div class="bb-code">
|
<ToolOutputBlock :lines="tool.output" empty-text="Waiting for output…" />
|
||||||
<div v-if="!hasOutput" class="bb-empty">Waiting for output…</div>
|
|
||||||
<div v-for="(line, i) in tool.output ?? []" :key="i">{{ line }}</div>
|
|
||||||
</div>
|
|
||||||
</ToolRow>
|
</ToolRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -89,14 +87,4 @@ function toggle(): void {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
.bb-empty {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.bb-code {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 11px 13px;
|
|
||||||
border: 1px solid var(--color-line);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue';
|
||||||
import type { FilePreviewRequest, ToolCall, ToolMedia } from '../../../types';
|
import type { FilePreviewRequest, ToolCall, ToolMedia } from '../../../types';
|
||||||
import { toolChip, toolGlyph, toolLabel, toolSummary } from '../../../lib/toolMeta';
|
import { toolChip, toolGlyph, toolLabel, toolSummary } from '../../../lib/toolMeta';
|
||||||
import ToolRow from '../ToolRow.vue';
|
import ToolRow from '../ToolRow.vue';
|
||||||
|
import ToolOutputBlock from './ToolOutputBlock.vue';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -72,10 +73,7 @@ watch(
|
||||||
<span v-if="chip" class="chip">{{ chip }}</span>
|
<span v-if="chip" class="chip">{{ chip }}</span>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="summaryFull" class="bb-summary">{{ summaryFull }}</div>
|
<div v-if="summaryFull" class="bb-summary">{{ summaryFull }}</div>
|
||||||
<div class="bb-code">
|
<ToolOutputBlock :lines="tool.output" empty-text="Waiting for output…" />
|
||||||
<div v-if="!hasOutput" class="bb-empty">Waiting for output…</div>
|
|
||||||
<div v-for="(line, i) in tool.output ?? []" :key="i">{{ line }}</div>
|
|
||||||
</div>
|
|
||||||
</ToolRow>
|
</ToolRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -92,14 +90,4 @@ watch(
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.bb-empty {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.bb-code {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 11px 13px;
|
|
||||||
border: 1px solid var(--color-line);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<!-- Shared line-oriented tool output block. Keeps long outputs to a readable
|
||||||
|
viewport while preserving the tool card's normal typography. -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const OUTPUT_SCROLL_LINE_COUNT = 50;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lines?: string[];
|
||||||
|
emptyText?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const outputLines = computed(() => props.lines ?? []);
|
||||||
|
const isScrollable = computed(() => outputLines.value.length > OUTPUT_SCROLL_LINE_COUNT);
|
||||||
|
const outputStyle = { '--tool-output-visible-lines': String(OUTPUT_SCROLL_LINE_COUNT) };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bb-code tool-output-block" :class="{ scroll: isScrollable }" :style="outputStyle">
|
||||||
|
<div v-if="outputLines.length === 0 && emptyText" class="bb-empty">{{ emptyText }}</div>
|
||||||
|
<div v-for="(line, i) in outputLines" :key="i">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tool-output-block {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
.tool-output-block.scroll {
|
||||||
|
max-height: calc(var(--tool-output-visible-lines) * 1lh);
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
.bb-empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -394,7 +394,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
|
||||||
}
|
}
|
||||||
.mgh-name {
|
.mgh-name {
|
||||||
font-size: var(--ui-font-size-lg);
|
font-size: var(--ui-font-size-lg);
|
||||||
font-weight: 500;
|
font-weight: 550;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
@ -402,6 +402,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
|
||||||
}
|
}
|
||||||
.mgh-path {
|
.mgh-path {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
|
font-weight: 425;
|
||||||
color: var(--color-text-faint);
|
color: var(--color-text-faint);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
@ -430,12 +431,14 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
|
||||||
.srow .m { flex: 1; min-width: 0; }
|
.srow .m { flex: 1; min-width: 0; }
|
||||||
.srow .m .t {
|
.srow .m .t {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
|
font-weight: 450;
|
||||||
|
line-height: var(--leading-tight);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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,
|
/* Running indicator — pulse dot in the indent gutter left of the title,
|
||||||
mirroring the desktop SessionRow (.t.run::before). */
|
mirroring the desktop SessionRow (.t.run::before). */
|
||||||
|
|
@ -471,6 +474,8 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
|
||||||
}
|
}
|
||||||
.srow .m .s {
|
.srow .m .s {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
|
font-weight: 475;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--color-text-faint);
|
color: var(--color-text-faint);
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ const props = defineProps<{
|
||||||
notify: boolean;
|
notify: boolean;
|
||||||
/** Browser-notification-on-question (needs answer) preference. */
|
/** Browser-notification-on-question (needs answer) preference. */
|
||||||
notifyQuestion: boolean;
|
notifyQuestion: boolean;
|
||||||
|
/** Browser-notification-on-approval preference. */
|
||||||
|
notifyApproval: boolean;
|
||||||
/** OS permission state ('default' | 'granted' | 'denied') for the hint. */
|
/** OS permission state ('default' | 'granted' | 'denied') for the hint. */
|
||||||
notifyPermission?: string;
|
notifyPermission?: string;
|
||||||
/** Play-a-sound-on-completion preference. */
|
/** Play-a-sound-on-completion preference. */
|
||||||
|
|
@ -54,6 +56,7 @@ const emit = defineEmits<{
|
||||||
setUiFontSize: [size: number];
|
setUiFontSize: [size: number];
|
||||||
setNotify: [on: boolean];
|
setNotify: [on: boolean];
|
||||||
setNotifyQuestion: [on: boolean];
|
setNotifyQuestion: [on: boolean];
|
||||||
|
setNotifyApproval: [on: boolean];
|
||||||
setSound: [on: boolean];
|
setSound: [on: boolean];
|
||||||
setConversationToc: [on: boolean];
|
setConversationToc: [on: boolean];
|
||||||
login: [];
|
login: [];
|
||||||
|
|
@ -417,6 +420,18 @@ function archiveTime(iso: string): string {
|
||||||
@update:model-value="emit('setNotifyQuestion', $event)"
|
@update:model-value="emit('setNotifyQuestion', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="rlabel">
|
||||||
|
{{ t('settings.notifyOnApproval') }}
|
||||||
|
<span v-if="notifyPermission === 'denied'" class="hint">{{ t('settings.notifyDenied') }}</span>
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
:model-value="notifyApproval"
|
||||||
|
:disabled="notifyPermission === 'denied'"
|
||||||
|
:label="t('settings.notifyOnApproval')"
|
||||||
|
@update:model-value="emit('setNotifyApproval', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="rlabel">{{ t('settings.soundOnComplete') }}</span>
|
<span class="rlabel">{{ t('settings.soundOnComplete') }}</span>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,11 @@ defineExpose({ el });
|
||||||
transition: background var(--duration-base) var(--ease-out),
|
transition: background var(--duration-base) var(--ease-out),
|
||||||
color 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:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
|
||||||
.ui-icon-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
.ui-icon-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
|
||||||
40
apps/kimi-web/src/components/ui/Kbd.vue
Normal file
40
apps/kimi-web/src/components/ui/Kbd.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!-- apps/kimi-web/src/components/ui/Kbd.vue -->
|
||||||
|
<!-- Design-system §03 Kbd: keyboard shortcut rendered as keycaps — one <kbd>
|
||||||
|
block per key (e.g. ['⌘', 'K'] renders two caps). Keycap look: sunken
|
||||||
|
surface + 2px bottom border, 18px tall to match Badge sm. -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
keys: string[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="ui-kbd">
|
||||||
|
<kbd v-for="key in keys" :key="key" class="ui-kbd__key">{{ key }}</kbd>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ui-kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.ui-kbd__key {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: var(--color-surface-sunken);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -38,9 +38,9 @@ defineEmits<{ click: [event: MouseEvent] }>();
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-base);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--duration-base), color var(--duration-base);
|
transition: background var(--duration-base), color var(--duration-base);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,29 @@
|
||||||
// apps/kimi-web/src/composables/client/useNotification.ts
|
// apps/kimi-web/src/composables/client/useNotification.ts
|
||||||
// Browser notifications for when the agent needs attention: a turn finished or
|
// Browser notifications for when the agent needs attention: a turn finished, a
|
||||||
// a question is waiting for an answer. Each kind has its own on/off preference
|
// question waiting for an answer, or a tool needing approval. Each kind has its
|
||||||
// (persisted) plus the shared OS permission + Notification API. Pure UI action
|
// own on/off preference (persisted) plus the shared OS permission + Notification
|
||||||
// module — it never reads rawState or calls the API. The rawState-dependent
|
// API. Pure UI action module — it never reads rawState or calls the API. The
|
||||||
// bits (is the session active & visible, its title, the click-to-select action)
|
// rawState-dependent bits (is the user watching the session, its title, the
|
||||||
// are passed in by the caller via the ctx objects.
|
// click-to-select action) are passed in by the caller via the ctx objects.
|
||||||
//
|
//
|
||||||
// Why two preferences: completion notifications default on (existing behavior),
|
// Why three preferences: completion notifications default on (existing
|
||||||
// but question notifications surface question text and default OFF, so an
|
// behavior), but question and approval notifications surface request text/tool
|
||||||
// existing user who only opted into completion alerts doesn't start receiving
|
// names and default OFF, so an existing user who only opted into completion
|
||||||
// question content on their desktop without explicitly opting in.
|
// alerts doesn't start receiving sensitive content on their desktop without
|
||||||
|
// explicitly opting in.
|
||||||
|
|
||||||
import { ref, type Ref } from 'vue';
|
import { ref, type Ref } from 'vue';
|
||||||
import { i18n } from '../../i18n';
|
import { i18n } from '../../i18n';
|
||||||
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
|
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
|
||||||
|
|
||||||
|
export function shouldNotifyCompletion(
|
||||||
|
status: 'idle' | 'aborted',
|
||||||
|
hasPendingApproval: boolean,
|
||||||
|
hasPendingQuestion: boolean,
|
||||||
|
): boolean {
|
||||||
|
return status === 'idle' && !hasPendingApproval && !hasPendingQuestion;
|
||||||
|
}
|
||||||
|
|
||||||
function loadNotify(key: string, defaultOn: boolean): boolean {
|
function loadNotify(key: string, defaultOn: boolean): boolean {
|
||||||
const v = safeGetString(key);
|
const v = safeGetString(key);
|
||||||
return v === null ? defaultOn : v === '1';
|
return v === null ? defaultOn : v === '1';
|
||||||
|
|
@ -22,6 +31,7 @@ function loadNotify(key: string, defaultOn: boolean): boolean {
|
||||||
|
|
||||||
const notifyOnComplete = ref(loadNotify(STORAGE_KEYS.notifyOnComplete, true));
|
const notifyOnComplete = ref(loadNotify(STORAGE_KEYS.notifyOnComplete, true));
|
||||||
const notifyOnQuestion = ref(loadNotify(STORAGE_KEYS.notifyOnQuestion, false));
|
const notifyOnQuestion = ref(loadNotify(STORAGE_KEYS.notifyOnQuestion, false));
|
||||||
|
const notifyOnApproval = ref(loadNotify(STORAGE_KEYS.notifyOnApproval, false));
|
||||||
const notifyPermission = ref<string>(
|
const notifyPermission = ref<string>(
|
||||||
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
|
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
|
||||||
);
|
);
|
||||||
|
|
@ -61,20 +71,42 @@ function setNotifyOnQuestion(on: boolean): Promise<void> {
|
||||||
return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on);
|
return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyCompletionCtx {
|
/** Enable/disable approval notifications. Off by default. */
|
||||||
/** True when the target session is the active one and the page is visible —
|
function setNotifyOnApproval(on: boolean): Promise<void> {
|
||||||
in which case we suppress the notification. */
|
return setNotifyPref(notifyOnApproval, STORAGE_KEYS.notifyOnApproval, on);
|
||||||
isActiveAndVisible: boolean;
|
}
|
||||||
|
|
||||||
|
export interface NotifyBaseCtx {
|
||||||
|
/** True when the user is actually watching the target session: it is the
|
||||||
|
active session, the page is visible, and the window has focus — in which
|
||||||
|
case we suppress the notification. */
|
||||||
|
isUserWatching: boolean;
|
||||||
/** Session title used as the completion notification body and a question-body fallback. */
|
/** Session title used as the completion notification body and a question-body fallback. */
|
||||||
sessionTitle: string;
|
sessionTitle: string;
|
||||||
/** Called when the user clicks the notification (e.g. select the session). */
|
/** Called when the user clicks the notification (e.g. select the session). */
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifyQuestionCtx extends NotifyCompletionCtx {
|
export interface NotifyCompletionCtx extends NotifyBaseCtx {
|
||||||
|
/** Prompt id of the finished turn; keys the dedup tag so every turn fires its
|
||||||
|
own notification while a replayed idle event for the same turn stays
|
||||||
|
collapsed. Falls back to a per-call unique tag when absent. */
|
||||||
|
promptId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyQuestionCtx extends NotifyBaseCtx {
|
||||||
/** Short preview of the question, used as the notification body. Falls back
|
/** Short preview of the question, used as the notification body. Falls back
|
||||||
to the session title, then to a generic line when empty. */
|
to the session title, then to a generic line when empty. */
|
||||||
questionPreview: string;
|
questionPreview: string;
|
||||||
|
/** Unique question request id; used to deduplicate notifications per request. */
|
||||||
|
questionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyApprovalCtx extends NotifyBaseCtx {
|
||||||
|
/** Tool call name needing approval, used as the notification body. */
|
||||||
|
toolName: string;
|
||||||
|
/** Unique approval request id; used to deduplicate notifications per request. */
|
||||||
|
approvalId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationCopy {
|
export interface NotificationCopy {
|
||||||
|
|
@ -111,12 +143,29 @@ export function questionNotificationCopy(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function approvalNotificationCopy(
|
||||||
|
sessionTitle: string,
|
||||||
|
toolName: string,
|
||||||
|
): NotificationCopy {
|
||||||
|
return {
|
||||||
|
title: i18n.global.t('settings.notifyApprovalTitle'),
|
||||||
|
body: firstText(
|
||||||
|
toolName,
|
||||||
|
sessionTitle,
|
||||||
|
i18n.global.t('settings.notifyApprovalFallback'),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Shared permission gate + fire. `enabled` is the caller's per-kind preference;
|
/** Shared permission gate + fire. `enabled` is the caller's per-kind preference;
|
||||||
`copy` and `tag` let each kind carry its own text and a per-kind dedup tag
|
`copy` and `tag` let each kind carry its own text and a per-turn/per-request
|
||||||
so a completion and a question don't collapse into one notification. */
|
dedup tag: repeats of the same turn or request collapse into one
|
||||||
|
notification, while distinct ones each fire (same-tag notifications replace
|
||||||
|
silently — renotify is unreliable across platforms — so the tag must change
|
||||||
|
whenever a new alert should pop). */
|
||||||
function maybeNotify(
|
function maybeNotify(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
ctx: NotifyCompletionCtx,
|
ctx: NotifyBaseCtx,
|
||||||
copy: NotificationCopy,
|
copy: NotificationCopy,
|
||||||
tag: string,
|
tag: string,
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -135,8 +184,8 @@ function maybeNotify(
|
||||||
fire(ctx, copy, tag);
|
fire(ctx, copy, tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): void {
|
function fire(ctx: NotifyBaseCtx, copy: NotificationCopy, tag: string): void {
|
||||||
if (ctx.isActiveAndVisible) return;
|
if (ctx.isUserWatching) return;
|
||||||
try {
|
try {
|
||||||
const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON });
|
const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON });
|
||||||
n.onclick = () => {
|
n.onclick = () => {
|
||||||
|
|
@ -154,24 +203,38 @@ function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): vo
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire a completion notification for a finished session, but only when the
|
/** Fire a completion notification for a finished session, but only when the
|
||||||
caller says the user isn't already looking at it. */
|
caller says the user isn't already looking at it. The tag carries the turn's
|
||||||
|
prompt id: same-tag notifications replace silently, so without it a stale
|
||||||
|
notification left in the notification center would swallow every later
|
||||||
|
turn's alert for that session. */
|
||||||
function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void {
|
function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void {
|
||||||
maybeNotify(
|
maybeNotify(
|
||||||
notifyOnComplete.value,
|
notifyOnComplete.value,
|
||||||
ctx,
|
ctx,
|
||||||
completionNotificationCopy(ctx.sessionTitle),
|
completionNotificationCopy(ctx.sessionTitle),
|
||||||
`kimi-complete-${sid}`,
|
`kimi-complete-${sid}-${ctx.promptId ?? Date.now()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire a notification when a session asks a question, but only when the user
|
/** Fire a notification when a session asks a question, but only when the user
|
||||||
explicitly opted into question notifications and isn't already looking. */
|
explicitly opted into question notifications and isn't already looking. */
|
||||||
function maybeNotifyQuestion(sid: string, ctx: NotifyQuestionCtx): void {
|
function maybeNotifyQuestion(ctx: NotifyQuestionCtx): void {
|
||||||
maybeNotify(
|
maybeNotify(
|
||||||
notifyOnQuestion.value,
|
notifyOnQuestion.value,
|
||||||
ctx,
|
ctx,
|
||||||
questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview),
|
questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview),
|
||||||
`kimi-question-${sid}`,
|
`kimi-question-${ctx.questionId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire a notification when a tool needs approval, but only when the user
|
||||||
|
explicitly opted into approval notifications and isn't already looking. */
|
||||||
|
function maybeNotifyApproval(ctx: NotifyApprovalCtx): void {
|
||||||
|
maybeNotify(
|
||||||
|
notifyOnApproval.value,
|
||||||
|
ctx,
|
||||||
|
approvalNotificationCopy(ctx.sessionTitle, ctx.toolName),
|
||||||
|
`kimi-approval-${ctx.approvalId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,10 +242,13 @@ export function useNotification() {
|
||||||
return {
|
return {
|
||||||
notifyOnComplete,
|
notifyOnComplete,
|
||||||
notifyOnQuestion,
|
notifyOnQuestion,
|
||||||
|
notifyOnApproval,
|
||||||
notifyPermission,
|
notifyPermission,
|
||||||
setNotifyOnComplete,
|
setNotifyOnComplete,
|
||||||
setNotifyOnQuestion,
|
setNotifyOnQuestion,
|
||||||
|
setNotifyOnApproval,
|
||||||
maybeNotifyCompletion,
|
maybeNotifyCompletion,
|
||||||
maybeNotifyQuestion,
|
maybeNotifyQuestion,
|
||||||
|
maybeNotifyApproval,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
// apps/kimi-web/src/composables/client/useSoundNotification.ts
|
// apps/kimi-web/src/composables/client/useSoundNotification.ts
|
||||||
// Browser "turn completed" sound: a persisted on/off preference plus a short
|
// Browser attention sound: a persisted on/off preference plus a short chime
|
||||||
// chime synthesized with the WebAudio API (no audio asset, no permission
|
// synthesized with the WebAudio API (no audio asset, no permission prompt).
|
||||||
// prompt). Pure UI action module — it never reads rawState or calls the API.
|
// One chime covers every "the agent needs you" moment — a finished turn, a
|
||||||
|
// question waiting for an answer, a tool needing approval. Pure UI action
|
||||||
|
// module — it never reads rawState or calls the API.
|
||||||
//
|
//
|
||||||
// Why the eager "unlock": the sound is most useful when the tab is in the
|
// Why the eager "unlock": the sound is most useful when the tab is in the
|
||||||
// background (so you hear it while doing something else). But an AudioContext
|
// background (so you hear it while doing something else). But an AudioContext
|
||||||
|
|
@ -161,11 +163,19 @@ function maybePlayQuestionSound(): void {
|
||||||
playChime();
|
playChime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Play the attention sound when a tool needs approval, whenever the
|
||||||
|
preference is on. Same chime as completion: it means "the agent needs you". */
|
||||||
|
function maybePlayApprovalSound(): void {
|
||||||
|
if (!soundOnComplete.value) return;
|
||||||
|
playChime();
|
||||||
|
}
|
||||||
|
|
||||||
export function useSoundNotification() {
|
export function useSoundNotification() {
|
||||||
return {
|
return {
|
||||||
soundOnComplete,
|
soundOnComplete,
|
||||||
setSoundOnComplete,
|
setSoundOnComplete,
|
||||||
maybePlayCompletionSound,
|
maybePlayCompletionSound,
|
||||||
maybePlayQuestionSound,
|
maybePlayQuestionSound,
|
||||||
|
maybePlayApprovalSound,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,17 @@ const pendingQuestionActions = reactive<Record<string, 'answer' | 'dismiss'>>({}
|
||||||
const pendingApprovalActions = reactive<Record<string, true>>({});
|
const pendingApprovalActions = reactive<Record<string, true>>({});
|
||||||
/** Task ids with an in-flight cancel, keyed by taskId. */
|
/** Task ids with an in-flight cancel, keyed by taskId. */
|
||||||
const pendingTaskCancellations = reactive<Record<string, true>>({});
|
const pendingTaskCancellations = reactive<Record<string, true>>({});
|
||||||
|
/**
|
||||||
|
* Workspace ids whose empty-session first prompt is currently being created +
|
||||||
|
* submitted. The empty-composer path (`startSessionAndSendPrompt`) awaits
|
||||||
|
* `createDraftSession` (addWorkspace + createSession + selectSession) before
|
||||||
|
* the session id exists, so the per-session `inFlightPromptSessions` guard
|
||||||
|
* cannot cover that window — a second Enter / send-button click during it
|
||||||
|
* would otherwise fire a second concurrent POST and trip the daemon's
|
||||||
|
* `turn.agent_busy` race. Module-level singleton — matches the other
|
||||||
|
* `pending*Actions` guards above.
|
||||||
|
*/
|
||||||
|
const startingFirstPromptWorkspaces = reactive(new Set<string>());
|
||||||
|
|
||||||
type SyncSessionResult = 'ok' | 'not-found' | 'failed';
|
type SyncSessionResult = 'ok' | 'not-found' | 'failed';
|
||||||
|
|
||||||
|
|
@ -815,12 +826,22 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
||||||
text: string,
|
text: string,
|
||||||
attachments?: PromptAttachment[],
|
attachments?: PromptAttachment[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Guard the whole "create draft session + submit first prompt" flow: the
|
||||||
|
// session id doesn't exist until `createDraftSession` resolves, so the
|
||||||
|
// per-session `inFlightPromptSessions` guard can't cover this window. A
|
||||||
|
// second Enter / send-button click in that window would otherwise fire a
|
||||||
|
// concurrent first POST for the same new session and trip the daemon's
|
||||||
|
// `turn.agent_busy` race.
|
||||||
|
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
|
||||||
|
startingFirstPromptWorkspaces.add(workspaceId);
|
||||||
try {
|
try {
|
||||||
const sid = await createDraftSession(workspaceId);
|
const sid = await createDraftSession(workspaceId);
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
await submitPromptInternal(sid, text, attachments);
|
await submitPromptInternal(sid, text, attachments);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pushOperationFailure('startSessionAndSendPrompt', err);
|
pushOperationFailure('startSessionAndSendPrompt', err);
|
||||||
|
} finally {
|
||||||
|
startingFirstPromptWorkspaces.delete(workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -837,6 +858,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
||||||
skillName: string,
|
skillName: string,
|
||||||
args?: string,
|
args?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Same reentry window as startSessionAndSendPrompt (see the guard there):
|
||||||
|
// draft-session creation selects the new session before the activation,
|
||||||
|
// so concurrent first actions must be dropped here.
|
||||||
|
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
|
||||||
|
startingFirstPromptWorkspaces.add(workspaceId);
|
||||||
try {
|
try {
|
||||||
const sid = await createDraftSession(workspaceId);
|
const sid = await createDraftSession(workspaceId);
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
|
|
@ -862,6 +888,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
||||||
: rawState.defaultModel) ?? undefined;
|
: rawState.defaultModel) ?? undefined;
|
||||||
await persistSessionProfile(
|
await persistSessionProfile(
|
||||||
{
|
{
|
||||||
|
model,
|
||||||
planMode,
|
planMode,
|
||||||
swarmMode,
|
swarmMode,
|
||||||
permissionMode: rawState.permission,
|
permissionMode: rawState.permission,
|
||||||
|
|
@ -872,6 +899,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
||||||
await modelProvider.activateSkill(skillName, args, sid);
|
await modelProvider.activateSkill(skillName, args, sid);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pushOperationFailure('startSessionAndActivateSkill', err);
|
pushOperationFailure('startSessionAndActivateSkill', err);
|
||||||
|
} finally {
|
||||||
|
startingFirstPromptWorkspaces.delete(workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -888,12 +917,17 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
prompt?: string,
|
prompt?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Same reentry window as startSessionAndSendPrompt (see the guard there).
|
||||||
|
if (startingFirstPromptWorkspaces.has(workspaceId)) return;
|
||||||
|
startingFirstPromptWorkspaces.add(workspaceId);
|
||||||
try {
|
try {
|
||||||
const sid = await createDraftSession(workspaceId);
|
const sid = await createDraftSession(workspaceId);
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
await sideChat.openSideChatOn(sid, prompt);
|
await sideChat.openSideChatOn(sid, prompt);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pushOperationFailure('startSessionAndOpenSideChat', err);
|
pushOperationFailure('startSessionAndOpenSideChat', err);
|
||||||
|
} finally {
|
||||||
|
startingFirstPromptWorkspaces.delete(workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2186,6 +2220,14 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
||||||
searchFiles,
|
searchFiles,
|
||||||
loadOlderMessages,
|
loadOlderMessages,
|
||||||
refreshSessionSidecars,
|
refreshSessionSidecars,
|
||||||
|
/** True while any empty-composer first prompt is being created + submitted
|
||||||
|
* (the window covered by startingFirstPromptWorkspaces). Drives the
|
||||||
|
* empty-session "starting conversation…" loading state. Intentionally
|
||||||
|
* keyed by the lock set itself rather than the current activeWorkspaceId:
|
||||||
|
* createDraftSession can swap activeWorkspaceId to a registered id
|
||||||
|
* mid-flight, and a workspace-keyed read would prematurely re-enable the
|
||||||
|
* composer and reopen the duplicate first-submit race. */
|
||||||
|
isStartingFirstPrompt: () => startingFirstPromptWorkspaces.size > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import {
|
||||||
} from '../lib/storage';
|
} from '../lib/storage';
|
||||||
import { createEventBatcher, isRenderEvent } from './client/eventBatcher';
|
import { createEventBatcher, isRenderEvent } from './client/eventBatcher';
|
||||||
import { useAppearance } from './client/useAppearance';
|
import { useAppearance } from './client/useAppearance';
|
||||||
import { useNotification } from './client/useNotification';
|
import { useNotification, shouldNotifyCompletion } from './client/useNotification';
|
||||||
import { useSoundNotification } from './client/useSoundNotification';
|
import { useSoundNotification } from './client/useSoundNotification';
|
||||||
import { useTaskPoller } from './client/useTaskPoller';
|
import { useTaskPoller } from './client/useTaskPoller';
|
||||||
import { useModelProviderState } from './client/useModelProviderState';
|
import { useModelProviderState } from './client/useModelProviderState';
|
||||||
|
|
@ -860,6 +860,11 @@ function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number
|
||||||
if (appEvent.type === 'questionRequested') {
|
if (appEvent.type === 'questionRequested') {
|
||||||
onQuestionRequested(appEvent.sessionId, appEvent.question);
|
onQuestionRequested(appEvent.sessionId, appEvent.question);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The agent needs approval for a tool call — surface it so the user comes back.
|
||||||
|
if (appEvent.type === 'approvalRequested') {
|
||||||
|
onApprovalRequested(appEvent.sessionId, appEvent.approval);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const enqueueEvent = createEventBatcher<PendingEvent>(
|
const enqueueEvent = createEventBatcher<PendingEvent>(
|
||||||
|
|
@ -1375,7 +1380,7 @@ function formatTime(iso: string, _status: string): string {
|
||||||
const diffD = diffMs / 86400000;
|
const diffD = diffMs / 86400000;
|
||||||
if (diffD < 7) return `${Math.round(diffD)}d`;
|
if (diffD < 7) return `${Math.round(diffD)}d`;
|
||||||
if (diffD < 30) return `${Math.round(diffD / 7)}w`;
|
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`;
|
return `${Math.round(diffD / 365)}y`;
|
||||||
} catch {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
|
|
@ -1680,6 +1685,11 @@ const isSending = computed<boolean>(() => {
|
||||||
return rawState.sendingBySession[sid] ?? false;
|
return rawState.sendingBySession[sid] ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// True while the empty-composer first prompt for the active workspace is being
|
||||||
|
// created + submitted (before the session id exists). Drives the empty-session
|
||||||
|
// "starting conversation…" loading state in ConversationPane / Composer.
|
||||||
|
const isStartingFirstPrompt = computed<boolean>(() => workspaceState.isStartingFirstPrompt());
|
||||||
|
|
||||||
const sideChat = useSideChat(rawState, {
|
const sideChat = useSideChat(rawState, {
|
||||||
pushOperationFailure,
|
pushOperationFailure,
|
||||||
nextOptimisticMsgId,
|
nextOptimisticMsgId,
|
||||||
|
|
@ -2315,10 +2325,27 @@ const workspaceState = useWorkspaceState(rawState, {
|
||||||
fileDiffLoading,
|
fileDiffLoading,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** True when the user is actually watching this session: it is the active
|
||||||
|
session, the page is visible, and the window has focus. Focus matters on
|
||||||
|
top of visibility: a window that lost focus to another app often stays
|
||||||
|
(partially) visible on screen, but the user is working elsewhere and would
|
||||||
|
miss the moment without a notification. */
|
||||||
|
function isUserWatching(sid: string): boolean {
|
||||||
|
return (
|
||||||
|
sid === rawState.activeSessionId &&
|
||||||
|
typeof document !== 'undefined' &&
|
||||||
|
document.visibilityState === 'visible' &&
|
||||||
|
document.hasFocus()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
|
function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
|
||||||
// The turn finished — this session no longer has a prompt in flight.
|
// The turn finished — this session no longer has a prompt in flight.
|
||||||
inFlightPromptSessions.delete(sid);
|
inFlightPromptSessions.delete(sid);
|
||||||
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
|
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
|
||||||
|
// Capture before the cleanup below drops it — it keys the completion
|
||||||
|
// notification's dedup tag so each finished turn alerts once.
|
||||||
|
const finishedPromptId = rawState.promptIdBySession[sid];
|
||||||
// Drop any cached prompt_id so a later skill activation (which has no
|
// Drop any cached prompt_id so a later skill activation (which has no
|
||||||
// prompt_id) doesn't accidentally reuse this stale id for :abort.
|
// prompt_id) doesn't accidentally reuse this stale id for :abort.
|
||||||
if (rawState.promptIdBySession[sid] !== undefined) {
|
if (rawState.promptIdBySession[sid] !== undefined) {
|
||||||
|
|
@ -2343,16 +2370,20 @@ function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browser notification when the user isn't watching this session.
|
// Browser notification when the user isn't watching this session.
|
||||||
notification.maybeNotifyCompletion(sid, {
|
// Only real completions notify; aborted turns and turns that ended up
|
||||||
isActiveAndVisible:
|
// blocked on approval/question do not fire the generic "Turn finished" alert.
|
||||||
sid === rawState.activeSessionId &&
|
const hasPendingApproval = (rawState.approvalsBySession[sid] ?? []).length > 0;
|
||||||
typeof document !== 'undefined' &&
|
const hasPendingQuestion = (rawState.questionsBySession[sid] ?? []).length > 0;
|
||||||
document.visibilityState === 'visible',
|
if (shouldNotifyCompletion(status, hasPendingApproval, hasPendingQuestion)) {
|
||||||
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
|
notification.maybeNotifyCompletion(sid, {
|
||||||
onClick: () => {
|
isUserWatching: isUserWatching(sid),
|
||||||
void workspaceState.selectSession(sid);
|
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
|
||||||
},
|
promptId: finishedPromptId,
|
||||||
});
|
onClick: () => {
|
||||||
|
void workspaceState.selectSession(sid);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Completion sound — only for real completions (aborted/cancelled turns stay
|
// Completion sound — only for real completions (aborted/cancelled turns stay
|
||||||
// silent). Plays regardless of visibility so it also reaches a backgrounded tab.
|
// silent). Plays regardless of visibility so it also reaches a backgrounded tab.
|
||||||
|
|
@ -2391,13 +2422,11 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void {
|
||||||
header && questionText ? `${header}: ${questionText}` : questionText || header;
|
header && questionText ? `${header}: ${questionText}` : questionText || header;
|
||||||
|
|
||||||
// Browser notification when the user isn't watching this session.
|
// Browser notification when the user isn't watching this session.
|
||||||
notification.maybeNotifyQuestion(sid, {
|
notification.maybeNotifyQuestion({
|
||||||
isActiveAndVisible:
|
isUserWatching: isUserWatching(sid),
|
||||||
sid === rawState.activeSessionId &&
|
|
||||||
typeof document !== 'undefined' &&
|
|
||||||
document.visibilityState === 'visible',
|
|
||||||
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
|
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
|
||||||
questionPreview: preview,
|
questionPreview: preview,
|
||||||
|
questionId: question.questionId,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
void workspaceState.selectSession(sid);
|
void workspaceState.selectSession(sid);
|
||||||
},
|
},
|
||||||
|
|
@ -2408,6 +2437,23 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void {
|
||||||
sound.maybePlayQuestionSound();
|
sound.maybePlayQuestionSound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onApprovalRequested(sid: string, approval: AppApprovalRequest): void {
|
||||||
|
// Browser notification when the user isn't watching this session.
|
||||||
|
notification.maybeNotifyApproval({
|
||||||
|
isUserWatching: isUserWatching(sid),
|
||||||
|
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
|
||||||
|
toolName: approval.toolName,
|
||||||
|
approvalId: approval.approvalId,
|
||||||
|
onClick: () => {
|
||||||
|
void workspaceState.selectSession(sid);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attention sound — plays regardless of visibility so it also reaches a
|
||||||
|
// backgrounded tab (same as the completion sound).
|
||||||
|
sound.maybePlayApprovalSound();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Composable return
|
// Composable return
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -2479,6 +2525,7 @@ export function useKimiWebClient() {
|
||||||
questions,
|
questions,
|
||||||
activity,
|
activity,
|
||||||
isSending,
|
isSending,
|
||||||
|
isStartingFirstPrompt,
|
||||||
fastMoon: appearance.fastMoon,
|
fastMoon: appearance.fastMoon,
|
||||||
|
|
||||||
// Model + Provider reactive state
|
// Model + Provider reactive state
|
||||||
|
|
@ -2501,9 +2548,11 @@ export function useKimiWebClient() {
|
||||||
setAccent: appearance.setAccent,
|
setAccent: appearance.setAccent,
|
||||||
notifyOnComplete: notification.notifyOnComplete,
|
notifyOnComplete: notification.notifyOnComplete,
|
||||||
notifyOnQuestion: notification.notifyOnQuestion,
|
notifyOnQuestion: notification.notifyOnQuestion,
|
||||||
|
notifyOnApproval: notification.notifyOnApproval,
|
||||||
notifyPermission: notification.notifyPermission,
|
notifyPermission: notification.notifyPermission,
|
||||||
setNotifyOnComplete: notification.setNotifyOnComplete,
|
setNotifyOnComplete: notification.setNotifyOnComplete,
|
||||||
setNotifyOnQuestion: notification.setNotifyOnQuestion,
|
setNotifyOnQuestion: notification.setNotifyOnQuestion,
|
||||||
|
setNotifyOnApproval: notification.setNotifyOnApproval,
|
||||||
soundOnComplete: sound.soundOnComplete,
|
soundOnComplete: sound.soundOnComplete,
|
||||||
setSoundOnComplete: sound.setSoundOnComplete,
|
setSoundOnComplete: sound.setSoundOnComplete,
|
||||||
onboarded,
|
onboarded,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ const SIDEBAR_WIDTH_KEY = STORAGE_KEYS.sidebarWidth;
|
||||||
const SIDEBAR_COLLAPSED_KEY = STORAGE_KEYS.sidebarCollapsed;
|
const SIDEBAR_COLLAPSED_KEY = STORAGE_KEYS.sidebarCollapsed;
|
||||||
const SIDEBAR_DEFAULT = 270;
|
const SIDEBAR_DEFAULT = 270;
|
||||||
const SIDEBAR_MIN = 170;
|
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
|
// 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
|
// 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
|
// 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 { viewportWidth } = useViewportWidth();
|
||||||
const sessionColWidth = ref(SIDEBAR_DEFAULT);
|
const sessionColWidth = ref(SIDEBAR_DEFAULT);
|
||||||
const sidebarCollapsed = ref(false);
|
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
|
// Largest sidebar width that still leaves the conversation pane usable, then
|
||||||
// the right-side panel is open, also reserves its minimum width so the
|
// clamped to SIDEBAR_MAX so it can never be dragged absurdly wide on large
|
||||||
// conversation column can never be squeezed to nothing.
|
// 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 sidebarMax = computed(() => {
|
||||||
const reserve = CONVERSATION_MIN + (toValue(options.previewOpen) ? PREVIEW_MIN : 0);
|
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(() =>
|
const sideWidth = computed(() =>
|
||||||
sidebarCollapsed.value
|
clampPanelWidth(sessionColWidth.value, SIDEBAR_MIN, sidebarMax.value),
|
||||||
? SIDEBAR_COLLAPSED_WIDTH
|
|
||||||
: clampPanelWidth(sessionColWidth.value, SIDEBAR_MIN, sidebarMax.value),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function loadSidebarCollapsed(): void {
|
function loadSidebarCollapsed(): void {
|
||||||
|
|
@ -71,6 +79,7 @@ export function useSidebarLayout(options: UseSidebarLayoutOptions = {}) {
|
||||||
sidebarMax,
|
sidebarMax,
|
||||||
sessionColWidth,
|
sessionColWidth,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
|
sidebarDragging,
|
||||||
sideWidth,
|
sideWidth,
|
||||||
loadSidebarCollapsed,
|
loadSidebarCollapsed,
|
||||||
toggleSidebarCollapse,
|
toggleSidebarCollapse,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export default {
|
||||||
send: 'Send ↵',
|
send: 'Send ↵',
|
||||||
queueLabel: 'Queue',
|
queueLabel: 'Queue',
|
||||||
placeholderRunning: 'Press Enter to queue · Ctrl+S to inject into the running turn',
|
placeholderRunning: 'Press Enter to queue · Ctrl+S to inject into the running turn',
|
||||||
|
starting: 'Sending…',
|
||||||
queueAutoDrain: 'sends automatically when the current turn ends',
|
queueAutoDrain: 'sends automatically when the current turn ends',
|
||||||
queueNext: 'Up next',
|
queueNext: 'Up next',
|
||||||
queueDragTitle: 'Drag to reorder',
|
queueDragTitle: 'Drag to reorder',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export default {
|
||||||
toc: 'Conversation outline',
|
toc: 'Conversation outline',
|
||||||
newMessages: 'Latest messages',
|
newMessages: 'Latest messages',
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
|
starting: 'Starting conversation…',
|
||||||
emptyWorkspaceHint: 'Send in {name}',
|
emptyWorkspaceHint: 'Send in {name}',
|
||||||
switchWorkspace: 'Switch workspace',
|
switchWorkspace: 'Switch workspace',
|
||||||
addWorkspace: 'New workspace',
|
addWorkspace: 'New workspace',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ export default {
|
||||||
gitTooltip: 'Open Files > Changed',
|
gitTooltip: 'Open Files > Changed',
|
||||||
detached: 'detached',
|
detached: 'detached',
|
||||||
openPr: 'Open pull request',
|
openPr: 'Open pull request',
|
||||||
|
prStatusOpen: 'open',
|
||||||
|
prStatusClosed: 'closed',
|
||||||
|
prStatusMerged: 'merged',
|
||||||
|
prStatusDraft: 'draft',
|
||||||
|
prStatusUnknown: 'unknown',
|
||||||
options: 'Options',
|
options: 'Options',
|
||||||
copySessionId: 'Copy Session ID',
|
copySessionId: 'Copy Session ID',
|
||||||
renameSession: 'Rename',
|
renameSession: 'Rename',
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,15 @@ export default {
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
notifyOnComplete: 'Notify when a turn completes',
|
notifyOnComplete: 'Notify when a turn completes',
|
||||||
notifyOnQuestion: 'Notify when a question needs an answer',
|
notifyOnQuestion: 'Notify when a question needs an answer',
|
||||||
soundOnComplete: 'Play a sound when a turn completes or needs an answer',
|
notifyOnApproval: 'Notify when a tool needs approval',
|
||||||
|
soundOnComplete: 'Play a sound when a turn completes, needs an answer, or needs approval',
|
||||||
notifyDenied: 'Blocked in browser settings',
|
notifyDenied: 'Blocked in browser settings',
|
||||||
notifyTitle: 'Kimi Code · Turn finished',
|
notifyTitle: 'Kimi Code · Turn finished',
|
||||||
notifyQuestionTitle: 'Kimi Code · Needs answer',
|
notifyQuestionTitle: 'Kimi Code · Needs answer',
|
||||||
|
notifyApprovalTitle: 'Kimi Code · Approval required',
|
||||||
notifyFallback: 'View result',
|
notifyFallback: 'View result',
|
||||||
notifyQuestionFallback: 'A question is waiting for your answer',
|
notifyQuestionFallback: 'A question is waiting for your answer',
|
||||||
|
notifyApprovalFallback: 'A tool needs your approval',
|
||||||
account: 'Account',
|
account: 'Account',
|
||||||
uiFontSize: 'Font size',
|
uiFontSize: 'Font size',
|
||||||
agentDefaults: 'Agent defaults',
|
agentDefaults: 'Agent defaults',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ export default {
|
||||||
sortRecent: 'Last edited',
|
sortRecent: 'Last edited',
|
||||||
collapseAll: 'Collapse all workspaces',
|
collapseAll: 'Collapse all workspaces',
|
||||||
expandAll: 'Expand all workspaces',
|
expandAll: 'Expand all workspaces',
|
||||||
showWorkspacePaths: 'Show workspace paths',
|
|
||||||
newSession: 'New Session',
|
newSession: 'New Session',
|
||||||
newChat: 'New Chat',
|
newChat: 'New Chat',
|
||||||
newWorkspace: 'New Workspace',
|
newWorkspace: 'New Workspace',
|
||||||
|
|
@ -31,14 +30,14 @@ export default {
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
daemon: 'Daemon',
|
daemon: 'Daemon',
|
||||||
noSessions: 'No conversations yet',
|
noSessions: 'No conversations yet',
|
||||||
showMore: 'Load more ({count})',
|
showMore: 'Load {count} more conversations',
|
||||||
showLess: 'Show less',
|
showLess: 'Show less',
|
||||||
showAll: 'Show all ({count})',
|
showAll: 'Show {count} more conversations',
|
||||||
loadingMore: 'Loading…',
|
loadingMore: 'Loading…',
|
||||||
collapseSidebar: 'Collapse sidebar',
|
collapseSidebar: 'Collapse sidebar',
|
||||||
expandSidebar: 'Expand sidebar',
|
expandSidebar: 'Expand sidebar',
|
||||||
searchPlaceholder: 'Search sessions',
|
searchPlaceholder: 'Search sessions',
|
||||||
searchShortcut: 'Search sessions ({shortcut})',
|
search: 'Search',
|
||||||
searchHint: '↑↓ navigate · ↵ open · Esc close',
|
searchHint: '↑↓ navigate · ↵ open · Esc close',
|
||||||
searchNoResults: 'No matching sessions',
|
searchNoResults: 'No matching sessions',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -12,23 +12,32 @@ export default {
|
||||||
permissionYoloDesc: 'Auto-approve tool actions, but agent may still ask questions',
|
permissionYoloDesc: 'Auto-approve tool actions, but agent may still ask questions',
|
||||||
// Plan mode pill
|
// Plan mode pill
|
||||||
planLabel: 'Plan',
|
planLabel: 'Plan',
|
||||||
|
planDesc: 'Have the agent make a plan before changing files',
|
||||||
planOn: 'on',
|
planOn: 'on',
|
||||||
planOff: 'off',
|
planOff: 'off',
|
||||||
planTooltip: 'Toggle plan mode (research before editing)',
|
planTooltip: 'Toggle plan mode (research before editing)',
|
||||||
// Mode selector (Plan / Goal / Swarm)
|
// Mode selector (Plan / Goal / Swarm)
|
||||||
modesLabel: 'Mode',
|
modesLabel: 'Mode',
|
||||||
goalLabel: 'Goal',
|
goalLabel: 'Goal',
|
||||||
|
goalDesc: 'Track one objective until it is complete',
|
||||||
swarmLabel: 'Swarm',
|
swarmLabel: 'Swarm',
|
||||||
|
swarmDesc: 'Run parallel agents for broader exploration',
|
||||||
modeOff: 'Off',
|
modeOff: 'Off',
|
||||||
goalPlaceholder: 'What should the agent achieve?',
|
goalPlaceholder: 'What should the agent achieve?',
|
||||||
goalStart: 'Start',
|
goalStart: 'Start',
|
||||||
goalPause: 'Pause',
|
goalPause: 'Pause',
|
||||||
goalResume: 'Resume',
|
goalResume: 'Resume',
|
||||||
goalCancel: 'Cancel',
|
goalCancel: 'Cancel',
|
||||||
|
goalStatusActive: 'Active',
|
||||||
|
goalStatusPaused: 'Paused',
|
||||||
|
goalStatusBlocked: 'Blocked',
|
||||||
|
goalStatusComplete: 'Complete',
|
||||||
modeNotSupported: 'Not supported',
|
modeNotSupported: 'Not supported',
|
||||||
// Thinking selector
|
// Thinking selector
|
||||||
thinkingLabel: 'thinking',
|
thinkingLabel: 'thinking',
|
||||||
thinkingTooltip: 'Toggle thinking mode',
|
thinkingTooltip: 'Toggle thinking mode',
|
||||||
|
thinkingOn: 'On',
|
||||||
|
thinkingOff: 'Off',
|
||||||
starredModels: 'Starred',
|
starredModels: 'Starred',
|
||||||
moreModels: 'More models…',
|
moreModels: 'More models…',
|
||||||
// Status panel
|
// Status panel
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ export default {
|
||||||
task: 'Task',
|
task: 'Task',
|
||||||
swarm: 'Swarm',
|
swarm: 'Swarm',
|
||||||
ask_user: 'Question',
|
ask_user: 'Question',
|
||||||
|
goal_create: 'Start Goal',
|
||||||
|
goal_get: 'Read Goal',
|
||||||
|
goal_budget: 'Set Goal Budget',
|
||||||
|
goal_update: 'Update Goal',
|
||||||
},
|
},
|
||||||
swarm: {
|
swarm: {
|
||||||
progress: '{done} / {total}',
|
progress: '{done} / {total}',
|
||||||
|
|
@ -32,6 +36,17 @@ export default {
|
||||||
created: 'created',
|
created: 'created',
|
||||||
todos: '{count} items',
|
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: {
|
group: {
|
||||||
title: '{count} tool call | {count} tool calls',
|
title: '{count} tool call | {count} tool calls',
|
||||||
running: 'running',
|
running: 'running',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export default {
|
||||||
send: '发送 ↵',
|
send: '发送 ↵',
|
||||||
queueLabel: '队列',
|
queueLabel: '队列',
|
||||||
placeholderRunning: '输入会加入队列 · Ctrl+S 立即插入运行中的回合',
|
placeholderRunning: '输入会加入队列 · Ctrl+S 立即插入运行中的回合',
|
||||||
|
starting: '正在发送…',
|
||||||
queueAutoDrain: '当前回合结束后自动逐条发送',
|
queueAutoDrain: '当前回合结束后自动逐条发送',
|
||||||
queueNext: '下一条',
|
queueNext: '下一条',
|
||||||
queueDragTitle: '拖拽排序',
|
queueDragTitle: '拖拽排序',
|
||||||
|
|
@ -22,7 +23,7 @@ export default {
|
||||||
emptyConversationTitle: 'Kimi Code',
|
emptyConversationTitle: 'Kimi Code',
|
||||||
emptyConversation: '还没有消息 —— 在下方输入开始对话',
|
emptyConversation: '还没有消息 —— 在下方输入开始对话',
|
||||||
quickStartPlaceholder: '输入消息开始新对话…',
|
quickStartPlaceholder: '输入消息开始新对话…',
|
||||||
thinkingSuffix: ' · thinking',
|
thinkingSuffix: ' · 思考',
|
||||||
thinkingSuffixEffort: ' · thinking: {level}',
|
thinkingSuffixEffort: ' · 思考: {level}',
|
||||||
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export default {
|
||||||
toc: '对话目录',
|
toc: '对话目录',
|
||||||
newMessages: '最新消息',
|
newMessages: '最新消息',
|
||||||
loading: '加载中…',
|
loading: '加载中…',
|
||||||
|
starting: '正在创建对话…',
|
||||||
emptyWorkspaceHint: '在 {name} 中发送',
|
emptyWorkspaceHint: '在 {name} 中发送',
|
||||||
switchWorkspace: '切换工作区',
|
switchWorkspace: '切换工作区',
|
||||||
addWorkspace: '添加工作区',
|
addWorkspace: '添加工作区',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ export default {
|
||||||
gitTooltip: '打开「文件 > 改动」',
|
gitTooltip: '打开「文件 > 改动」',
|
||||||
detached: '游离',
|
detached: '游离',
|
||||||
openPr: '打开 Pull Request',
|
openPr: '打开 Pull Request',
|
||||||
|
prStatusOpen: '已打开',
|
||||||
|
prStatusClosed: '已关闭',
|
||||||
|
prStatusMerged: '已合并',
|
||||||
|
prStatusDraft: '草稿',
|
||||||
|
prStatusUnknown: '未知',
|
||||||
options: '选项',
|
options: '选项',
|
||||||
copySessionId: '复制 Session ID',
|
copySessionId: '复制 Session ID',
|
||||||
renameSession: '重命名',
|
renameSession: '重命名',
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,15 @@ export default {
|
||||||
notifications: '通知',
|
notifications: '通知',
|
||||||
notifyOnComplete: '会话完成时通知',
|
notifyOnComplete: '会话完成时通知',
|
||||||
notifyOnQuestion: '待回答时通知',
|
notifyOnQuestion: '待回答时通知',
|
||||||
soundOnComplete: '会话完成或待回答时播放提示音',
|
notifyOnApproval: '待审批时通知',
|
||||||
|
soundOnComplete: '会话完成、待回答或待审批时播放提示音',
|
||||||
notifyDenied: '已在浏览器设置中被阻止',
|
notifyDenied: '已在浏览器设置中被阻止',
|
||||||
notifyTitle: 'Kimi Code · 回合完成',
|
notifyTitle: 'Kimi Code · 回合完成',
|
||||||
notifyQuestionTitle: 'Kimi Code · 待回答',
|
notifyQuestionTitle: 'Kimi Code · 待回答',
|
||||||
|
notifyApprovalTitle: 'Kimi Code · 等待审批',
|
||||||
notifyFallback: '点击查看结果',
|
notifyFallback: '点击查看结果',
|
||||||
notifyQuestionFallback: '有提问等待你回答',
|
notifyQuestionFallback: '有提问等待你回答',
|
||||||
|
notifyApprovalFallback: '有工具等待你审批',
|
||||||
account: '账户',
|
account: '账户',
|
||||||
uiFontSize: '字体大小',
|
uiFontSize: '字体大小',
|
||||||
agentDefaults: 'Agent 默认值',
|
agentDefaults: 'Agent 默认值',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ export default {
|
||||||
sortRecent: '按最后编辑时间',
|
sortRecent: '按最后编辑时间',
|
||||||
collapseAll: '折叠全部工作区',
|
collapseAll: '折叠全部工作区',
|
||||||
expandAll: '展开全部工作区',
|
expandAll: '展开全部工作区',
|
||||||
showWorkspacePaths: '显示工作区路径',
|
|
||||||
newSession: '新建会话',
|
newSession: '新建会话',
|
||||||
newChat: '新建对话',
|
newChat: '新建对话',
|
||||||
newWorkspace: '新建工作区',
|
newWorkspace: '新建工作区',
|
||||||
|
|
@ -31,14 +30,14 @@ export default {
|
||||||
language: '语言',
|
language: '语言',
|
||||||
daemon: '后台',
|
daemon: '后台',
|
||||||
noSessions: '暂无对话',
|
noSessions: '暂无对话',
|
||||||
showMore: '加载更多 ({count})',
|
showMore: '加载更多 {count} 个对话',
|
||||||
showLess: '收起',
|
showLess: '收起',
|
||||||
showAll: '展开 ({count})',
|
showAll: '展开剩余 {count} 个对话',
|
||||||
loadingMore: '加载中…',
|
loadingMore: '加载中…',
|
||||||
collapseSidebar: '收起侧边栏',
|
collapseSidebar: '收起侧边栏',
|
||||||
expandSidebar: '展开侧边栏',
|
expandSidebar: '展开侧边栏',
|
||||||
searchPlaceholder: '搜索会话',
|
searchPlaceholder: '搜索会话',
|
||||||
searchShortcut: '搜索会话 ({shortcut})',
|
search: '搜索',
|
||||||
searchHint: '↑↓ 选择 · ↵ 打开 · Esc 关闭',
|
searchHint: '↑↓ 选择 · ↵ 打开 · Esc 关闭',
|
||||||
searchNoResults: '没有匹配的会话',
|
searchNoResults: '没有匹配的会话',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,27 +8,36 @@ export default {
|
||||||
permissionAuto: '完全自主',
|
permissionAuto: '完全自主',
|
||||||
permissionYolo: '自动通过',
|
permissionYolo: '自动通过',
|
||||||
permissionManualDesc: '每个工具操作都需要你手动确认',
|
permissionManualDesc: '每个工具操作都需要你手动确认',
|
||||||
permissionAutoDesc: '完全自主运行,agent 自己做决定,不再询问',
|
permissionAutoDesc: '完全自主运行,智能体自己做决定,不再询问',
|
||||||
permissionYoloDesc: '自动批准工具操作,但遇到关键问题仍会询问',
|
permissionYoloDesc: '自动批准工具操作,但遇到关键问题仍会询问',
|
||||||
// 计划模式
|
// 计划模式
|
||||||
planLabel: '计划',
|
planLabel: '计划',
|
||||||
|
planDesc: '先让智能体梳理计划,再修改文件',
|
||||||
planOn: '开',
|
planOn: '开',
|
||||||
planOff: '关',
|
planOff: '关',
|
||||||
planTooltip: '切换计划模式(先调研再修改)',
|
planTooltip: '切换计划模式(先调研再修改)',
|
||||||
// 模式选择器(计划 / 目标 / Swarm)
|
// 模式选择器(计划 / 目标 / Swarm)
|
||||||
modesLabel: '模式',
|
modesLabel: '模式',
|
||||||
goalLabel: '目标',
|
goalLabel: '目标',
|
||||||
|
goalDesc: '持续跟踪一个目标,直到任务完成',
|
||||||
swarmLabel: 'Swarm',
|
swarmLabel: 'Swarm',
|
||||||
|
swarmDesc: '并行运行多个智能体,适合大范围探索',
|
||||||
modeOff: '未启用',
|
modeOff: '未启用',
|
||||||
goalPlaceholder: '让 Agent 完成什么目标?',
|
goalPlaceholder: '让智能体完成什么目标?',
|
||||||
goalStart: '开始',
|
goalStart: '开始',
|
||||||
goalPause: '暂停',
|
goalPause: '暂停',
|
||||||
goalResume: '继续',
|
goalResume: '继续',
|
||||||
goalCancel: '取消',
|
goalCancel: '取消',
|
||||||
|
goalStatusActive: '进行中',
|
||||||
|
goalStatusPaused: '已暂停',
|
||||||
|
goalStatusBlocked: '已阻塞',
|
||||||
|
goalStatusComplete: '已完成',
|
||||||
modeNotSupported: '暂不支持',
|
modeNotSupported: '暂不支持',
|
||||||
// 思考强度选择
|
// 思考强度选择
|
||||||
thinkingLabel: '思考',
|
thinkingLabel: '思考',
|
||||||
thinkingTooltip: '切换思考模式',
|
thinkingTooltip: '切换思考模式',
|
||||||
|
thinkingOn: '开',
|
||||||
|
thinkingOff: '关',
|
||||||
starredModels: '收藏',
|
starredModels: '收藏',
|
||||||
moreModels: '更多模型…',
|
moreModels: '更多模型…',
|
||||||
// 状态面板
|
// 状态面板
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ export default {
|
||||||
task: '任务',
|
task: '任务',
|
||||||
swarm: 'Swarm',
|
swarm: 'Swarm',
|
||||||
ask_user: '提问',
|
ask_user: '提问',
|
||||||
|
goal_create: '启动目标',
|
||||||
|
goal_get: '读取目标',
|
||||||
|
goal_budget: '设置目标预算',
|
||||||
|
goal_update: '更新目标',
|
||||||
},
|
},
|
||||||
swarm: {
|
swarm: {
|
||||||
progress: '{done} / {total}',
|
progress: '{done} / {total}',
|
||||||
|
|
@ -32,6 +36,17 @@ export default {
|
||||||
created: '已创建',
|
created: '已创建',
|
||||||
todos: '{count} 项',
|
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: {
|
group: {
|
||||||
title: '{count} 个工具调用',
|
title: '{count} 个工具调用',
|
||||||
running: '运行中',
|
running: '运行中',
|
||||||
|
|
|
||||||
4
apps/kimi-web/src/icons/kimi/add-conversation.svg
Normal file
4
apps/kimi-web/src/icons/kimi/add-conversation.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.1 12.9001V15.0909C11.1 15.593 11.5029 16 12 16C12.4971 16 12.9 15.593 12.9 15.0909V12.9001H15.0909C15.593 12.9001 16 12.4972 16 12.0001C16 11.5031 15.593 11.1001 15.0909 11.1001H12.9V8.90909C12.9 8.40701 12.4971 8 12 8C11.5029 8 11.1 8.40701 11.1 8.90909V11.1001H8.90909C8.40701 11.1001 8 11.5031 8 12.0001C8 12.4972 8.40701 12.9001 8.90909 12.9001H11.1Z" fill="currentColor"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9996 2.1001C6.53199 2.1001 2.09961 6.53248 2.09961 12.0001C2.09961 13.9226 2.64847 15.7192 3.59804 17.2391L2.517 19.8207C2.10313 20.8091 2.82908 21.9001 3.90059 21.9001H11.9996C17.4672 21.9001 21.8996 17.4677 21.8996 12.0001C21.8996 6.53248 17.4672 2.1001 11.9996 2.1001ZM3.89961 12.0001C3.89961 7.52659 7.5261 3.9001 11.9996 3.9001C16.4731 3.9001 20.0996 7.52659 20.0996 12.0001C20.0996 16.4736 16.4724 20.1001 11.9989 20.1001H4.35146L5.63494 17.0351L5.35165 16.6291C4.43632 15.3172 3.89961 13.7227 3.89961 12.0001Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
10
apps/kimi-web/src/icons/kimi/folder-open.svg
Normal file
10
apps/kimi-web/src/icons/kimi/folder-open.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_4626_2033)">
|
||||||
|
<path d="M18.3623 9.99976L18.209 8.48999C18.2031 8.43161 18.2004 8.37289 18.2002 8.31421C18.1988 8.31196 18.1956 8.30842 18.1904 8.30347C18.1718 8.28559 18.1302 8.26245 18.0713 8.26245H11C10.261 8.26245 9.59753 7.81016 9.32617 7.1228L8.9082 6.06421C8.88101 5.9953 8.85737 5.92501 8.83887 5.85327C8.83778 5.85099 8.833 5.84268 8.81836 5.83179C8.79454 5.81475 8.7549 5.79939 8.70605 5.80054H3.92871C3.86986 5.80054 3.82825 5.82368 3.80957 5.84155C3.80816 5.8429 3.80675 5.84428 3.80566 5.84546L4.47559 14.0955L3.62109 17.5154L5.12109 11.5154C5.34367 10.6251 6.1438 9.99977 7.06152 9.99976H18.3623ZM7.06152 11.7996C6.96976 11.7996 6.88944 11.8629 6.86719 11.9519L5.36719 17.9519C5.33598 18.078 5.43158 18.1999 5.56152 18.2H19.4385C19.5302 18.1999 19.6106 18.1376 19.6328 18.0486L21.1328 12.0486C21.1644 11.9224 21.0686 11.7996 20.9385 11.7996H7.06152ZM20.9385 9.99976C22.2396 9.99977 23.1945 11.2228 22.8789 12.4851L21.3789 18.4851C21.1563 19.3754 20.3562 19.9997 19.4385 19.9998H4.92871C4.41722 19.9998 3.92613 19.8059 3.56445 19.4597C3.20281 19.1135 3.00004 18.6436 3 18.1541L2 5.84644C2.00006 5.35711 2.20311 4.88786 2.56445 4.54175C2.92613 4.19554 3.41722 4.00073 3.92871 4.00073H8.66406C9.10133 3.99051 9.5296 4.1225 9.87793 4.37573C10.2285 4.63118 10.4767 4.99457 10.582 5.40405L11 6.46167H18.0713C18.5828 6.46167 19.0739 6.65648 19.4355 7.00269C19.7971 7.34888 20 7.81883 20 8.30835L20.1719 9.99976H20.9385Z" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_4626_2033">
|
||||||
|
<rect width="24" height="24" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
3
apps/kimi-web/src/icons/kimi/folder.svg
Normal file
3
apps/kimi-web/src/icons/kimi/folder.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.2373 3.7002C10.4169 3.7002 11.5297 4.24779 12.249 5.18262L12.4424 5.43359H18C20.0987 5.43359 21.7998 7.13472 21.7998 9.2334V16.5C21.7998 18.5987 20.0987 20.2998 18 20.2998H6C3.90132 20.2998 2.2002 18.5987 2.2002 16.5V7.5C2.2002 5.40132 3.90132 3.7002 6 3.7002H9.2373ZM6 5.5C4.89543 5.5 4 6.39543 4 7.5V16.5C4 17.6046 4.89543 18.5 6 18.5H18C19.0357 18.5 19.887 17.7128 19.9893 16.7041L20 16.5V9.2334C20 8.19775 19.2128 7.34641 18.2041 7.24414L18 7.2334H12.0479L11.9326 7.22656C11.666 7.19561 11.4205 7.05812 11.2549 6.84277L10.8223 6.28027C10.4437 5.78834 9.85808 5.5 9.2373 5.5H6ZM16 9.59961C16.4971 9.59961 16.9004 10.0029 16.9004 10.5C16.9004 10.9971 16.4971 11.4004 16 11.4004H8C7.50294 11.4004 7.09961 10.9971 7.09961 10.5C7.09961 10.0029 7.50294 9.59961 8 9.59961H16Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 911 B |
5
apps/kimi-web/src/icons/kimi/more.svg
Normal file
5
apps/kimi-web/src/icons/kimi/more.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 12C6 12.8283 5.32834 13.5 4.5 13.5C3.67166 13.5 3 12.8283 3 12C3 11.1717 3.67166 10.5 4.5 10.5C5.32834 10.5 6 11.1717 6 12Z" fill="currentColor"/>
|
||||||
|
<path d="M13.5 12C13.5 12.8283 12.8283 13.5 12 13.5C11.1717 13.5 10.5 12.8283 10.5 12C10.5 11.1717 11.1717 10.5 12 10.5C12.8283 10.5 13.5 11.1717 13.5 12Z" fill="currentColor"/>
|
||||||
|
<path d="M19.5002 13.5C20.3287 13.5 21 12.8287 21 12.0002C21 11.1718 20.3287 10.5 19.5002 10.5C18.6718 10.5 18 11.1718 18 12.0002C18 12.8287 18.6718 13.5 19.5002 13.5Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 631 B |
3
apps/kimi-web/src/icons/kimi/search.svg
Normal file
3
apps/kimi-web/src/icons/kimi/search.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.5 3C16.1944 3 20 6.80558 20 11.5C20 13.523 19.2933 15.381 18.1132 16.8404L21.1364 19.8636C21.4879 20.2151 21.4879 20.7849 21.1364 21.1364C20.7849 21.4879 20.2151 21.4879 19.8636 21.1364L16.8404 18.1132C15.381 19.2933 13.523 20 11.5 20C6.80558 20 3 16.1944 3 11.5C3 6.80558 6.80558 3 11.5 3ZM11.5 18.2C15.2003 18.2 18.2 15.2003 18.2 11.5C18.2 7.79969 15.2003 4.8 11.5 4.8C7.79969 4.8 4.8 7.79969 4.8 11.5C4.8 15.2003 7.79969 18.2 11.5 18.2Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 579 B |
4
apps/kimi-web/src/icons/kimi/setting.svg
Normal file
4
apps/kimi-web/src/icons/kimi/setting.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.0404 12C16.0404 9.76874 14.2313 7.9596 12.0001 7.9596C9.76883 7.9596 7.95972 9.76874 7.95972 12C7.95972 14.2313 9.76883 16.0404 12.0001 16.0404C14.2313 16.0404 16.0404 14.2313 16.0404 12ZM14.2222 12C14.2222 13.2271 13.2271 14.2222 12 14.2222C10.7729 14.2222 9.77783 13.2271 9.77783 12C9.77783 10.7729 10.7729 9.77778 12 9.77778C13.2271 9.77778 14.2222 10.7729 14.2222 12Z" fill="currentColor"/>
|
||||||
|
<path d="M9.91145 21.8009C9.29001 21.6797 8.76914 21.2612 8.50632 20.6922L8.07372 19.7556C7.88838 19.3544 7.43553 19.1048 6.95371 19.1549L5.89572 19.2647C5.2733 19.3293 4.64823 19.114 4.22298 18.6611C3.74343 18.1504 3.32454 17.6037 2.97033 17.0181C2.61571 16.4318 2.32839 15.8106 2.10407 15.1566C1.89769 14.5549 2.02148 13.8954 2.4089 13.3902L3.0376 12.5704C3.30043 12.2277 3.30042 11.7722 3.03758 11.4295L2.40413 10.6035C2.01474 10.0958 1.891 9.43198 2.10208 8.82826C2.55037 7.54612 3.27017 6.35997 4.22 5.34259C4.64518 4.8872 5.27275 4.67067 5.89701 4.73544L6.95383 4.84514C7.43561 4.89515 7.88844 4.6456 8.07377 4.24441L8.50266 3.31593C8.76494 2.74818 9.28448 2.33019 9.90423 2.20761C11.2916 1.9332 12.7148 1.93127 14.0885 2.19913C14.7099 2.32029 15.2308 2.73881 15.4937 3.3078L15.9263 4.24441C16.1116 4.6456 16.5644 4.89514 17.0462 4.84514L18.1043 4.73532C18.7267 4.67072 19.3518 4.88603 19.777 5.33886C20.2566 5.84953 20.6755 6.3963 21.0297 6.98193C21.3843 7.56823 21.6716 8.18942 21.8959 8.84339C22.1023 9.44509 21.9785 10.1046 21.5911 10.6098L20.9624 11.4295C20.6996 11.7722 20.6996 12.2278 20.9624 12.5705L21.5959 13.3964C21.9853 13.9042 22.109 14.568 21.8979 15.1717C21.4497 16.4538 20.7299 17.6399 19.7801 18.6573C19.3549 19.1128 18.7273 19.3294 18.103 19.2646L17.0462 19.1549C16.5645 19.1049 16.1116 19.3544 15.9263 19.7556L15.4974 20.6841C15.2351 21.2518 14.7156 21.6698 14.0958 21.7924C12.7083 22.0668 11.2852 22.0687 9.91145 21.8009ZM13.7432 20.0088C13.7844 20.0006 13.8259 19.9673 13.847 19.9216L14.2758 18.9931C14.7915 17.8768 15.9886 17.2171 17.2341 17.3464L18.2909 17.4561C18.3649 17.4638 18.4272 17.4423 18.4512 17.4166C19.2296 16.5828 19.8171 15.6146 20.1817 14.5716C20.1845 14.5636 20.1796 14.5373 20.1532 14.5029L19.5198 13.677C18.7564 12.6815 18.7564 11.3185 19.5198 10.323L20.1485 9.5033C20.1746 9.46927 20.1795 9.4429 20.1762 9.43327C19.9932 8.89965 19.7603 8.39623 19.4741 7.92293C19.1873 7.4489 18.846 7.00333 18.4517 6.58351C18.4272 6.55739 18.3656 6.53616 18.2921 6.54378L17.234 6.65361C15.9886 6.78287 14.7915 6.12317 14.2758 5.00689L13.8432 4.07027C13.822 4.02448 13.7811 3.9916 13.7406 3.98371C12.5983 3.76097 11.4132 3.76258 10.2571 3.99124C10.2158 3.99941 10.1744 4.03271 10.1533 4.07842L9.72441 5.00689C9.20875 6.12317 8.01164 6.7829 6.76619 6.6536L5.70942 6.54391C5.63535 6.53623 5.573 6.55774 5.54905 6.5834C4.77067 7.41713 4.18312 8.38534 3.81845 9.42835C3.81564 9.43637 3.82054 9.46265 3.84693 9.49706L4.48038 10.323C5.24381 11.3185 5.24383 12.6815 4.48041 13.6769L3.85171 14.4967C3.82561 14.5307 3.82066 14.5571 3.82396 14.5667C4.00701 15.1004 4.23986 15.6038 4.52613 16.0771C4.81284 16.5511 5.15421 16.9967 5.54845 17.4165C5.57298 17.4426 5.63461 17.4638 5.70811 17.4562L6.76608 17.3464C8.01157 17.2171 9.20871 17.8768 9.72438 18.9932L10.157 19.9297C10.1781 19.9755 10.2191 20.0084 10.2595 20.0163C11.4018 20.239 12.587 20.2374 13.7432 20.0088Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -1,13 +1,22 @@
|
||||||
// apps/kimi-web/src/lib/icons.ts
|
// apps/kimi-web/src/lib/icons.ts
|
||||||
// Single source of truth for apps/kimi-web icons (design-system §02).
|
// 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
|
// Icons come from three collections, all bundled by unplugin-icons at build
|
||||||
// bundled by unplugin-icons at build time — only the icons listed below end up
|
// time — only the icons listed below end up in the production bundle:
|
||||||
// in the production bundle. Each icon is imported twice: once as a Vue
|
// - `~icons/kimi/*` — Kimi Design System icons (24×24 outlined,
|
||||||
// component (for <Icon name=... />) and once as a `?raw` SVG string (for
|
// fill="currentColor"), local SVGs under src/icons/kimi/ registered as a
|
||||||
// iconSvg() in v-html contexts such as lib/toolMeta.ts).
|
// custom collection in vite.config.ts. Preferred when a Kimi icon exists
|
||||||
|
// for the intent.
|
||||||
|
// - `~icons/tabler/*` — Tabler Icons (https://tabler.io/icons, MIT),
|
||||||
|
// 24×24 stroke-based (stroke="currentColor"); used for the sidebar
|
||||||
|
// panel toggle, which neither pack above covers well.
|
||||||
|
// - `~icons/ri/*` — Remix Icon (https://remixicon.com/, Apache-2.0) for
|
||||||
|
// the remaining intents.
|
||||||
|
// Each icon is imported twice: once as a Vue component (for <Icon name=... />)
|
||||||
|
// and once as a `?raw` SVG string (for iconSvg() in v-html contexts such as
|
||||||
|
// lib/toolMeta.ts).
|
||||||
//
|
//
|
||||||
// Remix icons are fill-based (fill="currentColor") on a 24x24 source grid; the
|
// All collections share the 24x24 source grid and follow currentColor; the
|
||||||
// rendered size comes from the size token prop. Colour follows text.
|
// rendered size comes from the size token prop. Colour follows text.
|
||||||
//
|
//
|
||||||
// Two consumers share this registry:
|
// Two consumers share this registry:
|
||||||
|
|
@ -16,7 +25,19 @@
|
||||||
|
|
||||||
import type { Component } from 'vue';
|
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 RiAddLine from '~icons/ri/add-line';
|
||||||
import RiAlertLine from '~icons/ri/alert-line';
|
import RiAlertLine from '~icons/ri/alert-line';
|
||||||
import RiArrowDownLine from '~icons/ri/arrow-down-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 RiCalendarCloseLine from '~icons/ri/calendar-close-line';
|
||||||
import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line';
|
import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line';
|
||||||
import RiCalendarTodoLine from '~icons/ri/calendar-todo-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 RiCheckLine from '~icons/ri/check-line';
|
||||||
import RiCloseLine from '~icons/ri/close-line';
|
import RiCloseLine from '~icons/ri/close-line';
|
||||||
import RiCodeLine from '~icons/ri/code-line';
|
import RiCodeLine from '~icons/ri/code-line';
|
||||||
import RiCollapseDiagonalLine from '~icons/ri/collapse-diagonal-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 RiDownloadLine from '~icons/ri/download-line';
|
||||||
import RiDraggable from '~icons/ri/draggable';
|
import RiDraggable from '~icons/ri/draggable';
|
||||||
import RiEqualizerLine from '~icons/ri/equalizer-line';
|
import RiEqualizerLine from '~icons/ri/equalizer-line';
|
||||||
import RiExpandDiagonalLine from '~icons/ri/expand-diagonal-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 RiExternalLinkLine from '~icons/ri/external-link-line';
|
||||||
import RiFileAddLine from '~icons/ri/file-add-line';
|
import RiFileAddLine from '~icons/ri/file-add-line';
|
||||||
import RiFileCopyLine from '~icons/ri/file-copy-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 RiFileLine from '~icons/ri/file-line';
|
||||||
import RiFileTextLine from '~icons/ri/file-text-line';
|
import RiFileTextLine from '~icons/ri/file-text-line';
|
||||||
import RiFlashlightLine from '~icons/ri/flashlight-line';
|
import RiFlashlightLine from '~icons/ri/flashlight-line';
|
||||||
import RiFolderAddLine from '~icons/ri/folder-add-line';
|
import RiFolderAddLine from '~icons/ri/folder-add-line';
|
||||||
import RiFolderFill from '~icons/ri/folder-fill';
|
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 RiGitPullRequestLine from '~icons/ri/git-pull-request-line';
|
||||||
import RiGlobalLine from '~icons/ri/global-line';
|
import RiGlobalLine from '~icons/ri/global-line';
|
||||||
import RiImageLine from '~icons/ri/image-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 RiLoginBoxLine from '~icons/ri/login-box-line';
|
||||||
import RiMailLine from '~icons/ri/mail-line';
|
import RiMailLine from '~icons/ri/mail-line';
|
||||||
import RiMessageLine from '~icons/ri/message-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 RiPencilLine from '~icons/ri/pencil-line';
|
||||||
import RiPlayFill from '~icons/ri/play-fill';
|
import RiPlayFill from '~icons/ri/play-fill';
|
||||||
import RiQuestionLine from '~icons/ri/question-line';
|
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 RiSortDesc from '~icons/ri/sort-desc';
|
||||||
import RiSparklingLine from '~icons/ri/sparkling-line';
|
import RiSparklingLine from '~icons/ri/sparkling-line';
|
||||||
import RiStarFill from '~icons/ri/star-fill';
|
import RiStarFill from '~icons/ri/star-fill';
|
||||||
import RiStarLine from '~icons/ri/star-line';
|
import RiStarLine from '~icons/ri/star-line';
|
||||||
import RiStopFill from '~icons/ri/stop-fill';
|
import RiStopFill from '~icons/ri/stop-fill';
|
||||||
import RiSubtractLine from '~icons/ri/subtract-line';
|
import RiSubtractLine from '~icons/ri/subtract-line';
|
||||||
|
import RiTargetLine from '~icons/ri/target-line';
|
||||||
import RiTerminalBoxLine from '~icons/ri/terminal-box-line';
|
import RiTerminalBoxLine from '~icons/ri/terminal-box-line';
|
||||||
import RiTimeLine from '~icons/ri/time-line';
|
import RiTimeLine from '~icons/ri/time-line';
|
||||||
import RiToolsLine from '~icons/ri/tools-line';
|
import RiToolsLine from '~icons/ri/tools-line';
|
||||||
import RiUserLine from '~icons/ri/user-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 RawAddLine from '~icons/ri/add-line?raw';
|
||||||
import RawAlertLine from '~icons/ri/alert-line?raw';
|
import RawAlertLine from '~icons/ri/alert-line?raw';
|
||||||
import RawArrowDownLine from '~icons/ri/arrow-down-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 RawCalendarCloseLine from '~icons/ri/calendar-close-line?raw';
|
||||||
import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw';
|
import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw';
|
||||||
import RawCalendarTodoLine from '~icons/ri/calendar-todo-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 RawCheckLine from '~icons/ri/check-line?raw';
|
||||||
import RawCloseLine from '~icons/ri/close-line?raw';
|
import RawCloseLine from '~icons/ri/close-line?raw';
|
||||||
import RawCodeLine from '~icons/ri/code-line?raw';
|
import RawCodeLine from '~icons/ri/code-line?raw';
|
||||||
import RawCollapseDiagonalLine from '~icons/ri/collapse-diagonal-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 RawDownloadLine from '~icons/ri/download-line?raw';
|
||||||
import RawDraggable from '~icons/ri/draggable?raw';
|
import RawDraggable from '~icons/ri/draggable?raw';
|
||||||
import RawEqualizerLine from '~icons/ri/equalizer-line?raw';
|
import RawEqualizerLine from '~icons/ri/equalizer-line?raw';
|
||||||
import RawExpandDiagonalLine from '~icons/ri/expand-diagonal-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 RawExternalLinkLine from '~icons/ri/external-link-line?raw';
|
||||||
import RawFileAddLine from '~icons/ri/file-add-line?raw';
|
import RawFileAddLine from '~icons/ri/file-add-line?raw';
|
||||||
import RawFileCopyLine from '~icons/ri/file-copy-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 RawFileLine from '~icons/ri/file-line?raw';
|
||||||
import RawFileTextLine from '~icons/ri/file-text-line?raw';
|
import RawFileTextLine from '~icons/ri/file-text-line?raw';
|
||||||
import RawFlashlightLine from '~icons/ri/flashlight-line?raw';
|
import RawFlashlightLine from '~icons/ri/flashlight-line?raw';
|
||||||
import RawFolderAddLine from '~icons/ri/folder-add-line?raw';
|
import RawFolderAddLine from '~icons/ri/folder-add-line?raw';
|
||||||
import RawFolderFill from '~icons/ri/folder-fill?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 RawGitPullRequestLine from '~icons/ri/git-pull-request-line?raw';
|
||||||
import RawGlobalLine from '~icons/ri/global-line?raw';
|
import RawGlobalLine from '~icons/ri/global-line?raw';
|
||||||
import RawImageLine from '~icons/ri/image-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 RawLoginBoxLine from '~icons/ri/login-box-line?raw';
|
||||||
import RawMailLine from '~icons/ri/mail-line?raw';
|
import RawMailLine from '~icons/ri/mail-line?raw';
|
||||||
import RawMessageLine from '~icons/ri/message-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 RawPencilLine from '~icons/ri/pencil-line?raw';
|
||||||
import RawPlayFill from '~icons/ri/play-fill?raw';
|
import RawPlayFill from '~icons/ri/play-fill?raw';
|
||||||
import RawQuestionLine from '~icons/ri/question-line?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 RawSortDesc from '~icons/ri/sort-desc?raw';
|
||||||
import RawSparklingLine from '~icons/ri/sparkling-line?raw';
|
import RawSparklingLine from '~icons/ri/sparkling-line?raw';
|
||||||
import RawStarFill from '~icons/ri/star-fill?raw';
|
import RawStarFill from '~icons/ri/star-fill?raw';
|
||||||
import RawStarLine from '~icons/ri/star-line?raw';
|
import RawStarLine from '~icons/ri/star-line?raw';
|
||||||
import RawStopFill from '~icons/ri/stop-fill?raw';
|
import RawStopFill from '~icons/ri/stop-fill?raw';
|
||||||
import RawSubtractLine from '~icons/ri/subtract-line?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 RawTerminalBoxLine from '~icons/ri/terminal-box-line?raw';
|
||||||
import RawTimeLine from '~icons/ri/time-line?raw';
|
import RawTimeLine from '~icons/ri/time-line?raw';
|
||||||
import RawToolsLine from '~icons/ri/tools-line?raw';
|
import RawToolsLine from '~icons/ri/tools-line?raw';
|
||||||
|
|
@ -177,6 +200,7 @@ export type IconName =
|
||||||
| 'folder-solid'
|
| 'folder-solid'
|
||||||
| 'file'
|
| 'file'
|
||||||
| 'file-text'
|
| 'file-text'
|
||||||
|
| 'file-edit'
|
||||||
| 'file-plus'
|
| 'file-plus'
|
||||||
| 'file-off'
|
| 'file-off'
|
||||||
| 'image-off'
|
| 'image-off'
|
||||||
|
|
@ -197,6 +221,8 @@ export type IconName =
|
||||||
| 'alert-triangle'
|
| 'alert-triangle'
|
||||||
| 'clock'
|
| 'clock'
|
||||||
| 'sparkles'
|
| 'sparkles'
|
||||||
|
| 'target'
|
||||||
|
| 'pause'
|
||||||
| 'play'
|
| 'play'
|
||||||
| 'stop'
|
| 'stop'
|
||||||
| 'star'
|
| 'star'
|
||||||
|
|
@ -220,13 +246,13 @@ function entry(component: Component, svg: string): IconEntry {
|
||||||
|
|
||||||
export const ICONS: Record<IconName, IconEntry> = {
|
export const ICONS: Record<IconName, IconEntry> = {
|
||||||
plus: entry(RiAddLine, RawAddLine),
|
plus: entry(RiAddLine, RawAddLine),
|
||||||
'chat-new': entry(RiChatNewLine, RawChatNewLine),
|
'chat-new': entry(KimiAddConversation, RawKimiAddConversation),
|
||||||
'calendar-close': entry(RiCalendarCloseLine, RawCalendarCloseLine),
|
'calendar-close': entry(RiCalendarCloseLine, RawCalendarCloseLine),
|
||||||
'calendar-schedule': entry(RiCalendarScheduleLine, RawCalendarScheduleLine),
|
'calendar-schedule': entry(RiCalendarScheduleLine, RawCalendarScheduleLine),
|
||||||
'calendar-todo': entry(RiCalendarTodoLine, RawCalendarTodoLine),
|
'calendar-todo': entry(RiCalendarTodoLine, RawCalendarTodoLine),
|
||||||
close: entry(RiCloseLine, RawCloseLine),
|
close: entry(RiCloseLine, RawCloseLine),
|
||||||
check: entry(RiCheckLine, RawCheckLine),
|
check: entry(RiCheckLine, RawCheckLine),
|
||||||
search: entry(RiSearchLine, RawSearchLine),
|
search: entry(KimiSearch, RawKimiSearch),
|
||||||
copy: entry(RiFileCopyLine, RawFileCopyLine),
|
copy: entry(RiFileCopyLine, RawFileCopyLine),
|
||||||
link: entry(RiLinksLine, RawLinksLine),
|
link: entry(RiLinksLine, RawLinksLine),
|
||||||
'external-link': entry(RiExternalLinkLine, RawExternalLinkLine),
|
'external-link': entry(RiExternalLinkLine, RawExternalLinkLine),
|
||||||
|
|
@ -234,7 +260,7 @@ export const ICONS: Record<IconName, IconEntry> = {
|
||||||
undo: entry(RiArrowGoBackLine, RawArrowGoBackLine),
|
undo: entry(RiArrowGoBackLine, RawArrowGoBackLine),
|
||||||
send: entry(RiArrowUpLine, RawArrowUpLine),
|
send: entry(RiArrowUpLine, RawArrowUpLine),
|
||||||
image: entry(RiImageLine, RawImageLine),
|
image: entry(RiImageLine, RawImageLine),
|
||||||
settings: entry(RiSettings3Line, RawSettings3Line),
|
settings: entry(KimiSetting, RawKimiSetting),
|
||||||
sliders: entry(RiEqualizerLine, RawEqualizerLine),
|
sliders: entry(RiEqualizerLine, RawEqualizerLine),
|
||||||
'log-in': entry(RiLoginBoxLine, RawLoginBoxLine),
|
'log-in': entry(RiLoginBoxLine, RawLoginBoxLine),
|
||||||
'chevron-down': entry(RiArrowDownSLine, RawArrowDownSLine),
|
'chevron-down': entry(RiArrowDownSLine, RawArrowDownSLine),
|
||||||
|
|
@ -243,19 +269,20 @@ export const ICONS: Record<IconName, IconEntry> = {
|
||||||
'arrow-down': entry(RiArrowDownLine, RawArrowDownLine),
|
'arrow-down': entry(RiArrowDownLine, RawArrowDownLine),
|
||||||
'arrow-right': entry(RiArrowRightLine, RawArrowRightLine),
|
'arrow-right': entry(RiArrowRightLine, RawArrowRightLine),
|
||||||
minus: entry(RiSubtractLine, RawSubtractLine),
|
minus: entry(RiSubtractLine, RawSubtractLine),
|
||||||
'panel-collapse': entry(RiContractLeftLine, RawContractLeftLine),
|
'panel-collapse': entry(TablerSidebarLeftCollapse, RawTablerSidebarLeftCollapse),
|
||||||
'panel-expand': entry(RiExpandRightLine, RawExpandRightLine),
|
'panel-expand': entry(TablerSidebarLeftExpand, RawTablerSidebarLeftExpand),
|
||||||
expand: entry(RiExpandDiagonalLine, RawExpandDiagonalLine),
|
expand: entry(RiExpandDiagonalLine, RawExpandDiagonalLine),
|
||||||
collapse: entry(RiCollapseDiagonalLine, RawCollapseDiagonalLine),
|
collapse: entry(RiCollapseDiagonalLine, RawCollapseDiagonalLine),
|
||||||
list: entry(RiListUnordered, RawListUnordered),
|
list: entry(RiListUnordered, RawListUnordered),
|
||||||
sort: entry(RiSortDesc, RawSortDesc),
|
sort: entry(RiSortDesc, RawSortDesc),
|
||||||
grip: entry(RiDraggable, RawDraggable),
|
grip: entry(RiDraggable, RawDraggable),
|
||||||
folder: entry(RiFolderOpenLine, RawFolderOpenLine),
|
folder: entry(KimiFolderOpen, RawKimiFolderOpen),
|
||||||
'folder-closed': entry(RiFolderLine, RawFolderLine),
|
'folder-closed': entry(KimiFolder, RawKimiFolder),
|
||||||
'folder-plus': entry(RiFolderAddLine, RawFolderAddLine),
|
'folder-plus': entry(RiFolderAddLine, RawFolderAddLine),
|
||||||
'folder-solid': entry(RiFolderFill, RawFolderFill),
|
'folder-solid': entry(RiFolderFill, RawFolderFill),
|
||||||
file: entry(RiFileLine, RawFileLine),
|
file: entry(RiFileLine, RawFileLine),
|
||||||
'file-text': entry(RiFileTextLine, RawFileTextLine),
|
'file-text': entry(RiFileTextLine, RawFileTextLine),
|
||||||
|
'file-edit': entry(RiFileEditLine, RawFileEditLine),
|
||||||
'file-plus': entry(RiFileAddLine, RawFileAddLine),
|
'file-plus': entry(RiFileAddLine, RawFileAddLine),
|
||||||
'file-off': entry(RiFileLine, RawFileLine),
|
'file-off': entry(RiFileLine, RawFileLine),
|
||||||
'image-off': entry(RiImageLine, RawImageLine),
|
'image-off': entry(RiImageLine, RawImageLine),
|
||||||
|
|
@ -276,11 +303,13 @@ export const ICONS: Record<IconName, IconEntry> = {
|
||||||
'alert-triangle': entry(RiAlertLine, RawAlertLine),
|
'alert-triangle': entry(RiAlertLine, RawAlertLine),
|
||||||
clock: entry(RiTimeLine, RawTimeLine),
|
clock: entry(RiTimeLine, RawTimeLine),
|
||||||
sparkles: entry(RiSparklingLine, RawSparklingLine),
|
sparkles: entry(RiSparklingLine, RawSparklingLine),
|
||||||
|
target: entry(RiTargetLine, RawTargetLine),
|
||||||
|
pause: entry(RiPauseFill, RawPauseFill),
|
||||||
play: entry(RiPlayFill, RawPlayFill),
|
play: entry(RiPlayFill, RawPlayFill),
|
||||||
stop: entry(RiStopFill, RawStopFill),
|
stop: entry(RiStopFill, RawStopFill),
|
||||||
star: entry(RiStarFill, RawStarFill),
|
star: entry(RiStarFill, RawStarFill),
|
||||||
'star-outline': entry(RiStarLine, RawStarLine),
|
'star-outline': entry(RiStarLine, RawStarLine),
|
||||||
'dots-horizontal': entry(RiMoreLine, RawMoreLine),
|
'dots-horizontal': entry(KimiMore, RawKimiMore),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getIcon(name: IconName): IconEntry {
|
export function getIcon(name: IconName): IconEntry {
|
||||||
|
|
@ -353,6 +382,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
|
||||||
'folder-solid',
|
'folder-solid',
|
||||||
'file',
|
'file',
|
||||||
'file-text',
|
'file-text',
|
||||||
|
'file-edit',
|
||||||
'file-plus',
|
'file-plus',
|
||||||
'file-off',
|
'file-off',
|
||||||
'image-off',
|
'image-off',
|
||||||
|
|
@ -365,6 +395,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
|
||||||
'check-list',
|
'check-list',
|
||||||
'bolt',
|
'bolt',
|
||||||
'git-pull-request',
|
'git-pull-request',
|
||||||
|
'target',
|
||||||
'calendar-schedule',
|
'calendar-schedule',
|
||||||
'calendar-todo',
|
'calendar-todo',
|
||||||
'calendar-close',
|
'calendar-close',
|
||||||
|
|
@ -379,6 +410,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
|
||||||
'alert-triangle',
|
'alert-triangle',
|
||||||
'clock',
|
'clock',
|
||||||
'sparkles',
|
'sparkles',
|
||||||
|
'pause',
|
||||||
'play',
|
'play',
|
||||||
'stop',
|
'stop',
|
||||||
'star',
|
'star',
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ export const STORAGE_KEYS = {
|
||||||
colorScheme: 'kimi-web.color-scheme',
|
colorScheme: 'kimi-web.color-scheme',
|
||||||
hiddenWorkspaces: 'kimi-web.hidden-workspaces',
|
hiddenWorkspaces: 'kimi-web.hidden-workspaces',
|
||||||
collapsedWorkspaces: 'kimi-web.collapsed-workspaces',
|
collapsedWorkspaces: 'kimi-web.collapsed-workspaces',
|
||||||
showWorkspacePaths: 'kimi-web.show-workspace-paths',
|
|
||||||
workspaceOrder: 'kimi-web.workspace-order',
|
workspaceOrder: 'kimi-web.workspace-order',
|
||||||
workspaceNameOverrides: 'kimi-web.workspace-name-overrides',
|
workspaceNameOverrides: 'kimi-web.workspace-name-overrides',
|
||||||
workspaceSort: 'kimi-web.workspace-sort',
|
workspaceSort: 'kimi-web.workspace-sort',
|
||||||
|
|
@ -32,6 +31,7 @@ export const STORAGE_KEYS = {
|
||||||
conversationToc: 'kimi-web.beta-toc',
|
conversationToc: 'kimi-web.beta-toc',
|
||||||
notifyOnComplete: 'kimi-web.notify-on-complete',
|
notifyOnComplete: 'kimi-web.notify-on-complete',
|
||||||
notifyOnQuestion: 'kimi-web.notify-on-question',
|
notifyOnQuestion: 'kimi-web.notify-on-question',
|
||||||
|
notifyOnApproval: 'kimi-web.notify-on-approval',
|
||||||
soundOnComplete: 'kimi-web.sound-on-complete',
|
soundOnComplete: 'kimi-web.sound-on-complete',
|
||||||
inputHistory: 'kimi-web.input-history',
|
inputHistory: 'kimi-web.input-history',
|
||||||
// cross-file
|
// cross-file
|
||||||
|
|
@ -148,20 +148,6 @@ export function saveCollapsedWorkspaces(ids: Iterable<string>): void {
|
||||||
safeSetJson(STORAGE_KEYS.collapsedWorkspaces, Array.from(ids));
|
safeSetJson(STORAGE_KEYS.collapsedWorkspaces, Array.from(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the sidebar shows each workspace's root path under its name. Off by
|
|
||||||
* default to keep the list compact; toggled from the Workspaces section header.
|
|
||||||
* Stored as a JSON boolean; any missing/unset value reads as false. UI-only
|
|
||||||
* state with no server-side source of truth.
|
|
||||||
*/
|
|
||||||
export function loadShowWorkspacePaths(): boolean {
|
|
||||||
return safeGetJson<boolean>(STORAGE_KEYS.showWorkspacePaths) === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveShowWorkspacePaths(show: boolean): void {
|
|
||||||
safeSetJson(STORAGE_KEYS.showWorkspacePaths, show);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display order of workspace ids in the sidebar. Persisted as a JSON array so
|
* 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
|
* the user can drag workspaces into a custom order that survives a page
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ const TOOL_LABEL_KEYS: Record<string, string> = {
|
||||||
task: 'tools.label.task',
|
task: 'tools.label.task',
|
||||||
agentswarm: 'tools.label.swarm',
|
agentswarm: 'tools.label.swarm',
|
||||||
askuserquestion: 'tools.label.ask_user',
|
askuserquestion: 'tools.label.ask_user',
|
||||||
|
creategoal: 'tools.label.goal_create',
|
||||||
|
getgoal: 'tools.label.goal_get',
|
||||||
|
setgoalbudget: 'tools.label.goal_budget',
|
||||||
|
updategoal: 'tools.label.goal_update',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -60,6 +64,10 @@ const NAME_ALIASES: Record<string, string> = {
|
||||||
subagent: 'task',
|
subagent: 'task',
|
||||||
websearch: 'search',
|
websearch: 'search',
|
||||||
web_search: 'search',
|
web_search: 'search',
|
||||||
|
create_goal: 'creategoal',
|
||||||
|
get_goal: 'getgoal',
|
||||||
|
set_goal_budget: 'setgoalbudget',
|
||||||
|
update_goal: 'updategoal',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeToolName(name: string): string {
|
export function normalizeToolName(name: string): string {
|
||||||
|
|
@ -93,6 +101,10 @@ const TOOL_GLYPH: Record<string, IconName> = {
|
||||||
task: 'sparkles',
|
task: 'sparkles',
|
||||||
agentswarm: 'git-pull-request',
|
agentswarm: 'git-pull-request',
|
||||||
askuserquestion: 'help-circle',
|
askuserquestion: 'help-circle',
|
||||||
|
creategoal: 'target',
|
||||||
|
getgoal: 'target',
|
||||||
|
setgoalbudget: 'target',
|
||||||
|
updategoal: 'target',
|
||||||
// Cron scheduling tools share a calendar motif: schedule / list / cancel.
|
// Cron scheduling tools share a calendar motif: schedule / list / cancel.
|
||||||
croncreate: 'calendar-schedule',
|
croncreate: 'calendar-schedule',
|
||||||
cronlist: 'calendar-todo',
|
cronlist: 'calendar-todo',
|
||||||
|
|
@ -182,6 +194,41 @@ function filePath(d: Record<string, unknown>): string | undefined {
|
||||||
return str(d.path) ?? str(d.file_path) ?? str(d.filePath) ?? str(d.filename);
|
return str(d.path) ?? str(d.file_path) ?? str(d.filePath) ?? str(d.filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GOAL_STATUS_KEYS: Record<string, string> = {
|
||||||
|
active: 'status.goalStatusActive',
|
||||||
|
blocked: 'status.goalStatusBlocked',
|
||||||
|
complete: 'status.goalStatusComplete',
|
||||||
|
};
|
||||||
|
|
||||||
|
function goalStatusLabel(value: unknown): string | undefined {
|
||||||
|
const status = str(value);
|
||||||
|
if (!status) return undefined;
|
||||||
|
const key = GOAL_STATUS_KEYS[status];
|
||||||
|
return key ? t(key) : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goalBudgetSummary(d: Record<string, unknown>): string | undefined {
|
||||||
|
const value = num(d.value);
|
||||||
|
const unit = str(d.unit);
|
||||||
|
if (value === undefined || !unit) return undefined;
|
||||||
|
switch (unit) {
|
||||||
|
case 'turns':
|
||||||
|
return t('tools.goal.turns', { value });
|
||||||
|
case 'tokens':
|
||||||
|
return t('tools.goal.tokens', { value });
|
||||||
|
case 'milliseconds':
|
||||||
|
return t('tools.goal.milliseconds', { value });
|
||||||
|
case 'seconds':
|
||||||
|
return t('tools.goal.seconds', { value });
|
||||||
|
case 'minutes':
|
||||||
|
return t('tools.goal.minutes', { value });
|
||||||
|
case 'hours':
|
||||||
|
return t('tools.goal.hours', { value });
|
||||||
|
default:
|
||||||
|
return t('tools.goal.budget', { value, unit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const BASH_MAX = 64;
|
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 }));
|
if (items) return c(t('tools.chip.todos', { count: items.length }));
|
||||||
return fallback();
|
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:
|
default:
|
||||||
return fallback();
|
return fallback();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
import { installClientErrorCapture } from './debug/trace';
|
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 '@fontsource-variable/jetbrains-mono/wght.css';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -204,11 +204,9 @@ summary {
|
||||||
--ui-font-size-lg: calc(var(--ui-font-size) + 1px);
|
--ui-font-size-lg: calc(var(--ui-font-size) + 1px);
|
||||||
--ui-font-size-xl: calc(var(--ui-font-size) + 2px);
|
--ui-font-size-xl: calc(var(--ui-font-size) + 2px);
|
||||||
--content-font-size: calc(var(--base-ui-font-size) + 1px);
|
--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;
|
--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
|
/* Body/UI font follows the design-system canonical token. Mirrors --font-ui
|
||||||
first; Inter intentionally NOT in the body chain — it stays reserved for
|
so the legacy alias can never drift from the current text face. */
|
||||||
--font-display headings). Mirrors --font-ui so the two can never drift. */
|
|
||||||
--sans: var(--font-ui);
|
--sans: var(--font-ui);
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
@ -388,6 +386,15 @@ html[data-color-scheme="dark"][data-accent="mono"] {
|
||||||
--color-text-on-accent: #ffffff;
|
--color-text-on-accent: #ffffff;
|
||||||
--color-line: #e7eaee;
|
--color-line: #e7eaee;
|
||||||
--color-line-strong: #d4d9e0;
|
--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: var(--accent-primary);
|
||||||
--color-accent-hover: #0f6fe0;
|
--color-accent-hover: #0f6fe0;
|
||||||
--color-accent-soft: #e8f3ff;
|
--color-accent-soft: #e8f3ff;
|
||||||
|
|
@ -454,14 +461,15 @@ html[data-color-scheme="dark"][data-accent="mono"] {
|
||||||
--duration-slow: 260ms;
|
--duration-slow: 260ms;
|
||||||
|
|
||||||
/* -- type families ------------------------------------------------------- */
|
/* -- type families ------------------------------------------------------- */
|
||||||
/* UI/body use the platform's native UI font first (design-system §02);
|
/* UI/body use self-hosted Inter first. CJK and platform system UI families
|
||||||
Inter is intentionally NOT in this chain — it is reserved for
|
stay late in the fallback chain so Latin glyphs resolve to Inter while
|
||||||
--font-display (optional headings / brand wordmarks) below. */
|
Chinese text can still fall through to native CJK fonts. */
|
||||||
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
--font-ui: "Inter Variable", "Inter", "Helvetica Neue", Arial,
|
||||||
"Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans SC", "Noto Sans SC",
|
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans SC",
|
||||||
Roboto, Ubuntu, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
|
"Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
Ubuntu, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||||
--font-display: "Inter Variable", "Inter", var(--font-ui);
|
"Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--font-display: var(--font-ui);
|
||||||
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace,
|
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace,
|
||||||
"SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
"SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
|
||||||
|
|
@ -511,6 +519,9 @@ html[data-color-scheme="dark"] {
|
||||||
--color-text-faint: #6b7280;
|
--color-text-faint: #6b7280;
|
||||||
--color-line: #2d333b;
|
--color-line: #2d333b;
|
||||||
--color-line-strong: #3d444d;
|
--color-line-strong: #3d444d;
|
||||||
|
--color-selected: #ffffff14;
|
||||||
|
--color-hover: #ffffff0d;
|
||||||
|
--color-sidebar-bg: #181817;
|
||||||
--color-accent: #58a6ff;
|
--color-accent: #58a6ff;
|
||||||
--color-accent-hover: #79b8ff;
|
--color-accent-hover: #79b8ff;
|
||||||
--color-accent-soft: rgba(88, 166, 255, 0.14);
|
--color-accent-soft: rgba(88, 166, 255, 0.14);
|
||||||
|
|
@ -550,6 +561,9 @@ html[data-color-scheme="dark"] {
|
||||||
--color-text-faint: #6b7280;
|
--color-text-faint: #6b7280;
|
||||||
--color-line: #2d333b;
|
--color-line: #2d333b;
|
||||||
--color-line-strong: #3d444d;
|
--color-line-strong: #3d444d;
|
||||||
|
--color-selected: #ffffff14;
|
||||||
|
--color-hover: #ffffff0d;
|
||||||
|
--color-sidebar-bg: #181817;
|
||||||
--color-accent: #58a6ff;
|
--color-accent: #58a6ff;
|
||||||
--color-accent-hover: #79b8ff;
|
--color-accent-hover: #79b8ff;
|
||||||
--color-accent-soft: rgba(88, 166, 255, 0.14);
|
--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.
|
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).
|
Components may still override locally (e.g. the sidebar's even-thinner rule).
|
||||||
--------------------------------------------------------------------------- */
|
--------------------------------------------------------------------------- */
|
||||||
* {
|
/* Firefox-only fallback: the standard scrollbar properties DISABLE the whole
|
||||||
scrollbar-width: thin;
|
::-webkit-scrollbar customisation in Chromium 121+ (any non-auto value wins
|
||||||
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
|
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 {
|
*::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|
@ -643,6 +664,7 @@ body {
|
||||||
font-size: var(--ui-font-size);
|
font-size: var(--ui-font-size);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
font-optical-sizing: auto;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: auto;
|
text-rendering: auto;
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,7 @@ onUnmounted(() => {
|
||||||
<div class="color-card"><div class="color-chip" style="background:#ffffff"></div><div class="color-meta"><div class="cn">bg</div><div class="cv">#ffffff / #0d1117</div></div></div>
|
<div class="color-card"><div class="color-chip" style="background:#ffffff"></div><div class="color-meta"><div class="cn">bg</div><div class="cv">#ffffff / #0d1117</div></div></div>
|
||||||
<div class="color-card"><div class="color-chip" style="background:#fafbfc"></div><div class="color-meta"><div class="cn">surface</div><div class="cv">#fafbfc / #161b22</div></div></div>
|
<div class="color-card"><div class="color-chip" style="background:#fafbfc"></div><div class="color-meta"><div class="cn">surface</div><div class="cv">#fafbfc / #161b22</div></div></div>
|
||||||
<div class="color-card"><div class="color-chip" style="background:#f3f5f8"></div><div class="color-meta"><div class="cn">surface-sunken</div><div class="cv">#f3f5f8 / #0d1117</div></div></div>
|
<div class="color-card"><div class="color-chip" style="background:#f3f5f8"></div><div class="color-meta"><div class="cn">surface-sunken</div><div class="cv">#f3f5f8 / #0d1117</div></div></div>
|
||||||
|
<div class="color-card"><div class="color-chip" style="background:#eceff3"></div><div class="color-meta"><div class="cn">selected</div><div class="cv">#eceff3 / #2d333b</div></div></div>
|
||||||
<div class="color-card"><div class="color-chip" style="background:#14171c"></div><div class="color-meta"><div class="cn">fg</div><div class="cv">#14171c / #e8eaed</div></div></div>
|
<div class="color-card"><div class="color-chip" style="background:#14171c"></div><div class="color-meta"><div class="cn">fg</div><div class="cv">#14171c / #e8eaed</div></div></div>
|
||||||
<div class="color-card"><div class="color-chip" style="background:#6b7280"></div><div class="color-meta"><div class="cn">fg-muted</div><div class="cv">#6b7280 / #9aa0a8</div></div></div>
|
<div class="color-card"><div class="color-chip" style="background:#6b7280"></div><div class="color-meta"><div class="cn">fg-muted</div><div class="cv">#6b7280 / #9aa0a8</div></div></div>
|
||||||
<div class="color-card"><div class="color-chip" style="background:#e7eaee"></div><div class="color-meta"><div class="cn">line</div><div class="cv">#e7eaee / #2d333b</div></div></div>
|
<div class="color-card"><div class="color-chip" style="background:#e7eaee"></div><div class="color-meta"><div class="cn">line</div><div class="cv">#e7eaee / #2d333b</div></div></div>
|
||||||
|
|
@ -191,6 +192,9 @@ onUnmounted(() => {
|
||||||
<tr><td class="tk">--color-text</td><td class="val"><span class="swatch" style="background:#14171c"></span>#14171c</td><td class="val"><span class="swatch" style="background:#e8eaed"></span>#e8eaed</td><td>Body text / headings</td></tr>
|
<tr><td class="tk">--color-text</td><td class="val"><span class="swatch" style="background:#14171c"></span>#14171c</td><td class="val"><span class="swatch" style="background:#e8eaed"></span>#e8eaed</td><td>Body text / headings</td></tr>
|
||||||
<tr><td class="tk">--color-text-muted</td><td class="val"><span class="swatch" style="background:#6b7280"></span>#6b7280</td><td class="val"><span class="swatch" style="background:#9aa0a8"></span>#9aa0a8</td><td>Secondary text / placeholder</td></tr>
|
<tr><td class="tk">--color-text-muted</td><td class="val"><span class="swatch" style="background:#6b7280"></span>#6b7280</td><td class="val"><span class="swatch" style="background:#9aa0a8"></span>#9aa0a8</td><td>Secondary text / placeholder</td></tr>
|
||||||
<tr><td class="tk">--color-line</td><td class="val"><span class="swatch" style="background:#e7eaee"></span>#e7eaee</td><td class="val"><span class="swatch" style="background:#2d333b"></span>#2d333b</td><td>Divider / card border</td></tr>
|
<tr><td class="tk">--color-line</td><td class="val"><span class="swatch" style="background:#e7eaee"></span>#e7eaee</td><td class="val"><span class="swatch" style="background:#2d333b"></span>#2d333b</td><td>Divider / card border</td></tr>
|
||||||
|
<tr><td class="tk">--color-selected</td><td class="val"><span class="swatch" style="background:#00000014"></span>#00000014</td><td class="val"><span class="swatch" style="background:#ffffff14"></span>#ffffff14</td><td>Neutral selected fill (sidebar rows, list pickers) — translucent, never accent-tinted</td></tr>
|
||||||
|
<tr><td class="tk">--color-hover</td><td class="val"><span class="swatch" style="background:#0000000d"></span>#0000000d</td><td class="val"><span class="swatch" style="background:#ffffff0d"></span>#ffffff0d</td><td>Row hover wash — lighter than the selected fill (hover < selected); translucent, sits on any surface</td></tr>
|
||||||
|
<tr><td class="tk">--color-sidebar-bg</td><td class="val"><span class="swatch" style="background:#fbfaf9"></span>#fbfaf9</td><td class="val"><span class="swatch" style="background:#181817"></span>#181817</td><td>Sidebar surface — one step off <code>--color-bg</code> so the session column reads as its own plane</td></tr>
|
||||||
<tr><td class="tk">--color-accent</td><td class="val"><span class="swatch" style="background:#1783ff"></span>#1783ff</td><td class="val"><span class="swatch" style="background:#58a6ff"></span>#58a6ff</td><td>Primary action / link / focus</td></tr>
|
<tr><td class="tk">--color-accent</td><td class="val"><span class="swatch" style="background:#1783ff"></span>#1783ff</td><td class="val"><span class="swatch" style="background:#58a6ff"></span>#58a6ff</td><td>Primary action / link / focus</td></tr>
|
||||||
<tr><td class="tk">--color-success</td><td class="val"><span class="swatch" style="background:#0e7a38"></span>#0e7a38</td><td class="val"><span class="swatch" style="background:#3fb950"></span>#3fb950</td><td>Success / pass</td></tr>
|
<tr><td class="tk">--color-success</td><td class="val"><span class="swatch" style="background:#0e7a38"></span>#0e7a38</td><td class="val"><span class="swatch" style="background:#3fb950"></span>#3fb950</td><td>Success / pass</td></tr>
|
||||||
<tr><td class="tk">--color-warning</td><td class="val"><span class="swatch" style="background:#a9610a"></span>#a9610a</td><td class="val"><span class="swatch" style="background:#d29922"></span>#d29922</td><td>Warning / pending</td></tr>
|
<tr><td class="tk">--color-warning</td><td class="val"><span class="swatch" style="background:#a9610a"></span>#a9610a</td><td class="val"><span class="swatch" style="background:#d29922"></span>#d29922</td><td>Warning / pending</td></tr>
|
||||||
|
|
@ -227,18 +231,19 @@ onUnmounted(() => {
|
||||||
<p>All disabled controls use <code>opacity:.5</code> + <code>cursor:not-allowed</code> uniformly; do not separately grey out or recolor.</p>
|
<p>All disabled controls use <code>opacity:.5</code> + <code>cursor:not-allowed</code> uniformly; do not separately grey out or recolor.</p>
|
||||||
|
|
||||||
<h3 class="sub">Font families</h3>
|
<h3 class="sub">Font families</h3>
|
||||||
<p>Kimi Web uses two font families: <b>--font-ui</b> (UI and body, system fonts first) and <b>--font-mono</b> (code and monospace). Components always reference the variables; do not hard-code font names.</p>
|
<p>Kimi Web uses two font families: <b>--font-ui</b> (UI and body, Inter first) and <b>--font-mono</b> (code and monospace). Components always reference the variables; do not hard-code font names.</p>
|
||||||
|
|
||||||
<h4 class="mini">--font-ui · UI & body (system fonts first)</h4>
|
<h4 class="mini">--font-ui · UI & body (Inter first)</h4>
|
||||||
<p>Body and UI use each platform's native UI font — close to the system feel, comfortable for long text and CJK. Fallback chain:</p>
|
<p>Body and UI use self-hosted Inter as the primary face. CJK and platform system UI fonts sit late in the fallback chain so Latin glyphs resolve to Inter while Chinese text can fall through to native CJK fonts:</p>
|
||||||
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">--font-ui</span></div><pre>--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">--font-ui</span></div><pre>--font-ui: "Inter Variable", "Inter", "Helvetica Neue", Arial,
|
||||||
"PingFang SC", "Microsoft YaHei", "Noto Sans SC",
|
"PingFang SC", "Microsoft YaHei", "Noto Sans SC",
|
||||||
"Helvetica Neue", Arial, sans-serif,
|
-apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, Ubuntu, sans-serif,
|
||||||
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";</pre></div>
|
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";</pre></div>
|
||||||
<ul class="clean">
|
<ul class="clean">
|
||||||
<li>System UI fonts first: SF Pro on macOS / iOS, Segoe UI on Windows.</li>
|
<li>Inter first: self-hosted Latin UI and body text, loaded through the optical-size normal and italic variable faces.</li>
|
||||||
<li>CJK next: PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans SC (Linux).</li>
|
<li>Western fallbacks next: Helvetica Neue / Arial for environments where Inter cannot load.</li>
|
||||||
<li>Helvetica Neue / Arial / sans-serif as generic fallbacks; emoji fonts at the end.</li>
|
<li>CJK and system UI fallbacks late: PingFang SC / Microsoft YaHei / Noto Sans SC, then platform UI fonts and emoji fonts.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h4 class="mini">--font-mono · Code & monospace</h4>
|
<h4 class="mini">--font-mono · Code & monospace</h4>
|
||||||
|
|
@ -251,8 +256,8 @@ onUnmounted(() => {
|
||||||
<thead><tr><th>Font</th><th>Source</th><th>Bundled</th><th>Usage</th></tr></thead>
|
<thead><tr><th>Font</th><th>Source</th><th>Bundled</th><th>Usage</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td class="tk">JetBrains Mono</td><td class="val">@fontsource-variable/jetbrains-mono</td><td class="val">✓ self-hosted</td><td>monospace / code (--font-mono)</td></tr>
|
<tr><td class="tk">JetBrains Mono</td><td class="val">@fontsource-variable/jetbrains-mono</td><td class="val">✓ self-hosted</td><td>monospace / code (--font-mono)</td></tr>
|
||||||
<tr><td class="tk">Inter</td><td class="val">@fontsource-variable/inter</td><td class="val">✓ self-hosted</td><td>optional: page titles / brand wordmark (--font-display)</td></tr>
|
<tr><td class="tk">Inter</td><td class="val">@fontsource-variable/inter/opsz.css + opsz-italic.css</td><td class="val">✓ self-hosted</td><td>UI / body / display (--font-ui, --font-display), wght 100-900, opsz 14-32, normal + italic</td></tr>
|
||||||
<tr><td class="tk">System UI / CJK fonts</td><td class="val">operating system</td><td class="val">—</td><td>body / UI (--font-ui), not bundled</td></tr>
|
<tr><td class="tk">System UI / CJK fonts</td><td class="val">operating system</td><td class="val">—</td><td>late fallback for UI / body, not bundled</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="callout good"><span class="ico">✓</span><div>
|
<div class="callout good"><span class="ico">✓</span><div>
|
||||||
|
|
@ -262,12 +267,13 @@ onUnmounted(() => {
|
||||||
<h4 class="mini">Usage rules</h4>
|
<h4 class="mini">Usage rules</h4>
|
||||||
<ul class="clean check">
|
<ul class="clean check">
|
||||||
<li>Components always use <code>var(--font-ui)</code> / <code>var(--font-mono)</code>; do not hard-code font names like <code>'Inter'</code> / <code>'JetBrains Mono'</code>.</li>
|
<li>Components always use <code>var(--font-ui)</code> / <code>var(--font-mono)</code>; do not hard-code font names like <code>'Inter'</code> / <code>'JetBrains Mono'</code>.</li>
|
||||||
<li>Body / UI use <code>--font-ui</code> (system fonts first); code / monospace use <code>--font-mono</code> (JetBrains Mono).</li>
|
<li>Body / UI use <code>--font-ui</code> (Inter first); code / monospace use <code>--font-mono</code> (JetBrains Mono).</li>
|
||||||
<li>Inter is used only for headings / brand scenarios that need a unified look (optional <code>--font-display</code>); it is no longer the body default.</li>
|
<li>Inter is loaded from the complete optical-size variable faces, including normal and italic styles; <code>font-optical-sizing: auto</code> is enabled globally.</li>
|
||||||
|
<li>CJK and platform system UI fonts stay late in the <code>--font-ui</code> fallback chain, after Inter and Western fallbacks.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 class="sub">Type scale & weight</h3>
|
<h3 class="sub">Type scale & weight</h3>
|
||||||
<p>The user font-size preference writes <code>--base-ui-font-size</code>. Compact UI chrome follows it through <code>--ui-font-size</code>, while chat reading surfaces and the sidebar derive one readable step above it through <code>--content-font-size</code> and <code>--sidebar-ui-font-size</code>.</p>
|
<p>The user font-size preference writes <code>--base-ui-font-size</code>. Compact UI chrome and the sidebar follow it through <code>--ui-font-size</code>, while chat reading surfaces derive one readable step above it through <code>--content-font-size</code>.</p>
|
||||||
<p>The fixed product type tokens still define component defaults: <b>UI controls / buttons / forms</b> use <code>--text-base</code> (14px); <b>reading body — including chat Markdown, message bubbles, etc.</b> stays one step larger than compact chrome for readability; the <b>sidebar session list</b> follows that same readable step while keeping list density.
|
<p>The fixed product type tokens still define component defaults: <b>UI controls / buttons / forms</b> use <code>--text-base</code> (14px); <b>reading body — including chat Markdown, message bubbles, etc.</b> stays one step larger than compact chrome for readability; the <b>sidebar session list</b> follows that same readable step while keeping list density.
|
||||||
Drop stray <code>font-weight: 650 / 750</code>; converge on two weights, 400 / 500 (regular / emphasis).</p>
|
Drop stray <code>font-weight: 650 / 750</code>; converge on two weights, 400 / 500 (regular / emphasis).</p>
|
||||||
<div class="panel panel-pad" style="margin:16px 0">
|
<div class="panel panel-pad" style="margin:16px 0">
|
||||||
|
|
@ -281,11 +287,10 @@ onUnmounted(() => {
|
||||||
<table class="dt">
|
<table class="dt">
|
||||||
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
|
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td class="tk">--font-ui</td><td class="val">-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC"…</td><td>UI & body (system fonts first)</td></tr>
|
<tr><td class="tk">--font-ui</td><td class="val">"Inter Variable", "Inter", "Helvetica Neue", Arial…</td><td>UI & body (Inter first)</td></tr>
|
||||||
<tr><td class="tk">--font-mono</td><td class="val">JetBrains Mono…</td><td>code, tool names, line numbers, diffs</td></tr>
|
<tr><td class="tk">--font-mono</td><td class="val">JetBrains Mono…</td><td>code, tool names, line numbers, diffs</td></tr>
|
||||||
<tr><td class="tk">--base-ui-font-size</td><td class="val">14px user preference</td><td>root setting that drives UI, reading body, and sidebar font sizes</td></tr>
|
<tr><td class="tk">--base-ui-font-size</td><td class="val">14px user preference</td><td>root setting that drives UI, reading body, and sidebar font sizes</td></tr>
|
||||||
<tr><td class="tk">--content-font-size</td><td class="val">calc(base + 1px)</td><td>chat Markdown, message bubbles, composer</td></tr>
|
<tr><td class="tk">--content-font-size</td><td class="val">calc(base + 1px)</td><td>chat Markdown, message bubbles, composer</td></tr>
|
||||||
<tr><td class="tk">--sidebar-ui-font-size</td><td class="val">calc(base + 1px)</td><td>sidebar brand, search, workspace and session rows</td></tr>
|
|
||||||
<tr><td class="tk">--leading-tight/normal/relaxed</td><td class="val">1.25 / 1.5 / 1.7</td><td>headings / UI / long text</td></tr>
|
<tr><td class="tk">--leading-tight/normal/relaxed</td><td class="val">1.25 / 1.5 / 1.7</td><td>headings / UI / long text</td></tr>
|
||||||
<tr><td class="tk">--weight-regular/medium</td><td class="val">400 / 500</td><td>body / emphasis</td></tr>
|
<tr><td class="tk">--weight-regular/medium</td><td class="val">400 / 500</td><td>body / emphasis</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -565,6 +570,18 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Kbd ===== -->
|
||||||
|
<h3 class="sub">Kbd · keyboard shortcut</h3>
|
||||||
|
<p><b>Kbd</b> renders a shortcut as keycaps — one block per key, never inline text like <code>(⌘K)</code>. Caps are 18px tall (Badge sm rhythm): sunken surface, 1px border with a 2px bottom edge, 11px UI font, muted text. Typical placement: pushed to the row's trailing edge, opposite the label (e.g. the sidebar search row).</p>
|
||||||
|
<div class="stage-wrap">
|
||||||
|
<div class="stage-bar"><span class="st">Kbd · keycaps</span></div>
|
||||||
|
<div class="stage p">
|
||||||
|
<span class="p-kbd"><kbd>⌘</kbd><kbd>K</kbd></span>
|
||||||
|
<span class="p-kbd"><kbd>Ctrl</kbd><kbd>K</kbd></span>
|
||||||
|
<span class="p-kbd"><kbd>⌘</kbd><kbd>⇧</kbd><kbd>P</kbd></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===== Card / Surface ===== -->
|
<!-- ===== Card / Surface ===== -->
|
||||||
<h3 class="sub">Card / Surface</h3>
|
<h3 class="sub">Card / Surface</h3>
|
||||||
<p>All cards across the site share <b>one shell</b>: flat, <code>1px</code> border, <code>--radius-md</code> radius, <b>no shadow</b>. The structure is split into three parts — <code>head / body / foot</code>. Cards differ <b>only in the head</b> — in two tiers by visual weight, while the shell stays consistent:</p>
|
<p>All cards across the site share <b>one shell</b>: flat, <code>1px</code> border, <code>--radius-md</code> radius, <b>no shadow</b>. The structure is split into three parts — <code>head / body / foot</code>. Cards differ <b>only in the head</b> — in two tiers by visual weight, while the shell stays consistent:</p>
|
||||||
|
|
@ -1336,13 +1353,13 @@ onUnmounted(() => {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 class="sub">Layout grid</h3>
|
<h3 class="sub">Layout grid</h3>
|
||||||
<p>On desktop it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles.</p>
|
<p>On desktop it is a single-row 5-track grid: the sidebar and the right panel each occupy a permanent <code>auto</code> track, with the conversation column in the middle; two 0-width tracks are for the ResizeHandles.</p>
|
||||||
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">App.vue · .app</span></div><pre>grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
|
<div class="code"><div class="code-bar"><span class="d"></span><span class="d"></span><span class="d"></span><span class="fn">App.vue · .app</span></div><pre>grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
|
||||||
/* sidebar ↑ ↑handle ↑conversation ↑handle ↑right panel (auto) */</pre></div>
|
/* sidebar ↑ ↑handle ↑conversation ↑handle ↑right panel (auto) */</pre></div>
|
||||||
<table class="dt">
|
<table class="dt">
|
||||||
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
|
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td class="tk">--side-w</td><td class="val">248px (adjustable)</td><td>left conversation column width, changed by dragging the ResizeHandle; should approach §02's <code>--p-sidebar-w</code> (264px)</td></tr>
|
<tr><td class="tk">sidebar width</td><td class="val">270px default (adjustable)</td><td>expanded sidebar width, changed by dragging the ResizeHandle; should approach §02's <code>--p-sidebar-w</code> (264px)</td></tr>
|
||||||
<tr><td class="tk">--preview-w</td><td class="val">460px</td><td>width of the right preview panel when open</td></tr>
|
<tr><td class="tk">--preview-w</td><td class="val">460px</td><td>width of the right preview panel when open</td></tr>
|
||||||
<tr><td class="tk">--panel-head-h</td><td class="val">48px</td><td>unified height for all right panel heads + the conversation column head, so the hairline runs as one line</td></tr>
|
<tr><td class="tk">--panel-head-h</td><td class="val">48px</td><td>unified height for all right panel heads + the conversation column head, so the hairline runs as one line</td></tr>
|
||||||
<tr><td class="tk">--p-bp-sm</td><td class="val">640px</td><td>≤640 switches to a mobile single column (top bar + conversation), no sidebar / handle / right panel</td></tr>
|
<tr><td class="tk">--p-bp-sm</td><td class="val">640px</td><td>≤640 switches to a mobile single column (top bar + conversation), no sidebar / handle / right panel</td></tr>
|
||||||
|
|
@ -1350,17 +1367,18 @@ onUnmounted(() => {
|
||||||
</table>
|
</table>
|
||||||
<ul class="clean">
|
<ul class="clean">
|
||||||
<li>The right panel track exists permanently, with its width transitioning between <code>0 ↔ var(--preview-w)</code> (when open it squeezes the conversation column, rather than switching templates).</li>
|
<li>The right panel track exists permanently, with its width transitioning between <code>0 ↔ var(--preview-w)</code> (when open it squeezes the conversation column, rather than switching templates).</li>
|
||||||
<li>When the sidebar is collapsed, track 1 becomes a thin "rail" holding only an expand IconButton, avoiding crushing the conversation column head.</li>
|
<li>The sidebar collapses SYMMETRICALLY to the right panel: its container width animates to 0 while the content keeps its fixed width anchored to the right edge (clipped, sliding out left — no reflow, hairline stays on the clipped content). No rail remains. The collapse control differs by platform: on <b>macOS desktop</b> the toggle is a single resident floating IconButton pinned beside the traffic lights (rendered in both states, only the glyph swaps — the sidebar slides underneath it, never moves or flashes); on <b>Windows / web</b> the collapse button lives inside the sidebar header (right-aligned), and a floating expand button appears at the top-left only while collapsed. The conversation header pads left in step with the transition while collapsed.</li>
|
||||||
<li>All grid children must have <code>min-height:0; min-width:0</code>, so only the inner scroll containers scroll and the page itself does not scroll.</li>
|
<li>All grid children must have <code>min-height:0; min-width:0</code>, so only the inner scroll containers scroll and the page itself does not scroll.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 class="sub">Sidebar alignment system (<code>--sb-*</code>)</h3>
|
<h3 class="sub">Sidebar alignment system (<code>--sb-*</code>)</h3>
|
||||||
<p>All sidebar rows (group head, session row, New chat button) share 3 custom properties, so the "session title" aligns precisely under the "workspace name".</p>
|
<p>All sidebar rows (group head, session row, New chat button) share 4 custom properties, so the "session title" aligns precisely under the "workspace name".</p>
|
||||||
<table class="dt">
|
<table class="dt">
|
||||||
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
|
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td class="tk">--sb-pad-x</td><td class="val">16px</td><td>row horizontal padding</td></tr>
|
<tr><td class="tk">--sb-inset</td><td class="val">12px</td><td>row box (hover/selected pill) inset from the sidebar edges — matches the brand header's 12px padding</td></tr>
|
||||||
<tr><td class="tk">--sb-gutter</td><td class="val">20px</td><td>leading icon slot width (14px icon + 6px whitespace)</td></tr>
|
<tr><td class="tk">--sb-pad-x</td><td class="val">20px</td><td>content start x (= --sb-inset + 8px row padding)</td></tr>
|
||||||
|
<tr><td class="tk">--sb-gutter</td><td class="val">16px</td><td>leading icon slot width — matches the workspace folder icon so the session title aligns under the workspace name</td></tr>
|
||||||
<tr><td class="tk">--sb-gap</td><td class="val">6px</td><td>gap between the icon slot and the text</td></tr>
|
<tr><td class="tk">--sb-gap</td><td class="val">6px</td><td>gap between the icon slot and the text</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -1369,15 +1387,16 @@ onUnmounted(() => {
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
<h3 class="sub">Sidebar structure</h3>
|
<h3 class="sub">Sidebar structure</h3>
|
||||||
<p>The sidebar from top to bottom: brand header → search → New chat → grouped list (workspace head + session rows). Controls reuse the §03 primitives as much as possible.</p>
|
<p>The sidebar from top to bottom: brand header → New chat → search → grouped list (workspace head + session rows) → settings footer. Controls reuse the §03 primitives as much as possible. The sidebar sits on <code>--color-sidebar-bg</code> (one step off <code>--color-bg</code>: warm off-white in light, near-black in dark — the session column reads as its own plane; the hairline still separates it from the conversation pane). Vertical rhythm: the brand header keeps 12px padding (on macOS desktop the left padding grows to 80px to clear the traffic lights); rows inside the actions group (New chat + search) stack flush (0 gap, same rhythm as the list rows); adjacent groups are separated by 12px. Row hover uses <code>--sb-hover</code> (= the global <code>--color-hover</code> wash); the selected row uses <code>--color-selected</code> — neutral, never the accent.</p>
|
||||||
<table class="dt">
|
<table class="dt">
|
||||||
<thead><tr><th>Block</th><th>Use</th><th>Note</th></tr></thead>
|
<thead><tr><th>Block</th><th>Use</th><th>Note</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Brand header</td><td>logo + name + IconButton</td><td>collapse / settings use IconButton sm; the logo is animated (a blinking eye)</td></tr>
|
<tr><td>Brand header</td><td>logo + name + collapse IconButton (right-aligned)</td><td>on Windows / web the brand is left and the collapse IconButton sm is right-aligned inside the header; the logo is animated (a blinking eye). On macOS desktop the header is a bare drag strip (brand hidden, traffic lights + resident floating toggle over it)</td></tr>
|
||||||
<tr><td>Search</td><td>bare search row (custom)</td><td>no border, hover/focus shows a sunken background; icon + input + clear IconButton. <b>Do not</b> use Input (the 38px bordered version is too heavy)</td></tr>
|
<tr><td>New chat</td><td>full-width left-aligned button (custom)</td><td>same rhythm as the session rows in the list (left-aligned, hover = <code>--sb-hover</code>). <b>Do not</b> use Button (centered, breaks the rhythm)</td></tr>
|
||||||
<tr><td>New chat</td><td>full-width left-aligned button (custom)</td><td>same rhythm as the session rows in the list (left-aligned, hover sunken). <b>Do not</b> use Button (centered, breaks the rhythm)</td></tr>
|
<tr><td>Search</td><td>bare search row (custom)</td><td>no border, hover/focus shows a sunken background; icon + label, with the <code>Kbd</code> keycaps (⌘K / Ctrl K) pushed to the trailing edge — label and shortcut are justified apart. <b>Do not</b> use Input (the 38px bordered version is too heavy). Last fixed row above the list — its wrapper carries the scroll-linked seam</td></tr>
|
||||||
<tr><td>Section label</td><td><code>.p-section-label</code></td><td>uppercase muted small titles like "Workspaces"</td></tr>
|
<tr><td>Section label</td><td><code>.p-section-label</code></td><td>uppercase muted small titles like "Workspaces"</td></tr>
|
||||||
<tr><td>Workspace head / session row</td><td>see next two sections</td><td>share <code>--sb-*</code> alignment</td></tr>
|
<tr><td>Workspace head / session row</td><td>see next two sections</td><td>share <code>--sb-*</code> alignment</td></tr>
|
||||||
|
<tr><td>Settings footer</td><td>full-width left-aligned button (custom)</td><td>pinned row under the session list, separated by a 1px <code>--line</code> top border; icon + label, same list-style family as New chat</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="callout warn"><span class="ico">!</span><div>
|
<div class="callout warn"><span class="ico">!</span><div>
|
||||||
|
|
@ -1389,7 +1408,7 @@ onUnmounted(() => {
|
||||||
<table class="dt">
|
<table class="dt">
|
||||||
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
|
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Container</td><td><code>margin: 1px 6px; padding: 7px 10px; radius-md</code>; hover = <code>surface-sunken</code>; active = <code>accent-soft</code> + <code>inset 0 0 0 1px accent-bd</code></td></tr>
|
<tr><td>Container</td><td><code>padding: 8px 8px</code> inside the list's <code>--sb-inset</code> gutter, <code>radius-sm</code>; <b>no fixed/min height</b> — row height is font-driven (title <code>line-height: --leading-tight</code>, ≈16px) → ≈32px total, the sidebar-wide row rhythm. The hover kebab is absolutely positioned so it never forces the row taller (no hover jitter). hover = <code>--sb-hover</code> (the global <code>--color-hover</code> wash); active = <code>--color-selected</code> — neutral, no accent tint, no border, no weight change</td></tr>
|
||||||
<tr><td>Status slot (lead)</td><td>fixed <code>--sb-gutter</code> width; running = <code>Spinner</code> sm, otherwise unread = 7px accent dot</td></tr>
|
<tr><td>Status slot (lead)</td><td>fixed <code>--sb-gutter</code> width; running = <code>Spinner</code> sm, otherwise unread = 7px accent dot</td></tr>
|
||||||
<tr><td>Title</td><td>flex:1 with truncation; double-click enters inline rename (compact input, not Input)</td></tr>
|
<tr><td>Title</td><td>flex:1 with truncation; double-click enters inline rename (compact input, not Input)</td></tr>
|
||||||
<tr><td>Time</td><td>mono xs, <code>fg-faint</code>; yields to the kebab on hover</td></tr>
|
<tr><td>Time</td><td>mono xs, <code>fg-faint</code>; yields to the kebab on hover</td></tr>
|
||||||
|
|
@ -1400,11 +1419,11 @@ onUnmounted(() => {
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3 class="sub">Workspace group</h3>
|
<h3 class="sub">Workspace group</h3>
|
||||||
<p>The group head and session rows share <code>--sb-*</code>: folder icon (open/closed) → name → path subtitle, with the kebab and "+" revealed on hover.</p>
|
<p>The group head and session rows share <code>--sb-*</code>: folder icon (open/closed) → name, with the kebab and "+" revealed on hover.</p>
|
||||||
<ul class="clean">
|
<ul class="clean">
|
||||||
<li>The folder icon sits in the <code>--sb-gutter</code> slot, switching icons between open and closed states.</li>
|
<li>The folder icon leads the row (switching icons between open and closed states) with the plain <code>--sb-gap</code> before the name — it does not pad out the <code>--sb-gutter</code> slot.</li>
|
||||||
<li>A small <code>fg-muted</code> path line sits below the name.</li>
|
<li>The name is quiet by design — regular weight, muted color (<code>--color-text-muted</code>, one step lighter than session titles), so group heads read as grouping labels. No path subtitle; hovering the name shows the full root path in a <code>Tooltip</code>.</li>
|
||||||
<li>The kebab (menu) and "+" (new chat in this workspace) both use <code>IconButton</code> sm, shown on hover or keyboard focus (when not hovered they stay in the tab order via <code>opacity:0</code>, keeping them keyboard-reachable).</li>
|
<li>The kebab (menu) and "+" (new chat in this workspace) both use <code>IconButton</code> sm inside a floating actions layer anchored to the row's right edge — no reserved layout space, so the name uses the full row width when idle. Shown on hover, keyboard focus, or while the menu is open; the layer backs itself with the sidebar surface (container background) plus the row hover wash (an <code>::after</code> shown only while the row is hovered), so its color exactly equals the row's current background and the overlapped name tail doesn't bleed through (hidden via <code>opacity:0</code>, staying in the tab order).</li>
|
||||||
<li>The group is collapsible; when collapsed its session list is hidden.</li>
|
<li>The group is collapsible; when collapsed its session list is hidden.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
@ -1413,7 +1432,7 @@ onUnmounted(() => {
|
||||||
<table class="dt">
|
<table class="dt">
|
||||||
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
|
<thead><tr><th>Part</th><th>Rule</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td class="tk">Container</td><td>session-row pill: <code>display:flex; gap:--sb-gap; min-height:26px</code>, same padding as a session row, <code>radius-md</code>; hover = <code>surface-sunken</code> (no text recolor); <code>:focus-visible</code> uses <code>--p-focus-ring</code></td></tr>
|
<tr><td class="tk">Container</td><td>session-row pill: <code>display:flex; gap:--sb-gap; padding:8px …</code>, <b>no fixed/min height</b> (font-driven, ≈32px like a session row), same padding as a session row, <code>radius-sm</code>; hover = <code>--sb-hover</code> (no text recolor); <code>:focus-visible</code> uses <code>--p-focus-ring</code></td></tr>
|
||||||
<tr><td class="tk">Lead slot</td><td>empty, <code>--sb-gutter</code> wide, so the label's start x aligns with the session titles (<code>--sb-pad-x + --sb-gutter + --sb-gap</code>)</td></tr>
|
<tr><td class="tk">Lead slot</td><td>empty, <code>--sb-gutter</code> wide, so the label's start x aligns with the session titles (<code>--sb-pad-x + --sb-gutter + --sb-gap</code>)</td></tr>
|
||||||
<tr><td class="tk">Label</td><td><code>font-ui</code>, <code>text-xs</code>, <code>--color-text</code>; flex:1, truncated</td></tr>
|
<tr><td class="tk">Label</td><td><code>font-ui</code>, <code>text-xs</code>, <code>--color-text</code>; flex:1, truncated</td></tr>
|
||||||
<tr><td class="tk">Behavior</td><td>"Load more" fetches the next page and auto-expands; once more than the first page is loaded, "Show less" appears and collapses back to the first page (view-layer trim — data is kept, no refetch); "Show all" re-expands</td></tr>
|
<tr><td class="tk">Behavior</td><td>"Load more" fetches the next page and auto-expands; once more than the first page is loaded, "Show less" appears and collapses back to the first page (view-layer trim — data is kept, no refetch); "Show all" re-expands</td></tr>
|
||||||
|
|
@ -1442,7 +1461,7 @@ onUnmounted(() => {
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="callout info"><span class="ico">i</span><div>
|
<div class="callout info"><span class="ico">i</span><div>
|
||||||
<b>One-sentence principle:</b> the sidebar / shell is a "list + grid" skeleton that reuses the §02 tokens and §03 primitives (Button / IconButton / Badge / Menu / Spinner / PanelHeader); compact list controls that don't fit a primitive (search, New chat, inline rename, show-more) keep their custom form, governed by this section.
|
<b>One-sentence principle:</b> the sidebar / shell is a "list + grid" skeleton that reuses the §02 tokens and §03 primitives (Button / IconButton / Badge / Kbd / Menu / Spinner / PanelHeader); compact list controls that don't fit a primitive (search, New chat, inline rename, show-more) keep their custom form, governed by this section.
|
||||||
</div></div>
|
</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -1600,7 +1619,7 @@ onUnmounted(() => {
|
||||||
.brand-name { font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
|
.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; }
|
.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); }
|
.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 {
|
.nav a {
|
||||||
display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-radius: 7px;
|
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;
|
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.solid { background: var(--p-text); color: var(--p-bg); border-color: var(--p-text); }
|
||||||
.p-badge .p-ic { width: 12px; height: 12px; }
|
.p-badge .p-ic { width: 12px; height: 12px; }
|
||||||
|
|
||||||
|
/* Kbd — shortcut keycaps (one <kbd> block per key) */
|
||||||
|
.p-kbd { display: inline-flex; align-items: center; gap: 3px; }
|
||||||
|
.p-kbd kbd {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 18px; height: 18px; padding: 0 5px;
|
||||||
|
border: 1px solid var(--p-line); border-bottom-width: 2px; border-radius: var(--p-r-xs);
|
||||||
|
background: var(--p-surface-sunken); color: var(--p-text-muted);
|
||||||
|
font-family: var(--p-font-sans); font-size: 11px; line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* model / mode pill (composer toolbar) */
|
/* model / mode pill (composer toolbar) */
|
||||||
.p-pill {
|
.p-pill {
|
||||||
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px;
|
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { i18n } from '../src/i18n';
|
import { i18n } from '../src/i18n';
|
||||||
import { STORAGE_KEYS, safeGetString } from '../src/lib/storage';
|
import { STORAGE_KEYS, safeGetString } from '../src/lib/storage';
|
||||||
import {
|
import {
|
||||||
|
approvalNotificationCopy,
|
||||||
completionNotificationCopy,
|
completionNotificationCopy,
|
||||||
questionNotificationCopy,
|
questionNotificationCopy,
|
||||||
|
shouldNotifyCompletion,
|
||||||
useNotification,
|
useNotification,
|
||||||
} from '../src/composables/client/useNotification';
|
} from '../src/composables/client/useNotification';
|
||||||
|
|
||||||
|
|
@ -41,11 +43,17 @@ function installStorage(storage: Storage): void {
|
||||||
// Singleton — module-level refs + setters. The OS Notification API is absent in
|
// Singleton — module-level refs + setters. The OS Notification API is absent in
|
||||||
// the test env, so the *enable* path is a no-op; the disable path and the
|
// the test env, so the *enable* path is a no-op; the disable path and the
|
||||||
// load-from-storage defaults are what we exercise here.
|
// load-from-storage defaults are what we exercise here.
|
||||||
const { notifyOnComplete, notifyOnQuestion, setNotifyOnComplete, setNotifyOnQuestion } = useNotification();
|
const {
|
||||||
// Captured at import (before beforeEach touches the refs), so these reflect the
|
notifyOnComplete,
|
||||||
// load-from-storage defaults when nothing has been stored yet.
|
notifyOnQuestion,
|
||||||
|
notifyOnApproval,
|
||||||
|
setNotifyOnComplete,
|
||||||
|
setNotifyOnQuestion,
|
||||||
|
setNotifyOnApproval,
|
||||||
|
} = useNotification();
|
||||||
const importedCompleteDefault = notifyOnComplete.value;
|
const importedCompleteDefault = notifyOnComplete.value;
|
||||||
const importedQuestionDefault = notifyOnQuestion.value;
|
const importedQuestionDefault = notifyOnQuestion.value;
|
||||||
|
const importedApprovalDefault = notifyOnApproval.value;
|
||||||
|
|
||||||
describe('useNotification preferences', () => {
|
describe('useNotification preferences', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -64,6 +72,10 @@ describe('useNotification preferences', () => {
|
||||||
expect(importedQuestionDefault).toBe(false);
|
expect(importedQuestionDefault).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('approval notifications default to off', () => {
|
||||||
|
expect(importedApprovalDefault).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('disabling question notifications persists "0" and updates the ref', () => {
|
it('disabling question notifications persists "0" and updates the ref', () => {
|
||||||
void setNotifyOnQuestion(false);
|
void setNotifyOnQuestion(false);
|
||||||
expect(notifyOnQuestion.value).toBe(false);
|
expect(notifyOnQuestion.value).toBe(false);
|
||||||
|
|
@ -75,6 +87,12 @@ describe('useNotification preferences', () => {
|
||||||
expect(notifyOnComplete.value).toBe(false);
|
expect(notifyOnComplete.value).toBe(false);
|
||||||
expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0');
|
expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disabling approval notifications persists "0" and updates the ref', () => {
|
||||||
|
void setNotifyOnApproval(false);
|
||||||
|
expect(notifyOnApproval.value).toBe(false);
|
||||||
|
expect(safeGetString(STORAGE_KEYS.notifyOnApproval)).toBe('0');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('notification copy', () => {
|
describe('notification copy', () => {
|
||||||
|
|
@ -110,6 +128,32 @@ describe('notification copy', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses tool name in approval notifications', () => {
|
||||||
|
expect(approvalNotificationCopy('Refactor auth flow', 'bash')).toEqual({
|
||||||
|
title: 'Kimi Code · Approval required',
|
||||||
|
body: 'bash',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to session title and then generic approval line', () => {
|
||||||
|
expect(approvalNotificationCopy('Refactor auth flow', ' ')).toEqual({
|
||||||
|
title: 'Kimi Code · Approval required',
|
||||||
|
body: 'Refactor auth flow',
|
||||||
|
});
|
||||||
|
expect(approvalNotificationCopy(' ', ' ')).toEqual({
|
||||||
|
title: 'Kimi Code · Approval required',
|
||||||
|
body: 'A tool needs your approval',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localizes approval notification copy', () => {
|
||||||
|
i18n.global.locale.value = 'zh';
|
||||||
|
expect(approvalNotificationCopy('', '')).toEqual({
|
||||||
|
title: 'Kimi Code · 等待审批',
|
||||||
|
body: '有工具等待你审批',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('localizes the notification copy', () => {
|
it('localizes the notification copy', () => {
|
||||||
i18n.global.locale.value = 'zh';
|
i18n.global.locale.value = 'zh';
|
||||||
|
|
||||||
|
|
@ -123,3 +167,83 @@ describe('notification copy', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('shouldNotifyCompletion', () => {
|
||||||
|
it('returns true only for idle + no pending approval + no pending question', () => {
|
||||||
|
expect(shouldNotifyCompletion('idle', false, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for aborted', () => {
|
||||||
|
expect(shouldNotifyCompletion('aborted', false, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when pending approval exists', () => {
|
||||||
|
expect(shouldNotifyCompletion('idle', true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when pending question exists', () => {
|
||||||
|
expect(shouldNotifyCompletion('idle', false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same-tag notifications replace silently (renotify is unreliable), so the tag
|
||||||
|
// must be unique per turn/request for follow-up alerts in a session to pop.
|
||||||
|
describe('notification tags', () => {
|
||||||
|
class FakeNotification {
|
||||||
|
static permission = 'granted';
|
||||||
|
static fired: Array<{ title: string; tag?: string }> = [];
|
||||||
|
onclick: (() => void) | null = null;
|
||||||
|
constructor(title: string, options?: { body?: string; tag?: string; icon?: string }) {
|
||||||
|
FakeNotification.fired.push({ title, tag: options?.tag });
|
||||||
|
}
|
||||||
|
close(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { maybeNotifyCompletion, maybeNotifyQuestion, maybeNotifyApproval } = useNotification();
|
||||||
|
const base = { isUserWatching: false, sessionTitle: 'T', onClick: () => {} };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeNotification.fired = [];
|
||||||
|
(globalThis as Record<string, unknown>).Notification = FakeNotification;
|
||||||
|
notifyOnComplete.value = true;
|
||||||
|
notifyOnQuestion.value = true;
|
||||||
|
notifyOnApproval.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (globalThis as Record<string, unknown>).Notification;
|
||||||
|
notifyOnComplete.value = true;
|
||||||
|
notifyOnQuestion.value = false;
|
||||||
|
notifyOnApproval.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completion tags carry the prompt id so each turn in a session alerts', () => {
|
||||||
|
maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
|
||||||
|
maybeNotifyCompletion('s1', { ...base, promptId: 'p2' });
|
||||||
|
expect(FakeNotification.fired.map((f) => f.tag)).toEqual([
|
||||||
|
'kimi-complete-s1-p1',
|
||||||
|
'kimi-complete-s1-p2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a replayed idle event for the same turn keeps the same tag', () => {
|
||||||
|
maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
|
||||||
|
maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
|
||||||
|
expect(FakeNotification.fired).toHaveLength(2);
|
||||||
|
expect(FakeNotification.fired[0]?.tag).toBe(FakeNotification.fired[1]?.tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('question and approval tags are per-request', () => {
|
||||||
|
maybeNotifyQuestion({ ...base, questionPreview: 'q', questionId: 'q1' });
|
||||||
|
maybeNotifyApproval({ ...base, toolName: 'bash', approvalId: 'a1' });
|
||||||
|
expect(FakeNotification.fired.map((f) => f.tag)).toEqual([
|
||||||
|
'kimi-question-q1',
|
||||||
|
'kimi-approval-a1',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses the notification while the user is watching the session', () => {
|
||||||
|
maybeNotifyCompletion('s1', { ...base, isUserWatching: true, promptId: 'p1' });
|
||||||
|
expect(FakeNotification.fired).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -638,7 +638,7 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => {
|
||||||
// Activation must NOT have started while /profile is still pending.
|
// Activation must NOT have started while /profile is still pending.
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
expect(persistSessionProfile).toHaveBeenCalledWith(
|
expect(persistSessionProfile).toHaveBeenCalledWith(
|
||||||
{ planMode: true, swarmMode: true, permissionMode: 'auto', thinking: 'high' },
|
{ model: undefined, planMode: true, swarmMode: true, permissionMode: 'auto', thinking: 'high' },
|
||||||
'sess_new',
|
'sess_new',
|
||||||
);
|
);
|
||||||
expect(activateSkill).not.toHaveBeenCalled();
|
expect(activateSkill).not.toHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import Icons from 'unplugin-icons/vite';
|
import Icons from 'unplugin-icons/vite';
|
||||||
|
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const webPort = Number(process.env.WEB_PORT) || 5175;
|
const webPort = Number(process.env.WEB_PORT) || 5175;
|
||||||
// Where the dev proxy forwards server traffic. Defaults to the local server
|
// 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({
|
export default defineConfig({
|
||||||
plugins: [vue(), Icons({ compiler: 'vue3' })],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Icons({
|
||||||
|
compiler: 'vue3',
|
||||||
|
// Local Kimi Design System icons (24×24 outlined, fill="currentColor"),
|
||||||
|
// copied from the design-system icon pack into src/icons/kimi/ and
|
||||||
|
// imported as `~icons/kimi/<file-name>` (plus `?raw`), same as the ri
|
||||||
|
// collection. Registered in src/lib/icons.ts only.
|
||||||
|
customCollections: {
|
||||||
|
kimi: FileSystemIconLoader(fileURLToPath(new URL('./src/icons/kimi', import.meta.url))),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
// Expose the dev proxy's upstream server target to the client so the UI can
|
// 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
|
// show which server it is connected to (the browser otherwise only sees its
|
||||||
// own same-origin URL). Unused by the same-origin production build.
|
// own same-origin URL). Unused by the same-origin production build.
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) |
|
||||||
| `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) |
|
| `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) |
|
||||||
| `background` | `table` | — | Background task runtime parameters → [`background`](#background) |
|
| `background` | `table` | — | Background task runtime parameters → [`background`](#background) |
|
||||||
|
| `image` | `table` | — | Image compression parameters → [`image`](#image) |
|
||||||
| `services` | `table` | — | Built-in external service configuration → [`services`](#services) |
|
| `services` | `table` | — | Built-in external service configuration → [`services`](#services) |
|
||||||
| `permission` | `table` | — | Initial permission rules → [`permission`](#permission) |
|
| `permission` | `table` | — | Initial permission rules → [`permission`](#permission) |
|
||||||
| `hooks` | `array<table>` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) |
|
| `hooks` | `array<table>` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) |
|
||||||
|
|
||||||
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `services`, and `permission`.
|
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `image`, `services`, and `permission`.
|
||||||
|
|
||||||
## `providers`
|
## `providers`
|
||||||
|
|
||||||
|
|
@ -202,6 +203,17 @@ You can also switch models temporarily without touching the config file — by s
|
||||||
|
|
||||||
In print mode (`kimi -p "<prompt>"`), Kimi Code runs a single non-interactive turn and exits as soon as the main agent finishes. If you launch background tasks (for example, concurrent subagents via `Agent(run_in_background=true)`) and need them to run to completion, set `keep_alive_on_exit = true`: the process then waits for every background task to reach a terminal state before exiting, bounded by `print_wait_ceiling_s`. Without it, the single turn ending tears background tasks down with the process.
|
In print mode (`kimi -p "<prompt>"`), Kimi Code runs a single non-interactive turn and exits as soon as the main agent finishes. If you launch background tasks (for example, concurrent subagents via `Agent(run_in_background=true)`) and need them to run to completion, set `keep_alive_on_exit = true`: the process then waits for every background task to reach a terminal state before exiting, bounded by `print_wait_ceiling_s`. Without it, the single turn ending tears background tasks down with the process.
|
||||||
|
|
||||||
|
## `image`
|
||||||
|
|
||||||
|
`image` controls how images are compressed before being sent to the model, across every ingestion point (pasted images, `ReadMediaFile` reads, images in MCP tool results, and so on).
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `max_edge_px` | `integer` | `2000` | Longest-edge ceiling in pixels. Larger images are scaled down proportionally to fit; raising it preserves more detail at the cost of larger request bodies |
|
||||||
|
| `read_byte_budget` | `integer` | `262144` (256 KB) | Per-image byte budget for images the model reads for itself (`ReadMediaFile` default reads). It bounds the accumulated request-body size when the model keeps screenshotting and reading images; fine detail stays reachable through the `region` parameter, which reads a crop back at full fidelity (`region` and `full_resolution` are not subject to this budget) |
|
||||||
|
|
||||||
|
`max_edge_px` can be overridden by the `KIMI_IMAGE_MAX_EDGE_PX` environment variable and `read_byte_budget` by `KIMI_IMAGE_READ_BYTE_BUDGET`; both take higher priority than `config.toml`.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
## `experimental`
|
## `experimental`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,8 @@ Switches that control the behavior of subsystems such as telemetry, background t
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
|
| `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
|
||||||
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
|
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
|
||||||
|
| `KIMI_IMAGE_MAX_EDGE_PX` | Longest-edge ceiling (px) for image compression; takes higher priority than `[image] max_edge_px` in `config.toml` (default `2000`) | Positive integer; invalid values are ignored |
|
||||||
|
| `KIMI_IMAGE_READ_BYTE_BUDGET` | Per-image byte budget for model-initiated image reads (`ReadMediaFile` default reads); takes higher priority than `[image] read_byte_budget` in `config.toml` (default `262144`, i.e. 256 KB) | Positive integer; invalid values are ignored |
|
||||||
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths |
|
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths |
|
||||||
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast |
|
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast |
|
||||||
| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` |
|
| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` |
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,60 @@ outline: 2
|
||||||
|
|
||||||
This page documents the changes in each Kimi Code CLI release.
|
This page documents the changes in each Kimi Code CLI release.
|
||||||
|
|
||||||
|
## 0.23.4 (2026-07-10)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- web: Add notifications when a tool needs approval, and improve notification reliability.
|
||||||
|
|
||||||
|
### Polish
|
||||||
|
|
||||||
|
- web: Polish the chat UI with Inter typography, localized labels, and tighter composer and menu styling.
|
||||||
|
- web: Polish the session sidebar layout, colors, icons, and typography.
|
||||||
|
- Display the Extra Usage (fuel pack) balance in the `/usage` and `/status` commands.
|
||||||
|
- Add a Kimi WebBridge entry to the Official tab of the `/plugins` panel that opens the WebBridge install page in your browser.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Keep image-heavy sessions within provider request-size limits: oversized images (model-read and pasted, including WebP) are downscaled and compressed, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and an HTTP 413 request-too-large now recovers automatically — the request and `/compact` retry with older media replaced by text markers. The limits are configurable via `[image]` in `config.toml` (or `KIMI_IMAGE_*` env vars), and each core keeps its own settings so reloading one client's config no longer changes another client's compression.
|
||||||
|
- Fix resuming sessions whose original working directory no longer exists.
|
||||||
|
- Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts.
|
||||||
|
- web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent.
|
||||||
|
|
||||||
|
## 0.23.3 (2026-07-08)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix a misleading "OAuth login expired" message shown when a model is not available for the current account.
|
||||||
|
|
||||||
|
## 0.23.2 (2026-07-08)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add the Vercel plugin to the bundled plugin marketplace. Run `/plugins` and select Vercel Plugin to install it.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix `kimi -p` runs exiting with code 0 when a turn fails.
|
||||||
|
- Prevent autonomous goals from being paused by model-reported status updates.
|
||||||
|
- Count the turn that starts an autonomous goal toward its turn budget.
|
||||||
|
- Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly.
|
||||||
|
- web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background.
|
||||||
|
- Fix console windows flashing on Windows each time a hook runs.
|
||||||
|
|
||||||
|
### Polish
|
||||||
|
|
||||||
|
- web: Redesign the scheduled reminder UI.
|
||||||
|
- web: Show session skills in the slash menu as `/skill:<name>` so they are distinguishable from built-in commands; typing the bare skill name still works.
|
||||||
|
- web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice.
|
||||||
|
- web: Press Enter to confirm in archive and other confirmation dialogs.
|
||||||
|
- Tighten goal-mode guidance for blocked and complete status updates.
|
||||||
|
- Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them, and the model re-selects the tools it still needs afterward. A from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active.
|
||||||
|
|
||||||
|
### Refactors
|
||||||
|
|
||||||
|
- web: Compile icons at build time so the bundled web UI only carries the icons it renders.
|
||||||
|
|
||||||
## 0.23.1 (2026-07-07)
|
## 0.23.1 (2026-07-07)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
|
||||||
|
|
@ -87,11 +87,12 @@ timeout = 5
|
||||||
| `thinking` | `table` | — | Thinking 模式默认参数 → [`thinking`](#thinking) |
|
| `thinking` | `table` | — | Thinking 模式默认参数 → [`thinking`](#thinking) |
|
||||||
| `loop_control` | `table` | — | Agent 循环控制参数 → [`loop_control`](#loop_control) |
|
| `loop_control` | `table` | — | Agent 循环控制参数 → [`loop_control`](#loop_control) |
|
||||||
| `background` | `table` | — | 后台任务运行参数 → [`background`](#background) |
|
| `background` | `table` | — | 后台任务运行参数 → [`background`](#background) |
|
||||||
|
| `image` | `table` | — | 图片压缩参数 → [`image`](#image) |
|
||||||
| `services` | `table` | — | 内置外部服务配置 → [`services`](#services) |
|
| `services` | `table` | — | 内置外部服务配置 → [`services`](#services) |
|
||||||
| `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) |
|
| `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) |
|
||||||
| `hooks` | `array<table>` | — | 生命周期 hook,详见 [Hooks](../customization/hooks.md) |
|
| `hooks` | `array<table>` | — | 生命周期 hook,详见 [Hooks](../customization/hooks.md) |
|
||||||
|
|
||||||
以下各节对 `providers`、`models`、`thinking`、`loop_control`、`background`、`services`、`permission` 等嵌套表逐一展开。
|
以下各节对 `providers`、`models`、`thinking`、`loop_control`、`background`、`image`、`services`、`permission` 等嵌套表逐一展开。
|
||||||
|
|
||||||
## `providers`
|
## `providers`
|
||||||
|
|
||||||
|
|
@ -202,6 +203,17 @@ display_name = "Kimi for Coding (custom)"
|
||||||
|
|
||||||
在 print 模式(`kimi -p "<prompt>"`)下,Kimi Code 只跑一个非交互的单轮 turn,主 agent 一结束就退出。如果你启动了后台任务(例如通过 `Agent(run_in_background=true)` 并发子代理)并希望它们跑完,请设置 `keep_alive_on_exit = true`:进程会在退出前等待所有后台任务进入终态,最长不超过 `print_wait_ceiling_s`。否则,单轮 turn 结束时后台任务会随进程一起被清理。
|
在 print 模式(`kimi -p "<prompt>"`)下,Kimi Code 只跑一个非交互的单轮 turn,主 agent 一结束就退出。如果你启动了后台任务(例如通过 `Agent(run_in_background=true)` 并发子代理)并希望它们跑完,请设置 `keep_alive_on_exit = true`:进程会在退出前等待所有后台任务进入终态,最长不超过 `print_wait_ceiling_s`。否则,单轮 turn 结束时后台任务会随进程一起被清理。
|
||||||
|
|
||||||
|
## `image`
|
||||||
|
|
||||||
|
`image` 控制图片发送给模型前的压缩行为,对所有图片入口生效(粘贴图片、`ReadMediaFile` 读图、MCP 工具结果里的图片等)。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `max_edge_px` | `integer` | `2000` | 图片最长边上限(像素)。超过时按比例缩小到该值以内;调大可保留更多细节,代价是更大的请求体积 |
|
||||||
|
| `read_byte_budget` | `integer` | `262144`(256 KB) | 模型自行读取的图片(`ReadMediaFile` 默认读取)的单图字节预算。会话中模型反复截图、读图时,累计请求体大小由它控制;细节可通过 `region` 参数按原图坐标全保真回读(`region` 与 `full_resolution` 不受此预算限制) |
|
||||||
|
|
||||||
|
`max_edge_px` 可被环境变量 `KIMI_IMAGE_MAX_EDGE_PX` 覆盖,`read_byte_budget` 可被 `KIMI_IMAGE_READ_BYTE_BUDGET` 覆盖,优先级均高于配置文件。
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
## `experimental`
|
## `experimental`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,8 @@ kimi
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1`、`true`、`yes`、`y`(不区分大小写) |
|
| `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1`、`true`、`yes`、`y`(不区分大小写) |
|
||||||
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
|
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
|
||||||
|
| `KIMI_IMAGE_MAX_EDGE_PX` | 图片压缩的最长边上限(像素),优先级高于 `config.toml` 的 `[image] max_edge_px`(默认 `2000`) | 正整数;非法值被忽略 |
|
||||||
|
| `KIMI_IMAGE_READ_BYTE_BUDGET` | 模型自行读图(`ReadMediaFile` 默认读取)的单图字节预算,优先级高于 `config.toml` 的 `[image] read_byte_budget`(默认 `262144`,即 256 KB) | 正整数;非法值被忽略 |
|
||||||
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON,适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://`、`file://` URL 和本地路径 |
|
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON,适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://`、`file://` URL 和本地路径 |
|
||||||
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 |
|
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 |
|
||||||
| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1`、`true`、`yes`、`on` |
|
| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1`、`true`、`yes`、`on` |
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,60 @@ outline: 2
|
||||||
|
|
||||||
本页记录 Kimi Code CLI 每个版本的变更内容。
|
本页记录 Kimi Code CLI 每个版本的变更内容。
|
||||||
|
|
||||||
|
## 0.23.4(2026-07-10)
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
|
||||||
|
- web: 新增工具需要审批时的通知,并提升通知的可靠性。
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- web: 优化聊天界面,采用 Inter 字体、本地化标签与更紧凑的输入框和菜单样式。
|
||||||
|
- web: 优化会话侧边栏的布局、配色、图标与字体。
|
||||||
|
- `/usage` 和 `/status` 命令现显示 Extra Usage(加油包)余额。
|
||||||
|
- `/plugins` 面板的 Official 标签页新增 Kimi WebBridge 入口,可在浏览器中打开 WebBridge 安装页。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 控制图片较多会话的请求体积:超大体量的模型读取与粘贴图片(含 WebP)会自动压缩、缩小;HEIC/HEIF 图片会给出对应平台的转换命令,而非污染会话;HTTP 413 请求过大现可自动恢复——请求和 `/compact` 会用文本标记替换旧媒体后重试。相关限制可通过 `config.toml` 的 `[image]`(或 `KIMI_IMAGE_*` 环境变量)配置,且每个 core 独立保存设置,重新加载某客户端的配置不再影响其他客户端的图片压缩。
|
||||||
|
- 修复原工作目录已不存在的会话无法恢复的问题。
|
||||||
|
- 修复 prompt 模式目标未运行至完成的问题,并在发送 prompt 前校验并提示无效的目标命令。
|
||||||
|
- web: 修复新对话发送首条消息时偶发的 “another turn is active” 错误,并在发送过程中显示启动状态。
|
||||||
|
|
||||||
|
## 0.23.3(2026-07-08)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复当前账户无法使用某模型时错误显示“OAuth 登录已过期”的问题。
|
||||||
|
|
||||||
|
## 0.23.2(2026-07-08)
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
|
||||||
|
- 内置插件市场新增 Vercel 插件,运行 `/plugins` 并选择 Vercel Plugin 即可安装。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复 `kimi -p` 在轮次失败时仍以退出码 0 退出的问题。
|
||||||
|
- 修复自主目标会被模型上报的状态更新暂停的问题。
|
||||||
|
- 修复启动自主目标的轮次未计入其轮次预算的问题。
|
||||||
|
- 将图片降采样上限从 2000px 提高到 3000px,并修复 EXIF 旋转(竖拍)照片在压缩说明与媒体读取备注中宽高互换的问题,使区域回读坐标正确对应。
|
||||||
|
- web: 修复从后台返回后,WebSocket 重连完成但连接错误提示仍残留的问题。
|
||||||
|
- 修复 Windows 上每次运行 hook 时控制台窗口闪烁的问题。
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- web: 重新设计定时提醒界面。
|
||||||
|
- web: 在斜杠菜单中以 `/skill:<name>` 显示会话技能,便于与内置命令区分;直接输入技能名称仍然可用。
|
||||||
|
- web: 输入框的模型切换器在切换当前会话模型的同时,也会更新全局默认模型,使新会话继承该选择。
|
||||||
|
- web: 归档等确认对话框支持按 Enter 确认。
|
||||||
|
- 优化目标模式对阻塞与完成状态更新的指引。
|
||||||
|
- 渐进式工具加载(`select_tools`,实验功能):压缩后丢弃已加载的工具 schema,由模型重新选择仍需要的工具,使压缩后上下文保持精简;凭记忆调用未再加载的工具会被拒绝,并提示先选择。仅在启用 `tool-select` 实验标志且模型支持 `select_tools` 时生效。
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
|
||||||
|
- web: 在构建时编译图标,使打包后的 web UI 仅包含实际渲染的图标。
|
||||||
|
|
||||||
## 0.23.1(2026-07-07)
|
## 0.23.1(2026-07-07)
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@
|
||||||
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
||||||
inherit pnpm;
|
inherit pnpm;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-RPjCWL7NqDSKgpHGL16zPlUOfjWN2rkaDY/4GFAD8VA=";
|
hash = "sha256-iBk+TV+rIhmd7bYnVFbW3kTGltojJl3pL2hhmsGO+Fk=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@ export async function compressPromptImageParts(
|
||||||
readonly originalsDir?: string | undefined;
|
readonly originalsDir?: string | undefined;
|
||||||
/** Report an `image_compress` event per prompt image (source `acp_prompt`). */
|
/** Report an `image_compress` event per prompt image (source `acp_prompt`). */
|
||||||
readonly telemetry?: TelemetryClient | undefined;
|
readonly telemetry?: TelemetryClient | undefined;
|
||||||
|
/**
|
||||||
|
* Longest-edge ceiling (px) from the harness's [image] config, resolved
|
||||||
|
* per prompt so a config reload applies immediately. Absent → the
|
||||||
|
* env/built-in default cap applies.
|
||||||
|
*/
|
||||||
|
readonly maxImageEdgePx?: number | undefined;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<PromptPart[]> {
|
): Promise<PromptPart[]> {
|
||||||
const out: PromptPart[] = [];
|
const out: PromptPart[] = [];
|
||||||
|
|
@ -101,6 +107,7 @@ export async function compressPromptImageParts(
|
||||||
const parsed = parseImageDataUrl(part.imageUrl.url);
|
const parsed = parseImageDataUrl(part.imageUrl.url);
|
||||||
if (parsed !== null) {
|
if (parsed !== null) {
|
||||||
const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, {
|
const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, {
|
||||||
|
maxEdge: options.maxImageEdgePx,
|
||||||
telemetry:
|
telemetry:
|
||||||
options.telemetry === undefined
|
options.telemetry === undefined
|
||||||
? undefined
|
? undefined
|
||||||
|
|
|
||||||
|
|
@ -741,6 +741,7 @@ export class AcpSession {
|
||||||
parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks), {
|
parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks), {
|
||||||
originalsDir:
|
originalsDir:
|
||||||
sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir),
|
sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir),
|
||||||
|
maxImageEdgePx: this.harness?.imageLimits?.maxEdgePx(),
|
||||||
telemetry:
|
telemetry:
|
||||||
track === undefined
|
track === undefined
|
||||||
? undefined
|
? undefined
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,39 @@ describe('compressPromptImageParts', () => {
|
||||||
await rm(originalsDir, { recursive: true, force: true });
|
await rm(originalsDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('downsamples to the caller-provided max edge instead of the built-in cap', async () => {
|
||||||
|
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
|
||||||
|
const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(3600, 1800), 'image/png')]);
|
||||||
|
const compressed = await compressPromptImageParts(parts, {
|
||||||
|
originalsDir,
|
||||||
|
maxImageEdgePx: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
const part = compressed[1];
|
||||||
|
if (part?.type !== 'image_url') throw new Error('expected an image_url part');
|
||||||
|
const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64'));
|
||||||
|
expect(decoded.width).toBe(800);
|
||||||
|
expect(decoded.height).toBe(400);
|
||||||
|
await rm(originalsDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the built-in 2000px cap when no max edge is provided', async () => {
|
||||||
|
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
|
||||||
|
const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(3600, 1800), 'image/png')]);
|
||||||
|
const compressed = await compressPromptImageParts(parts, { originalsDir });
|
||||||
|
|
||||||
|
const part = compressed[1];
|
||||||
|
if (part?.type !== 'image_url') throw new Error('expected an image_url part');
|
||||||
|
const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64'));
|
||||||
|
expect(decoded.width).toBe(2000);
|
||||||
|
expect(decoded.height).toBe(1000);
|
||||||
|
await rm(originalsDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('emits image_compress telemetry tagged acp_prompt', async () => {
|
it('emits image_compress telemetry tagged acp_prompt', async () => {
|
||||||
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
|
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
|
||||||
const events: { event: string; props: Record<string, unknown> }[] = [];
|
const events: { event: string; props: Record<string, unknown> }[] = [];
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@antfu/utils": "^9.3.0",
|
"@antfu/utils": "^9.3.0",
|
||||||
|
"@jsquash/webp": "^1.5.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@moonshot-ai/kaos": "workspace:^",
|
"@moonshot-ai/kaos": "workspace:^",
|
||||||
"@moonshot-ai/kimi-code-oauth": "workspace:^",
|
"@moonshot-ai/kimi-code-oauth": "workspace:^",
|
||||||
|
|
|
||||||
36
packages/agent-core/scripts/generate-webp-dec-wasm.mjs
Normal file
36
packages/agent-core/scripts/generate-webp-dec-wasm.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Regenerate `src/tools/support/webp-dec-wasm.ts` from the installed
|
||||||
|
* `@jsquash/webp` package.
|
||||||
|
*
|
||||||
|
* The WebP decoder wasm is committed as a base64 string module because the
|
||||||
|
* published CLI bundles every dependency into a single file with no runtime
|
||||||
|
* node_modules — a file-path lookup for the .wasm would break there, while a
|
||||||
|
* string constant survives every packaging (vitest on sources, tsdown
|
||||||
|
* bundling, nix builds) unchanged. Run this after bumping @jsquash/webp:
|
||||||
|
*
|
||||||
|
* node scripts/generate-webp-dec-wasm.mjs
|
||||||
|
*/
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const packageRoot = resolve(import.meta.dirname, '..');
|
||||||
|
const require = createRequire(resolve(packageRoot, 'package.json'));
|
||||||
|
|
||||||
|
const wasmPath = require.resolve('@jsquash/webp/codec/dec/webp_dec.wasm');
|
||||||
|
const version = require('@jsquash/webp/package.json').version;
|
||||||
|
const wasm = readFileSync(wasmPath);
|
||||||
|
|
||||||
|
const target = resolve(packageRoot, 'src/tools/support/webp-dec-wasm.ts');
|
||||||
|
writeFileSync(
|
||||||
|
target,
|
||||||
|
`// GENERATED FILE — do not edit by hand.
|
||||||
|
// WebP decoder wasm from @jsquash/webp@${version} (codec/dec/webp_dec.wasm),
|
||||||
|
// base64-encoded so the bundled CLI needs no on-disk wasm asset.
|
||||||
|
// Regenerate with: node scripts/generate-webp-dec-wasm.mjs
|
||||||
|
|
||||||
|
export const WEBP_DECODER_WASM_BASE64 =
|
||||||
|
'${wasm.toString('base64')}';
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
console.log(`Wrote ${target} (${wasm.length} bytes of wasm)`);
|
||||||
|
|
@ -8,10 +8,12 @@ import {
|
||||||
APIEmptyResponseError,
|
APIEmptyResponseError,
|
||||||
inputTotal,
|
inputTotal,
|
||||||
isRetryableGenerateError,
|
isRetryableGenerateError,
|
||||||
|
type ContentPart,
|
||||||
type GenerateResult,
|
type GenerateResult,
|
||||||
type Message,
|
type Message,
|
||||||
type TokenUsage,
|
type TokenUsage,
|
||||||
APIContextOverflowError,
|
APIContextOverflowError,
|
||||||
|
APIRequestTooLargeError,
|
||||||
APIStatusError,
|
APIStatusError,
|
||||||
createUserMessage,
|
createUserMessage,
|
||||||
} from '@moonshot-ai/kosong';
|
} from '@moonshot-ai/kosong';
|
||||||
|
|
@ -430,6 +432,7 @@ export class FullCompaction {
|
||||||
// prefix-race check and `compactedCount`.
|
// prefix-race check and `compactedCount`.
|
||||||
let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory);
|
let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory);
|
||||||
let droppedCount = 0;
|
let droppedCount = 0;
|
||||||
|
let mediaStripAttempted = false;
|
||||||
let overflowShrinkCount = 0;
|
let overflowShrinkCount = 0;
|
||||||
let emptyOrTruncatedShrinkCount = 0;
|
let emptyOrTruncatedShrinkCount = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -465,6 +468,24 @@ export class FullCompaction {
|
||||||
summary = extractCompactionSummary(response);
|
summary = extractCompactionSummary(response);
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// A request-body-size rejection (HTTP 413) is first retried with
|
||||||
|
// media parts replaced by text markers: accumulated base64 payloads
|
||||||
|
// are the usual culprit, and a text summary does not need them —
|
||||||
|
// the conversation already narrates what was seen, and the
|
||||||
|
// ReadMediaFile `<image path="...">` text wrapper survives. Only
|
||||||
|
// the summarizer input copy is rewritten; the real history keeps
|
||||||
|
// its media. A 413 after the strip (or with no media to strip)
|
||||||
|
// falls through to the overflow shrink below — dropping oldest
|
||||||
|
// messages shrinks the body too.
|
||||||
|
if (error instanceof APIRequestTooLargeError && !mediaStripAttempted) {
|
||||||
|
mediaStripAttempted = true;
|
||||||
|
const stripped = replaceMediaPartsWithMarkers(historyForModel);
|
||||||
|
if (stripped !== historyForModel) {
|
||||||
|
historyForModel = stripped;
|
||||||
|
retryCount = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
const isContextOverflow = this.shouldRecoverFromContextOverflow(
|
const isContextOverflow = this.shouldRecoverFromContextOverflow(
|
||||||
error,
|
error,
|
||||||
estimatedCompactionRequestTokens,
|
estimatedCompactionRequestTokens,
|
||||||
|
|
@ -472,7 +493,9 @@ export class FullCompaction {
|
||||||
if (isContextOverflow) {
|
if (isContextOverflow) {
|
||||||
this.observeContextOverflow(estimatedCompactionRequestTokens);
|
this.observeContextOverflow(estimatedCompactionRequestTokens);
|
||||||
}
|
}
|
||||||
if (isContextOverflow && historyForModel.length > 1) {
|
const shouldShrinkAfterOverflow =
|
||||||
|
isContextOverflow || error instanceof APIRequestTooLargeError;
|
||||||
|
if (shouldShrinkAfterOverflow && historyForModel.length > 1) {
|
||||||
overflowShrinkCount += 1;
|
overflowShrinkCount += 1;
|
||||||
if (overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS) {
|
if (overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -595,7 +618,12 @@ export class FullCompaction {
|
||||||
thinking_effort: this.agent.config.thinkingEffort,
|
thinking_effort: this.agent.config.thinkingEffort,
|
||||||
error_type: error instanceof Error ? error.name : 'Unknown',
|
error_type: error instanceof Error ? error.name : 'Unknown',
|
||||||
});
|
});
|
||||||
if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error;
|
if (
|
||||||
|
isKimiError(error) &&
|
||||||
|
(error.code === ErrorCodes.AUTH_LOGIN_REQUIRED ||
|
||||||
|
error.code === ErrorCodes.PROVIDER_AUTH_ERROR)
|
||||||
|
)
|
||||||
|
throw error;
|
||||||
throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error });
|
throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -634,6 +662,40 @@ export class FullCompaction {
|
||||||
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
|
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
|
||||||
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;
|
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;
|
||||||
|
|
||||||
|
const MEDIA_PART_MARKERS = {
|
||||||
|
image_url: '[image]',
|
||||||
|
audio_url: '[audio]',
|
||||||
|
video_url: '[video]',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function isMediaPart(part: ContentPart): part is ContentPart & { type: keyof typeof MEDIA_PART_MARKERS } {
|
||||||
|
return part.type in MEDIA_PART_MARKERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace media parts (image/audio/video) with text markers in the summarizer
|
||||||
|
* input, for the 413 strip-and-retry above. Messages without media are
|
||||||
|
* returned by reference (keeping the per-message token-estimate cache warm),
|
||||||
|
* and when nothing changed the input array itself is returned so the caller
|
||||||
|
* can tell there was no media to strip.
|
||||||
|
*/
|
||||||
|
function replaceMediaPartsWithMarkers(
|
||||||
|
messages: readonly ContextMessage[],
|
||||||
|
): readonly ContextMessage[] {
|
||||||
|
let changed = false;
|
||||||
|
const out = messages.map((message) => {
|
||||||
|
if (!message.content.some(isMediaPart)) return message;
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: message.content.map((part): ContentPart =>
|
||||||
|
isMediaPart(part) ? { type: 'text', text: MEDIA_PART_MARKERS[part.type] } : part,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? out : messages;
|
||||||
|
}
|
||||||
|
|
||||||
function shrinkCompactionHistoryAfterOverflow<T extends Message>(
|
function shrinkCompactionHistoryAfterOverflow<T extends Message>(
|
||||||
messages: readonly T[],
|
messages: readonly T[],
|
||||||
attempt: number,
|
attempt: number,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export class ConfigState {
|
||||||
});
|
});
|
||||||
if (changed.cwd) {
|
if (changed.cwd) {
|
||||||
this._cwd = changed.cwd;
|
this._cwd = changed.cwd;
|
||||||
void this.agent.kaos.chdir(changed.cwd);
|
this.agent.setKaos(this.agent.kaos.withCwd(changed.cwd));
|
||||||
}
|
}
|
||||||
if (changed.modelAlias) {
|
if (changed.modelAlias) {
|
||||||
this._modelAlias = changed.modelAlias;
|
this._modelAlias = changed.modelAlias;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
type CompactionResult,
|
type CompactionResult,
|
||||||
} from '../compaction';
|
} from '../compaction';
|
||||||
import {
|
import {
|
||||||
|
degradeOlderMediaParts,
|
||||||
|
MEDIA_DEGRADE_KEEP_RECENT,
|
||||||
project,
|
project,
|
||||||
type ProjectionAnomaly,
|
type ProjectionAnomaly,
|
||||||
type ProjectOptions,
|
type ProjectOptions,
|
||||||
|
|
@ -488,6 +490,17 @@ export class ContextMemory {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback projection for the post-413 media-degraded resend: the normal
|
||||||
|
// wire projection with all but the most recent media parts replaced by text
|
||||||
|
// markers, so a request body bloated by accumulated base64 media fits the
|
||||||
|
// provider's size limit. Purely read-side — the history keeps its media —
|
||||||
|
// and only used when the provider has already rejected the normal
|
||||||
|
// projection as too large; see the request-too-large fallback in
|
||||||
|
// `turn-step`.
|
||||||
|
get mediaDegradedMessages(): Message[] {
|
||||||
|
return degradeOlderMediaParts(this.messages, MEDIA_DEGRADE_KEEP_RECENT);
|
||||||
|
}
|
||||||
|
|
||||||
useProjectedHistoryFrom(source: ContextMemory): void {
|
useProjectedHistoryFrom(source: ContextMemory): void {
|
||||||
this.clear();
|
this.clear();
|
||||||
this.pushHistory(...trimTrailingOpenToolExchange(source.project(source.history)));
|
this.pushHistory(...trimTrailingOpenToolExchange(source.project(source.history)));
|
||||||
|
|
|
||||||
|
|
@ -465,3 +465,58 @@ export function trimTrailingOpenToolExchange(history: readonly Message[]): Messa
|
||||||
const closed = assistant.toolCalls.every((toolCall) => trailingToolCallIds.has(toolCall.id));
|
const closed = assistant.toolCalls.every((toolCall) => trailingToolCallIds.has(toolCall.id));
|
||||||
return closed ? [...history] : history.slice(0, lastNonToolIndex);
|
return closed ? [...history] : history.slice(0, lastNonToolIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of the most recent media parts survive the media-degraded
|
||||||
|
* projection. The tail images are what the model is actively working from
|
||||||
|
* (the screenshot it just took); everything older is replaced by a marker.
|
||||||
|
*/
|
||||||
|
export const MEDIA_DEGRADE_KEEP_RECENT = 2;
|
||||||
|
|
||||||
|
const MEDIA_DEGRADED_PLACEHOLDERS = {
|
||||||
|
image_url:
|
||||||
|
'[image omitted: dropped to fit the provider request size limit; re-read the file to view it]',
|
||||||
|
audio_url:
|
||||||
|
'[audio omitted: dropped to fit the provider request size limit; re-read the file to hear it]',
|
||||||
|
video_url:
|
||||||
|
'[video omitted: dropped to fit the provider request size limit; re-read the file to view it]',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function isDegradableMediaPart(
|
||||||
|
part: ContentPart,
|
||||||
|
): part is ContentPart & { type: keyof typeof MEDIA_DEGRADED_PLACEHOLDERS } {
|
||||||
|
return part.type in MEDIA_DEGRADED_PLACEHOLDERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all but the `keepRecent` most recent media parts with deterministic
|
||||||
|
* text markers. This is the media-degraded projection used to resend a request
|
||||||
|
* the provider rejected as too large (HTTP 413 on accumulated base64 media):
|
||||||
|
* a purely read-side transform — the underlying history is left untouched —
|
||||||
|
* that trades old pixels for bytes while the surrounding text (including
|
||||||
|
* ReadMediaFile's `<image path="...">` wrapper) survives, so the model can
|
||||||
|
* re-read any file it still needs. Untouched messages are returned by
|
||||||
|
* reference, and when nothing needs degrading the input array itself is
|
||||||
|
* returned.
|
||||||
|
*/
|
||||||
|
export function degradeOlderMediaParts(
|
||||||
|
messages: readonly Message[],
|
||||||
|
keepRecent: number,
|
||||||
|
): Message[] {
|
||||||
|
const mediaCount = messages.reduce(
|
||||||
|
(count, message) => count + message.content.filter(isDegradableMediaPart).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let toDegrade = Math.max(0, mediaCount - keepRecent);
|
||||||
|
if (toDegrade === 0) return messages as Message[];
|
||||||
|
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (toDegrade === 0 || !message.content.some(isDegradableMediaPart)) return message;
|
||||||
|
const content = message.content.map((part): ContentPart => {
|
||||||
|
if (toDegrade === 0 || !isDegradableMediaPart(part)) return part;
|
||||||
|
toDegrade -= 1;
|
||||||
|
return { type: 'text', text: MEDIA_DEGRADED_PLACEHOLDERS[part.type] };
|
||||||
|
});
|
||||||
|
return { ...message, content };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type { PluginCommandOrigin } from './context';
|
||||||
|
|
||||||
import type { McpConnectionManager } from '../mcp';
|
import type { McpConnectionManager } from '../mcp';
|
||||||
import { FlagResolver, type ExperimentalFlagResolver } from '../flags';
|
import { FlagResolver, type ExperimentalFlagResolver } from '../flags';
|
||||||
|
import { ImageLimits } from '../tools/support/image-limits';
|
||||||
import {
|
import {
|
||||||
prepareSystemPromptContext,
|
prepareSystemPromptContext,
|
||||||
type PreparedSystemPromptContext,
|
type PreparedSystemPromptContext,
|
||||||
|
|
@ -96,6 +97,8 @@ export interface AgentOptions {
|
||||||
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
|
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
|
||||||
readonly pluginCommands?: readonly PluginCommandDef[];
|
readonly pluginCommands?: readonly PluginCommandDef[];
|
||||||
readonly experimentalFlags?: ExperimentalFlagResolver;
|
readonly experimentalFlags?: ExperimentalFlagResolver;
|
||||||
|
/** Owner-scoped [image] limits; a standalone Agent gets env/built-in defaults. */
|
||||||
|
readonly imageLimits?: ImageLimits;
|
||||||
readonly replay?: ReplayBuilderOptions;
|
readonly replay?: ReplayBuilderOptions;
|
||||||
readonly additionalDirs?: readonly string[];
|
readonly additionalDirs?: readonly string[];
|
||||||
readonly systemPromptContextProvider?: (() => Promise<PreparedSystemPromptContext>) | undefined;
|
readonly systemPromptContextProvider?: (() => Promise<PreparedSystemPromptContext>) | undefined;
|
||||||
|
|
@ -124,6 +127,7 @@ export class Agent {
|
||||||
readonly log: Logger;
|
readonly log: Logger;
|
||||||
readonly telemetry: TelemetryClient;
|
readonly telemetry: TelemetryClient;
|
||||||
readonly experimentalFlags: ExperimentalFlagResolver;
|
readonly experimentalFlags: ExperimentalFlagResolver;
|
||||||
|
readonly imageLimits: ImageLimits;
|
||||||
|
|
||||||
readonly llmRequestLogger: LlmRequestLogger;
|
readonly llmRequestLogger: LlmRequestLogger;
|
||||||
readonly llmRequestRecorder: LlmRequestRecorder;
|
readonly llmRequestRecorder: LlmRequestRecorder;
|
||||||
|
|
@ -178,6 +182,7 @@ export class Agent {
|
||||||
this.log = options.log ?? log;
|
this.log = options.log ?? log;
|
||||||
this.telemetry = options.telemetry ?? noopTelemetryClient;
|
this.telemetry = options.telemetry ?? noopTelemetryClient;
|
||||||
this.experimentalFlags = options.experimentalFlags ?? new FlagResolver();
|
this.experimentalFlags = options.experimentalFlags ?? new FlagResolver();
|
||||||
|
this.imageLimits = options.imageLimits ?? new ImageLimits();
|
||||||
this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []);
|
this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []);
|
||||||
this.systemPromptContextProvider = options.systemPromptContextProvider;
|
this.systemPromptContextProvider = options.systemPromptContextProvider;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -177,8 +177,9 @@ export interface AgentRecordEvents {
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
turnStep?: string;
|
turnStep?: string;
|
||||||
attempt?: string;
|
attempt?: string;
|
||||||
/** Set when this request is the strict wire-compliant rebuild resend. */
|
/** Set when this request is a fallback resend (strict rebuild or
|
||||||
projection?: 'strict';
|
* media-degraded rebuild). */
|
||||||
|
projection?: 'strict' | 'media-degraded';
|
||||||
/** Compaction only: messages dropped so far by overflow/empty shrinking. */
|
/** Compaction only: messages dropped so far by overflow/empty shrinking. */
|
||||||
droppedCount?: number;
|
droppedCount?: number;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,8 @@ export class ToolManager {
|
||||||
return mcpResultToExecutableOutput(result, qualified, {
|
return mcpResultToExecutableOutput(result, qualified, {
|
||||||
originalsDir: this.agent.mediaOriginalsDir,
|
originalsDir: this.agent.mediaOriginalsDir,
|
||||||
telemetry: this.agent.telemetry,
|
telemetry: this.agent.telemetry,
|
||||||
|
// Resolved per call so a config reload applies immediately.
|
||||||
|
maxImageEdgePx: this.agent.imageLimits?.maxEdgePx(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -706,6 +708,7 @@ export class ToolManager {
|
||||||
modelCapabilities,
|
modelCapabilities,
|
||||||
videoUploader,
|
videoUploader,
|
||||||
this.agent.telemetry,
|
this.agent.telemetry,
|
||||||
|
this.agent.imageLimits,
|
||||||
),
|
),
|
||||||
new b.EnterPlanModeTool(this.agent),
|
new b.EnterPlanModeTool(this.agent),
|
||||||
new b.ExitPlanModeTool(this.agent),
|
new b.ExitPlanModeTool(this.agent),
|
||||||
|
|
|
||||||
|
|
@ -724,6 +724,7 @@ export class TurnFlow {
|
||||||
llm: this.agent.llm,
|
llm: this.agent.llm,
|
||||||
buildMessages: () => this.agent.context.messages,
|
buildMessages: () => this.agent.context.messages,
|
||||||
buildMessagesStrict: () => this.agent.context.strictMessages,
|
buildMessagesStrict: () => this.agent.context.strictMessages,
|
||||||
|
buildMessagesMediaDegraded: () => this.agent.context.mediaDegradedMessages,
|
||||||
dispatchEvent: this.buildDispatchEvent(turnId),
|
dispatchEvent: this.buildDispatchEvent(turnId),
|
||||||
// Re-read per step (not snapshotted per turn) so a select_tools load
|
// Re-read per step (not snapshotted per turn) so a select_tools load
|
||||||
// is dispatchable on the very next step of the same turn.
|
// is dispatchable on the very next step of the same turn.
|
||||||
|
|
|
||||||
|
|
@ -7,32 +7,28 @@ import { canonicalTelemetryArgs } from './canonical-args';
|
||||||
|
|
||||||
const REMINDER_TEXT_1 =
|
const REMINDER_TEXT_1 =
|
||||||
'\n\n<system-reminder>\n' +
|
'\n\n<system-reminder>\n' +
|
||||||
'You are repeating the exact same tool call with identical parameters.' +
|
'The same tool call has been repeated several times in a row. ' +
|
||||||
' Please carefully analyze the previous result. If the task is not yet complete,' +
|
'Before making your next call, write one sentence stating what new information you expect it to produce. ' +
|
||||||
' try a different method or parameters instead of repeating the same call.' +
|
'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.' +
|
||||||
'\n</system-reminder>';
|
'\n</system-reminder>';
|
||||||
|
|
||||||
function makeReminderText2(toolName: string, repeatCount: number, args: unknown): string {
|
function makeReminderText2(repeatCount: number): string {
|
||||||
const argsStr = canonicalTelemetryArgs(args);
|
|
||||||
return (
|
return (
|
||||||
'\n\n<system-reminder>\n' +
|
'\n\n<system-reminder>\n' +
|
||||||
'You have repeatedly called the same tool with identical parameters many times.\n' +
|
`The same tool call has now been issued ${String(repeatCount)} times in a row. ` +
|
||||||
'Repeated tool call detected:\n' +
|
'Choose exactly one of the following and state your choice before acting:\n' +
|
||||||
`- tool: ${toolName}\n` +
|
'(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' +
|
||||||
`- repeated_times: ${String(repeatCount)}\n` +
|
'(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' +
|
||||||
`- arguments: ${argsStr}\n` +
|
'(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.' +
|
||||||
'The previous repeated calls did not make progress. Do not call this exact same tool with the exact same arguments again.\n' +
|
|
||||||
'Carefully inspect the latest tool result and choose a different next action, different parameters, or finish the task if enough evidence has been gathered.' +
|
|
||||||
'\n</system-reminder>'
|
'\n</system-reminder>'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const REMINDER_TEXT_3 =
|
const REMINDER_TEXT_3 =
|
||||||
'\n\n<system-reminder>\n' +
|
'\n\n<system-reminder>\n' +
|
||||||
'You are stuck in a dead end and have repeatedly made the same function call without progress.\n' +
|
'Write your final response now, without any further tool calls. ' +
|
||||||
'Stop all function calls immediately. Do not call any tool in your next response.\n' +
|
'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' +
|
||||||
'In analysis, review the current execution state and identify why progress is blocked.\n' +
|
'Text only.' +
|
||||||
'Then return a text-only summary to the user that reports the current problem, what has already been tried, and what information or decision is needed next.' +
|
|
||||||
'\n</system-reminder>';
|
'\n</system-reminder>';
|
||||||
|
|
||||||
const REPEAT_REMINDER_1_START = 3;
|
const REPEAT_REMINDER_1_START = 3;
|
||||||
|
|
@ -105,8 +101,8 @@ const DEDUP_PLACEHOLDER_RESULT: ExecutableToolResult = { output: '' };
|
||||||
* - Cross-step dedup: when the exact same call is repeated consecutively
|
* - Cross-step dedup: when the exact same call is repeated consecutively
|
||||||
* across steps, the result returned to the model is suffixed with a system
|
* across steps, the result returned to the model is suffixed with a system
|
||||||
* reminder once the streak hits 3. The reminder escalates as the streak
|
* reminder once the streak hits 3. The reminder escalates as the streak
|
||||||
* grows: r1 (gentle nudge) from streak 3, r2 (concrete repeat report) from
|
* grows: r1 (expectation-setting nudge) from streak 3, r2 (forced decision
|
||||||
* streak 5, r3 (dead-end stop instruction) from streak 8. From streak 12
|
* menu) from streak 5, r3 (final hand-off instruction) from streak 8. From streak 12
|
||||||
* onward the turn is force-stopped via `{ stopTurn: true }` so the loop
|
* onward the turn is force-stopped via `{ stopTurn: true }` so the loop
|
||||||
* cannot keep spinning on the same call. Force-stop does not flip a
|
* cannot keep spinning on the same call. Force-stop does not flip a
|
||||||
* successful tool result into an error — the underlying tool's `isError`
|
* successful tool result into an error — the underlying tool's `isError`
|
||||||
|
|
@ -239,7 +235,7 @@ export class ToolCallDeduplicator {
|
||||||
finalResult = appendReminder(result, REMINDER_TEXT_3);
|
finalResult = appendReminder(result, REMINDER_TEXT_3);
|
||||||
action = 'r3';
|
action = 'r3';
|
||||||
} else if (streak >= REPEAT_REMINDER_2_START) {
|
} else if (streak >= REPEAT_REMINDER_2_START) {
|
||||||
finalResult = appendReminder(result, makeReminderText2(toolName, streak, args));
|
finalResult = appendReminder(result, makeReminderText2(streak));
|
||||||
action = 'r2';
|
action = 'r2';
|
||||||
} else if (streak >= REPEAT_REMINDER_1_START) {
|
} else if (streak >= REPEAT_REMINDER_1_START) {
|
||||||
finalResult = appendReminder(result, REMINDER_TEXT_1);
|
finalResult = appendReminder(result, REMINDER_TEXT_1);
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,25 @@ export const BackgroundConfigSchema = z.object({
|
||||||
|
|
||||||
export type BackgroundConfig = z.infer<typeof BackgroundConfigSchema>;
|
export type BackgroundConfig = z.infer<typeof BackgroundConfigSchema>;
|
||||||
|
|
||||||
|
export const ImageConfigSchema = z.object({
|
||||||
|
/**
|
||||||
|
* Longest-edge ceiling (px) applied when compressing images for the model.
|
||||||
|
* Overrides the built-in default; the KIMI_IMAGE_MAX_EDGE_PX env var wins
|
||||||
|
* over this value.
|
||||||
|
*/
|
||||||
|
maxEdgePx: z.number().int().min(1).optional(),
|
||||||
|
/**
|
||||||
|
* Raw-byte budget for images the model reads for itself (ReadMediaFile's
|
||||||
|
* default path). Overrides the built-in default; the
|
||||||
|
* KIMI_IMAGE_READ_BYTE_BUDGET env var wins over this value. Explicit
|
||||||
|
* region / full_resolution reads use the provider-scale per-image limit
|
||||||
|
* instead.
|
||||||
|
*/
|
||||||
|
readByteBudget: z.number().int().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ImageConfig = z.infer<typeof ImageConfigSchema>;
|
||||||
|
|
||||||
export const ModelCatalogConfigSchema = z.object({
|
export const ModelCatalogConfigSchema = z.object({
|
||||||
/** Interval (ms) between automatic provider-model refreshes. `0` disables. */
|
/** Interval (ms) between automatic provider-model refreshes. `0` disables. */
|
||||||
refreshIntervalMs: z.number().int().min(0).optional(),
|
refreshIntervalMs: z.number().int().min(0).optional(),
|
||||||
|
|
@ -256,6 +275,7 @@ export const KimiConfigSchema = z.object({
|
||||||
extraSkillDirs: z.array(z.string()).optional(),
|
extraSkillDirs: z.array(z.string()).optional(),
|
||||||
loopControl: LoopControlSchema.optional(),
|
loopControl: LoopControlSchema.optional(),
|
||||||
background: BackgroundConfigSchema.optional(),
|
background: BackgroundConfigSchema.optional(),
|
||||||
|
image: ImageConfigSchema.optional(),
|
||||||
modelCatalog: ModelCatalogConfigSchema.optional(),
|
modelCatalog: ModelCatalogConfigSchema.optional(),
|
||||||
experimental: ExperimentalConfigSchema.optional(),
|
experimental: ExperimentalConfigSchema.optional(),
|
||||||
telemetry: z.boolean().optional(),
|
telemetry: z.boolean().optional(),
|
||||||
|
|
@ -270,6 +290,7 @@ const ThinkingConfigPatchSchema = ThinkingConfigSchema.partial();
|
||||||
const PermissionConfigPatchSchema = PermissionConfigSchema.partial();
|
const PermissionConfigPatchSchema = PermissionConfigSchema.partial();
|
||||||
const LoopControlPatchSchema = LoopControlSchema.partial();
|
const LoopControlPatchSchema = LoopControlSchema.partial();
|
||||||
const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial();
|
const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial();
|
||||||
|
const ImageConfigPatchSchema = ImageConfigSchema.partial();
|
||||||
const ModelCatalogConfigPatchSchema = ModelCatalogConfigSchema.partial();
|
const ModelCatalogConfigPatchSchema = ModelCatalogConfigSchema.partial();
|
||||||
const ExperimentalConfigPatchSchema = ExperimentalConfigSchema;
|
const ExperimentalConfigPatchSchema = ExperimentalConfigSchema;
|
||||||
const MoonshotServiceConfigPatchSchema = MoonshotServiceConfigSchema.partial();
|
const MoonshotServiceConfigPatchSchema = MoonshotServiceConfigSchema.partial();
|
||||||
|
|
@ -296,6 +317,7 @@ export const KimiConfigPatchSchema = z
|
||||||
extraSkillDirs: z.array(z.string()).optional(),
|
extraSkillDirs: z.array(z.string()).optional(),
|
||||||
loopControl: LoopControlPatchSchema.optional(),
|
loopControl: LoopControlPatchSchema.optional(),
|
||||||
background: BackgroundConfigPatchSchema.optional(),
|
background: BackgroundConfigPatchSchema.optional(),
|
||||||
|
image: ImageConfigPatchSchema.optional(),
|
||||||
modelCatalog: ModelCatalogConfigPatchSchema.optional(),
|
modelCatalog: ModelCatalogConfigPatchSchema.optional(),
|
||||||
experimental: ExperimentalConfigPatchSchema.optional(),
|
experimental: ExperimentalConfigPatchSchema.optional(),
|
||||||
telemetry: z.boolean().optional(),
|
telemetry: z.boolean().optional(),
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue