mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Merge origin/main into feat/web
# Conflicts: # apps/kimi-code/package.json # apps/kimi-code/tsdown.config.ts # pnpm-lock.yaml
This commit is contained in:
commit
e319ead0ca
146 changed files with 4383 additions and 601 deletions
|
|
@ -1,6 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Clarify that compaction summaries must be emitted in the final answer.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Read media files using header-detected types before falling back to media extensions.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core": minor
|
||||
"@moonshot-ai/acp-adapter": minor
|
||||
"@moonshot-ai/protocol": minor
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports.
|
||||
|
|
@ -1,5 +1,49 @@
|
|||
# @moonshot-ai/kimi-code
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#779](https://github.com/MoonshotAI/kimi-code/pull/779) [`2746c71`](https://github.com/MoonshotAI/kimi-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Add an all-sessions picker view with name search, paginated browsing, and clipboard-ready resume commands for sessions in other working directories.
|
||||
|
||||
- [#744](https://github.com/MoonshotAI/kimi-code/pull/744) [`18f299f`](https://github.com/MoonshotAI/kimi-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e) - Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Clarify AGENTS.md prompt guidance and mark truncated instruction files.
|
||||
|
||||
- [#780](https://github.com/MoonshotAI/kimi-code/pull/780) [`8a92db6`](https://github.com/MoonshotAI/kimi-code/commit/8a92db6a0c110a21c6e6e86622f498e836178e5f) - Prompt the CLI to show one brief same-language status sentence before non-trivial tool calls.
|
||||
|
||||
- [#786](https://github.com/MoonshotAI/kimi-code/pull/786) [`e10b25f`](https://github.com/MoonshotAI/kimi-code/commit/e10b25f9be18ca64aada0d0a3cab0e02fdbd46df) - Stop writing resume version markers into persisted agent metadata.
|
||||
|
||||
- [#768](https://github.com/MoonshotAI/kimi-code/pull/768) [`c6a9967`](https://github.com/MoonshotAI/kimi-code/commit/c6a996756cd8f1fb317b6eee6f4e668eebc7dc14) - Recover resumed sessions when an interrupted tool call result was not recorded.
|
||||
|
||||
- [#775](https://github.com/MoonshotAI/kimi-code/pull/775) [`3fa1b8e`](https://github.com/MoonshotAI/kimi-code/commit/3fa1b8ea7deb558b88073b5f7b02857e52c3f60c) - Optimize the npm packaging system.
|
||||
|
||||
- [#343](https://github.com/MoonshotAI/kimi-code/pull/343) [`73be7ba`](https://github.com/MoonshotAI/kimi-code/commit/73be7ba17d41df7999d4c1fba410994e7024eb7b) - Repair mismatched JSON Schema types emitted by Xcode 26.5 MCP server for Moonshot compatibility.
|
||||
|
||||
- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Collapse hidden directories in the workspace prompt and explain how to inspect them.
|
||||
|
||||
- [#766](https://github.com/MoonshotAI/kimi-code/pull/766) [`9cef896`](https://github.com/MoonshotAI/kimi-code/commit/9cef89656311974a57e6675f474ea6c2adb1d8e9) - Clarify that compaction summaries must be emitted in the final answer.
|
||||
|
||||
- [#765](https://github.com/MoonshotAI/kimi-code/pull/765) [`046856b`](https://github.com/MoonshotAI/kimi-code/commit/046856b740afb604132e914f1fc489de72394036) - Read media files using header-detected types before falling back to media extensions.
|
||||
|
||||
- [#779](https://github.com/MoonshotAI/kimi-code/pull/779) [`2746c71`](https://github.com/MoonshotAI/kimi-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Show the all-sessions toggle hint when the current working directory has no sessions.
|
||||
|
||||
- [#785](https://github.com/MoonshotAI/kimi-code/pull/785) [`4578f05`](https://github.com/MoonshotAI/kimi-code/commit/4578f05f44101f24d45c6452e2a6993cbb52e331) - Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked.
|
||||
|
||||
- [#784](https://github.com/MoonshotAI/kimi-code/pull/784) [`a562ef5`](https://github.com/MoonshotAI/kimi-code/commit/a562ef54e537a36211c48f0fe19e9252e83397a0) - Decouple agent skill access from session-specific registry implementations.
|
||||
|
||||
- [#772](https://github.com/MoonshotAI/kimi-code/pull/772) [`d47e699`](https://github.com/MoonshotAI/kimi-code/commit/d47e699015f02f4f76723aa8fb17d51a74aa74ff) - Do not carry obsolete legacy loop, background, plan, yolo, or unknown experimental flags into migrated config files.
|
||||
|
||||
- [#783](https://github.com/MoonshotAI/kimi-code/pull/783) [`e2a407c`](https://github.com/MoonshotAI/kimi-code/commit/e2a407ce31685220b2f891a7f6d8b89c62418c98) - Keep TUI components within narrow terminal widths by wrapping, compacting, or truncating lines that could exceed the render width.
|
||||
|
||||
- [#776](https://github.com/MoonshotAI/kimi-code/pull/776) [`ecd7a0a`](https://github.com/MoonshotAI/kimi-code/commit/ecd7a0afb646d14a14c780a4088fd8a59da134ad) - Resolve model capabilities through a static lookup instead of instantiating a temporary provider.
|
||||
|
||||
- [#767](https://github.com/MoonshotAI/kimi-code/pull/767) [`a355f2a`](https://github.com/MoonshotAI/kimi-code/commit/a355f2af2fd68ad9e2bdc72ce854cd18c8242ce8) - Prioritize clearing draft editor text before Ctrl-C cancels an active stream.
|
||||
|
||||
- [#787](https://github.com/MoonshotAI/kimi-code/pull/787) [`1eb363f`](https://github.com/MoonshotAI/kimi-code/commit/1eb363f655aa44abc1e5c3af89016f00764ecc95) - Extend the same-language rule to the model's reasoning, so thinking follows the user's language while keeping code and technical terms in their original form.
|
||||
|
||||
## 0.14.3
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@moonshot-ai/kimi-code",
|
||||
"version": "0.14.3",
|
||||
"version": "0.15.0",
|
||||
"description": "The Starting Point for Next-Gen Agents",
|
||||
"license": "MIT",
|
||||
"author": "Moonshot AI",
|
||||
|
|
@ -71,12 +71,12 @@
|
|||
"e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts",
|
||||
"postinstall": "node scripts/postinstall.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-tui": "^0.74.0",
|
||||
"optionalDependencies": {
|
||||
"@mariozechner/clipboard": "^0.3.2",
|
||||
"chalk": "^5.4.1",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^13.1.0",
|
||||
"koffi": "^2.16.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pino-pretty": "^13.0.0",
|
||||
|
|
@ -85,6 +85,7 @@
|
|||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-tui": "^0.74.0",
|
||||
"@moonshot-ai/acp-adapter": "workspace:^",
|
||||
"@moonshot-ai/kimi-code-oauth": "workspace:^",
|
||||
"@moonshot-ai/kimi-code-sdk": "workspace:^",
|
||||
|
|
@ -94,9 +95,16 @@
|
|||
"@moonshot-ai/server": "workspace:^",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/yazl": "^2.4.6",
|
||||
"chalk": "^5.4.1",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^13.1.0",
|
||||
"pathe": "^2.0.3",
|
||||
"postject": "1.0.0-alpha.6",
|
||||
"semver": "^7.7.4",
|
||||
"smol-toml": "^1.6.1",
|
||||
"tsx": "^4.21.0",
|
||||
"yazl": "^3.3.1"
|
||||
"yazl": "^3.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
|
|
|
|||
|
|
@ -3,13 +3,20 @@ import { z } from 'zod';
|
|||
import { getUpdateStateFile } from '#/utils/paths';
|
||||
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
|
||||
|
||||
import { UpdateManifestSchema } from './cdn';
|
||||
import { emptyUpdateCache, type UpdateCache } from './types';
|
||||
|
||||
const UpdateCacheSchema: z.ZodType<UpdateCache> = z
|
||||
// Stays `.strict()` (we own this file), but a malformed manifest is treated
|
||||
// as no manifest so one bad optional field does not discard the whole cache.
|
||||
const UpdateCacheSchema = z
|
||||
.object({
|
||||
source: z.literal('cdn'),
|
||||
checkedAt: z.string().min(1).nullable(),
|
||||
latest: z.string().min(1).nullable(),
|
||||
manifest: z.preprocess((value) => {
|
||||
const parsed = UpdateManifestSchema.nullable().safeParse(value === undefined ? null : value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}, z.union([UpdateManifestSchema, z.null()])),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,49 @@
|
|||
import { valid } from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
|
||||
import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
|
||||
|
||||
import type { UpdateManifest } from './types';
|
||||
|
||||
const CDN_FETCH_TIMEOUT_MS = 3_000;
|
||||
|
||||
const RolloutBatchSchema = z.object({
|
||||
percent: z.number().int().min(0).max(100),
|
||||
delaySeconds: z.number().int().min(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown
|
||||
* fields are ignored so future manifest additions never break shipped
|
||||
* clients (the plain-text `/latest` taught us that hard-failing on
|
||||
* unexpected content bricks the update path forever).
|
||||
*/
|
||||
export const UpdateManifestSchema = z.object({
|
||||
version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }),
|
||||
publishedAt: z
|
||||
.string()
|
||||
.refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }),
|
||||
rollout: z.array(RolloutBatchSchema).readonly().default([]),
|
||||
});
|
||||
|
||||
export interface FetchLatestResult {
|
||||
/** Raw newest version — what `kimi upgrade` installs, never rollout-gated. */
|
||||
readonly latest: string;
|
||||
/** Null when the JSON manifest was unavailable and we fell back to plain text. */
|
||||
readonly manifest: UpdateManifest | null;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, CDN_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetchImpl(input, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest published Kimi Code version from the CDN.
|
||||
|
|
@ -15,7 +58,7 @@ import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
|
|||
export async function fetchLatestVersionFromCdn(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL);
|
||||
const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`CDN /latest returned HTTP ${response.status}`);
|
||||
}
|
||||
|
|
@ -25,3 +68,30 @@ export async function fetchLatestVersionFromCdn(
|
|||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise<UpdateManifest> {
|
||||
const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_JSON_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`CDN /latest.json returned HTTP ${response.status}`);
|
||||
}
|
||||
return UpdateManifestSchema.parse(JSON.parse(await response.text()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the rollout manifest, falling back to the plain-text `/latest` when
|
||||
* `latest.json` is unavailable or malformed. The fallback removes any
|
||||
* deployment-order coupling between client releases and the CDN file, and a
|
||||
* null manifest means "fully rolled out" — exactly the pre-rollout behavior.
|
||||
*
|
||||
* **Throws** only when both sources fail; callers must catch (see above).
|
||||
*/
|
||||
export async function fetchLatestFromCdn(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<FetchLatestResult> {
|
||||
const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null);
|
||||
if (manifest !== null) {
|
||||
return { latest: manifest.version, manifest };
|
||||
}
|
||||
const latest = await fetchLatestVersionFromCdn(fetchImpl);
|
||||
return { latest, manifest: null };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,13 +19,22 @@ import {
|
|||
type InstallPromptOptions,
|
||||
} from './prompt';
|
||||
import { refreshUpdateCache } from './refresh';
|
||||
import { selectUpdateTarget } from './select';
|
||||
import {
|
||||
appendRolloutDecisionLog,
|
||||
decidePassiveUpdateTarget,
|
||||
isRolloutBypassedByExperimentalEnv,
|
||||
resolveUpdateDeviceId,
|
||||
rolloutBucket,
|
||||
rolloutDelayForBucket,
|
||||
type PassiveUpdateDecision,
|
||||
} from './rollout';
|
||||
import { detectInstallSource } from './source';
|
||||
import {
|
||||
NPM_PACKAGE_NAME,
|
||||
type InstallSource,
|
||||
type UpdateDecision,
|
||||
type UpdateInstallState,
|
||||
type UpdateManifest,
|
||||
type UpdatePreflightResult,
|
||||
type UpdateTarget,
|
||||
} from './types';
|
||||
|
|
@ -177,8 +186,59 @@ function refreshInBackground(): void {
|
|||
void refreshUpdateCache().catch(() => {});
|
||||
}
|
||||
|
||||
/** Telemetry properties describing where this device sits in the rollout. */
|
||||
interface RolloutTelemetry {
|
||||
readonly rollout_bucket: number;
|
||||
readonly rollout_delay_seconds: number;
|
||||
readonly rollout_from_manifest: boolean;
|
||||
readonly rollout_bypassed: boolean;
|
||||
}
|
||||
|
||||
function rolloutTelemetryFor(
|
||||
deviceId: string,
|
||||
targetVersion: string,
|
||||
manifest: UpdateManifest | null,
|
||||
bypassRollout: boolean,
|
||||
): RolloutTelemetry {
|
||||
const bucket = rolloutBucket(deviceId, targetVersion);
|
||||
return {
|
||||
rollout_bucket: bucket,
|
||||
rollout_delay_seconds:
|
||||
manifest === null || bypassRollout ? 0 : rolloutDelayForBucket(manifest.rollout, bucket),
|
||||
rollout_from_manifest: manifest !== null,
|
||||
rollout_bypassed: bypassRollout,
|
||||
};
|
||||
}
|
||||
|
||||
type RolloutCheckPhase = 'startup-cache' | 'background-refresh' | 'prompt-refresh';
|
||||
|
||||
/** Record which case a passive version check hit in `updates/rollout.log`. */
|
||||
function logRolloutDecision(
|
||||
phase: RolloutCheckPhase,
|
||||
currentVersion: string,
|
||||
latest: string | null,
|
||||
manifest: UpdateManifest | null,
|
||||
decision: PassiveUpdateDecision,
|
||||
): void {
|
||||
void appendRolloutDecisionLog({
|
||||
ts: nowIso(),
|
||||
phase,
|
||||
reason: decision.reason,
|
||||
current: currentVersion,
|
||||
latest,
|
||||
target: decision.target?.version ?? null,
|
||||
manifestPresent: manifest !== null,
|
||||
publishedAt: manifest?.publishedAt ?? null,
|
||||
bucket: decision.bucket,
|
||||
delaySeconds: decision.delaySeconds,
|
||||
eligibleAt: decision.eligibleAt,
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAndMaybeInstallInBackground(
|
||||
currentVersion: string,
|
||||
deviceId: string,
|
||||
bypassRollout: boolean,
|
||||
isInteractive: boolean,
|
||||
installState: UpdateInstallState,
|
||||
platform: NodeJS.Platform,
|
||||
|
|
@ -188,7 +248,16 @@ function refreshAndMaybeInstallInBackground(
|
|||
void (async () => {
|
||||
const refreshed = await refreshUpdateCache();
|
||||
if (!isInteractive) return;
|
||||
const target = selectUpdateTarget(currentVersion, refreshed.latest);
|
||||
const decision = decidePassiveUpdateTarget(
|
||||
currentVersion,
|
||||
refreshed.latest,
|
||||
refreshed.manifest,
|
||||
deviceId,
|
||||
new Date(),
|
||||
bypassRollout,
|
||||
);
|
||||
logRolloutDecision('background-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
|
||||
const target = decision.target;
|
||||
if (target === null) return;
|
||||
const source = await detectInstallSource().catch(() => 'unsupported' as const);
|
||||
await tryStartAutomaticBackgroundInstall(
|
||||
|
|
@ -199,27 +268,54 @@ function refreshAndMaybeInstallInBackground(
|
|||
platform,
|
||||
track,
|
||||
logger,
|
||||
rolloutTelemetryFor(deviceId, target.version, refreshed.manifest, bypassRollout),
|
||||
);
|
||||
})().catch(() => {});
|
||||
}
|
||||
|
||||
interface UserVisibleUpdateTarget {
|
||||
readonly target: UpdateTarget | null;
|
||||
readonly manifest: UpdateManifest | null;
|
||||
}
|
||||
|
||||
async function refreshUserVisibleUpdateTarget(
|
||||
currentVersion: string,
|
||||
deviceId: string,
|
||||
bypassRollout: boolean,
|
||||
fallbackTarget: UpdateTarget,
|
||||
): Promise<UpdateTarget | null> {
|
||||
fallbackManifest: UpdateManifest | null,
|
||||
): Promise<UserVisibleUpdateTarget> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const fallback: UserVisibleUpdateTarget = {
|
||||
target: fallbackTarget,
|
||||
manifest: fallbackManifest,
|
||||
};
|
||||
try {
|
||||
const refresh = refreshUpdateCache()
|
||||
.then((refreshed) => selectUpdateTarget(currentVersion, refreshed.latest))
|
||||
.catch(() => fallbackTarget);
|
||||
const fallback = new Promise<UpdateTarget>((resolve) => {
|
||||
.then((refreshed) => {
|
||||
const decision = decidePassiveUpdateTarget(
|
||||
currentVersion,
|
||||
refreshed.latest,
|
||||
refreshed.manifest,
|
||||
deviceId,
|
||||
new Date(),
|
||||
bypassRollout,
|
||||
);
|
||||
logRolloutDecision('prompt-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
|
||||
return {
|
||||
target: decision.target,
|
||||
manifest: refreshed.manifest,
|
||||
};
|
||||
})
|
||||
.catch(() => fallback);
|
||||
const timeoutFallback = new Promise<UserVisibleUpdateTarget>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
resolve(fallbackTarget);
|
||||
resolve(fallback);
|
||||
}, USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS);
|
||||
});
|
||||
return await Promise.race([refresh, fallback]);
|
||||
return await Promise.race([refresh, timeoutFallback]);
|
||||
} catch {
|
||||
return fallbackTarget;
|
||||
return fallback;
|
||||
} finally {
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
|
|
@ -331,6 +427,7 @@ function trackUpdatePrompted(
|
|||
target: UpdateTarget,
|
||||
source: InstallSource,
|
||||
decision: UpdateDecision,
|
||||
rolloutTelemetry: RolloutTelemetry,
|
||||
): void {
|
||||
trackUpdateEvent(track, 'update_prompted', {
|
||||
current: currentVersion,
|
||||
|
|
@ -339,6 +436,7 @@ function trackUpdatePrompted(
|
|||
target_version: target.version,
|
||||
source,
|
||||
decision,
|
||||
...rolloutTelemetry,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -413,6 +511,7 @@ async function startBackgroundInstall(
|
|||
platform: NodeJS.Platform,
|
||||
track: RunUpdatePreflightOptions['track'],
|
||||
logger: UpdateLogger,
|
||||
rolloutTelemetry: RolloutTelemetry,
|
||||
): Promise<void> {
|
||||
const lock = await tryAcquireUpdateInstallLock({ version: target.version });
|
||||
if (lock === null) return;
|
||||
|
|
@ -439,6 +538,7 @@ async function startBackgroundInstall(
|
|||
current_version: currentVersion,
|
||||
target_version: target.version,
|
||||
source,
|
||||
...rolloutTelemetry,
|
||||
});
|
||||
logUpdateInfo(logger, 'background update install started', {
|
||||
currentVersion,
|
||||
|
|
@ -515,6 +615,7 @@ async function tryStartAutomaticBackgroundInstall(
|
|||
platform: NodeJS.Platform,
|
||||
track: RunUpdatePreflightOptions['track'],
|
||||
logger: UpdateLogger,
|
||||
rolloutTelemetry: RolloutTelemetry,
|
||||
): Promise<boolean> {
|
||||
const sourceCanAutoInstall = canAutoInstall(source, platform);
|
||||
const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false;
|
||||
|
|
@ -531,6 +632,7 @@ async function tryStartAutomaticBackgroundInstall(
|
|||
platform,
|
||||
track,
|
||||
logger,
|
||||
rolloutTelemetry,
|
||||
).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
|
|
@ -562,6 +664,8 @@ export async function runUpdatePreflight(
|
|||
try {
|
||||
const isInteractive =
|
||||
options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY);
|
||||
const deviceId = resolveUpdateDeviceId();
|
||||
const bypassRollout = isRolloutBypassedByExperimentalEnv();
|
||||
let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState());
|
||||
if (isInteractive) {
|
||||
installState = await showPendingBackgroundInstallNotice(
|
||||
|
|
@ -574,11 +678,22 @@ export async function runUpdatePreflight(
|
|||
}
|
||||
|
||||
const cache = await readUpdateCache().catch(() => null);
|
||||
const latest = cache?.latest ?? null;
|
||||
const target = selectUpdateTarget(currentVersion, latest);
|
||||
const cachedManifest = cache?.manifest ?? null;
|
||||
const cachedDecision = decidePassiveUpdateTarget(
|
||||
currentVersion,
|
||||
cache?.latest ?? null,
|
||||
cachedManifest,
|
||||
deviceId,
|
||||
new Date(),
|
||||
bypassRollout,
|
||||
);
|
||||
logRolloutDecision('startup-cache', currentVersion, cache?.latest ?? null, cachedManifest, cachedDecision);
|
||||
const target = cachedDecision.target;
|
||||
if (target === null) {
|
||||
refreshAndMaybeInstallInBackground(
|
||||
currentVersion,
|
||||
deviceId,
|
||||
bypassRollout,
|
||||
isInteractive,
|
||||
installState,
|
||||
platform,
|
||||
|
|
@ -608,14 +723,28 @@ export async function runUpdatePreflight(
|
|||
platform,
|
||||
options.track,
|
||||
logger,
|
||||
rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout),
|
||||
)
|
||||
) {
|
||||
refreshInBackground();
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
const userVisibleTarget = await refreshUserVisibleUpdateTarget(currentVersion, target);
|
||||
const userVisibleUpdate = await refreshUserVisibleUpdateTarget(
|
||||
currentVersion,
|
||||
deviceId,
|
||||
bypassRollout,
|
||||
target,
|
||||
cachedManifest,
|
||||
);
|
||||
const userVisibleTarget = userVisibleUpdate.target;
|
||||
if (userVisibleTarget === null) return 'continue';
|
||||
const userVisibleRollout = rolloutTelemetryFor(
|
||||
deviceId,
|
||||
userVisibleTarget.version,
|
||||
userVisibleUpdate.manifest,
|
||||
bypassRollout,
|
||||
);
|
||||
if (
|
||||
await tryStartAutomaticBackgroundInstall(
|
||||
installState,
|
||||
|
|
@ -625,13 +754,14 @@ export async function runUpdatePreflight(
|
|||
platform,
|
||||
options.track,
|
||||
logger,
|
||||
userVisibleRollout,
|
||||
)
|
||||
) {
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
const installCommand = installCommandFor(source, userVisibleTarget.version, platform);
|
||||
trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision);
|
||||
trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision, userVisibleRollout);
|
||||
|
||||
if (decision === 'manual-command') {
|
||||
stdout.write(renderManualUpdateMessage(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { writeUpdateCache } from './cache';
|
||||
import { fetchLatestVersionFromCdn } from './cdn';
|
||||
import { fetchLatestFromCdn, type FetchLatestResult } from './cdn';
|
||||
import { type UpdateCache } from './types';
|
||||
|
||||
export interface RefreshUpdateCacheDeps {
|
||||
/** Resolves with the latest semver. **Throws** on any failure — callers
|
||||
* (including the default background invocation in preflight) must catch.
|
||||
* Errors intentionally skip `writeCache` so a transient CDN blip does not
|
||||
* overwrite a previously known `latest` with `null`. */
|
||||
readonly fetchLatest: () => Promise<string>;
|
||||
/** Resolves with the latest version + rollout manifest. **Throws** on any
|
||||
* failure — callers (including the default background invocation in
|
||||
* preflight) must catch. Errors intentionally skip `writeCache` so a
|
||||
* transient CDN blip does not overwrite a previously known `latest` with
|
||||
* `null`. */
|
||||
readonly fetchLatest: () => Promise<FetchLatestResult>;
|
||||
readonly writeCache: (cache: UpdateCache) => Promise<void>;
|
||||
readonly now: () => Date;
|
||||
}
|
||||
|
|
@ -16,16 +17,17 @@ export async function refreshUpdateCache(
|
|||
overrides: Partial<RefreshUpdateCacheDeps> = {},
|
||||
): Promise<UpdateCache> {
|
||||
const resolved: RefreshUpdateCacheDeps = {
|
||||
fetchLatest: overrides.fetchLatest ?? (() => fetchLatestVersionFromCdn()),
|
||||
fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()),
|
||||
writeCache: overrides.writeCache ?? writeUpdateCache,
|
||||
now: overrides.now ?? (() => new Date()),
|
||||
};
|
||||
|
||||
const latest = await resolved.fetchLatest();
|
||||
const { latest, manifest } = await resolved.fetchLatest();
|
||||
const cache: UpdateCache = {
|
||||
source: 'cdn',
|
||||
checkedAt: resolved.now().toISOString(),
|
||||
latest,
|
||||
manifest,
|
||||
};
|
||||
await resolved.writeCache(cache);
|
||||
return cache;
|
||||
|
|
|
|||
210
apps/kimi-code/src/cli/update/rollout.ts
Normal file
210
apps/kimi-code/src/cli/update/rollout.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import { readKimiDeviceId } from '@moonshot-ai/kimi-code-oauth';
|
||||
import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { getUpdateRolloutLogFile } from '#/utils/paths';
|
||||
|
||||
import { selectUpdateTarget } from './select';
|
||||
import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types';
|
||||
|
||||
/**
|
||||
* Hard ceiling for any rollout delay. Combined with the uncovered-bucket
|
||||
* fallback below, it guarantees every device sees a release no later than
|
||||
* `publishedAt + 24h`, no matter what the published plan says.
|
||||
*/
|
||||
export const MAX_ROLLOUT_DELAY_SECONDS = 86_400;
|
||||
|
||||
/**
|
||||
* Deterministic 0-99 bucket for a device. The version is mixed into the hash
|
||||
* so each release reshuffles which devices land in the early batches.
|
||||
*/
|
||||
export function rolloutBucket(deviceId: string, version: string): number {
|
||||
const digest = createHash('sha256').update(`${deviceId}:${version}`, 'utf-8').digest();
|
||||
return digest.readUInt32BE(0) % 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay assigned to a bucket by the published plan. Batches claim bucket
|
||||
* ranges in array order; buckets left uncovered (percents summing under 100)
|
||||
* fall into the slowest cohort, and oversized delays are clamped to 24h.
|
||||
*/
|
||||
export function rolloutDelayForBucket(rollout: readonly RolloutBatch[], bucket: number): number {
|
||||
let cumulative = 0;
|
||||
for (const batch of rollout) {
|
||||
cumulative += batch.percent;
|
||||
if (bucket < cumulative) {
|
||||
return Math.min(Math.max(batch.delaySeconds, 0), MAX_ROLLOUT_DELAY_SECONDS);
|
||||
}
|
||||
}
|
||||
if (rollout.length === 0) return 0;
|
||||
return MAX_ROLLOUT_DELAY_SECONDS;
|
||||
}
|
||||
|
||||
export function rolloutDelaySeconds(manifest: UpdateManifest, deviceId: string): number {
|
||||
return rolloutDelayForBucket(manifest.rollout, rolloutBucket(deviceId, manifest.version));
|
||||
}
|
||||
|
||||
export function isRolloutEligible(
|
||||
manifest: UpdateManifest,
|
||||
deviceId: string,
|
||||
now: Date,
|
||||
): boolean {
|
||||
const publishedAt = Date.parse(manifest.publishedAt);
|
||||
// Schema validation rejects unparseable timestamps before they get here;
|
||||
// fail open defensively so a defect can never block updates indefinitely.
|
||||
if (!Number.isFinite(publishedAt)) return true;
|
||||
const delayMs = rolloutDelaySeconds(manifest, deviceId) * 1000;
|
||||
return now.getTime() >= publishedAt + delayMs;
|
||||
}
|
||||
|
||||
/** Which case a passive update check hit; written to the rollout log. */
|
||||
export type PassiveUpdateReason =
|
||||
/** Nothing known yet (no cache / CDN never reached). */
|
||||
| 'no-latest'
|
||||
/** Known version is not an upgrade over the running one. */
|
||||
| 'not-newer'
|
||||
/** Plain-text fallback or legacy cache: visible immediately, no gating. */
|
||||
| 'no-manifest'
|
||||
/** Gated: this device's batch delay has not elapsed yet. */
|
||||
| 'held'
|
||||
/** Gated and the batch delay has elapsed: update is visible. */
|
||||
| 'eligible'
|
||||
/** KIMI_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */
|
||||
| 'experimental';
|
||||
|
||||
export interface PassiveUpdateDecision {
|
||||
readonly target: UpdateTarget | null;
|
||||
readonly reason: PassiveUpdateReason;
|
||||
readonly bucket: number | null;
|
||||
readonly delaySeconds: number | null;
|
||||
readonly eligibleAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update decision for the passive surfaces (background install, startup
|
||||
* prompt, manual-command notice). Devices whose batch is not yet eligible see
|
||||
* no update at all. A null manifest (plain-text fallback or legacy cache)
|
||||
* keeps the pre-rollout behavior: the latest version is visible immediately.
|
||||
*
|
||||
* `kimi upgrade` must NOT go through this gate — it selects directly from the
|
||||
* raw latest version.
|
||||
*/
|
||||
export function decidePassiveUpdateTarget(
|
||||
currentVersion: string,
|
||||
latest: string | null,
|
||||
manifest: UpdateManifest | null,
|
||||
deviceId: string,
|
||||
now: Date,
|
||||
bypassRollout = false,
|
||||
): PassiveUpdateDecision {
|
||||
if (bypassRollout) {
|
||||
if (latest === null) {
|
||||
return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
|
||||
}
|
||||
const target = selectUpdateTarget(currentVersion, latest);
|
||||
return {
|
||||
target,
|
||||
reason: target === null ? 'not-newer' : 'experimental',
|
||||
bucket: null,
|
||||
delaySeconds: null,
|
||||
eligibleAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (manifest === null) {
|
||||
if (latest === null) {
|
||||
return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
|
||||
}
|
||||
const target = selectUpdateTarget(currentVersion, latest);
|
||||
return {
|
||||
target,
|
||||
reason: target === null ? 'not-newer' : 'no-manifest',
|
||||
bucket: null,
|
||||
delaySeconds: null,
|
||||
eligibleAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const target = selectUpdateTarget(currentVersion, manifest.version);
|
||||
if (target === null) {
|
||||
return { target: null, reason: 'not-newer', bucket: null, delaySeconds: null, eligibleAt: null };
|
||||
}
|
||||
|
||||
const bucket = rolloutBucket(deviceId, manifest.version);
|
||||
const delaySeconds = rolloutDelayForBucket(manifest.rollout, bucket);
|
||||
const publishedAt = Date.parse(manifest.publishedAt);
|
||||
const eligibleAt = Number.isFinite(publishedAt)
|
||||
? new Date(publishedAt + delaySeconds * 1000).toISOString()
|
||||
: null;
|
||||
const eligible = isRolloutEligible(manifest, deviceId, now);
|
||||
return {
|
||||
target: eligible ? target : null,
|
||||
reason: eligible ? 'eligible' : 'held',
|
||||
bucket,
|
||||
delaySeconds,
|
||||
eligibleAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectPassiveUpdateTarget(
|
||||
currentVersion: string,
|
||||
latest: string | null,
|
||||
manifest: UpdateManifest | null,
|
||||
deviceId: string,
|
||||
now: Date,
|
||||
): UpdateTarget | null {
|
||||
return decidePassiveUpdateTarget(currentVersion, latest, manifest, deviceId, now).target;
|
||||
}
|
||||
|
||||
const ROLLOUT_LOG_MAX_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Append one JSON line describing a passive update decision to
|
||||
* `<dataDir>/updates/rollout.log`. Best-effort diagnostics: any I/O failure
|
||||
* is swallowed — logging must never affect update prompting. The file is
|
||||
* reset once it grows past a small cap so it cannot grow unbounded.
|
||||
*/
|
||||
export async function appendRolloutDecisionLog(
|
||||
entry: Record<string, unknown>,
|
||||
filePath: string = getUpdateRolloutLogFile(),
|
||||
): Promise<void> {
|
||||
try {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
const line = `${JSON.stringify(entry)}\n`;
|
||||
const size = await stat(filePath).then((s) => s.size, () => 0);
|
||||
if (size > ROLLOUT_LOG_MAX_BYTES) {
|
||||
await writeFile(filePath, line, 'utf-8');
|
||||
return;
|
||||
}
|
||||
await appendFile(filePath, line, 'utf-8');
|
||||
} catch {
|
||||
// Diagnostic logging must never affect the update flow.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-installation id used for bucketing when telemetry has already
|
||||
* minted one. Missing ids stay ephemeral here so update preflight never
|
||||
* creates the telemetry device_id before telemetry can emit first_launch.
|
||||
*/
|
||||
export function resolveUpdateDeviceId(): string {
|
||||
return readKimiDeviceId(resolveKimiHome()) ?? randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* The experimental master switch opts a device out of staged rollouts: the
|
||||
* newest version is always visible to the passive update surfaces, exactly as
|
||||
* if every release were fully rolled out. Read directly from the env (same
|
||||
* truthy values as `KIMI_CODE_NO_AUTO_UPDATE`) — the update preflight runs
|
||||
* before the harness exists, so the core flag registry is not consulted.
|
||||
* `KIMI_CODE_NO_AUTO_UPDATE` still wins: disabling updates beats opting in.
|
||||
*/
|
||||
export function isRolloutBypassedByExperimentalEnv(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): boolean {
|
||||
const value = (env['KIMI_CODE_EXPERIMENTAL_FLAG'] ?? '').trim().toLowerCase();
|
||||
return ['1', 'true', 'yes', 'on'].includes(value);
|
||||
}
|
||||
|
|
@ -16,10 +16,28 @@ export interface UpdateTarget {
|
|||
readonly version: string;
|
||||
}
|
||||
|
||||
/** One gradual-rollout cohort: `percent` of devices delayed by `delaySeconds`. */
|
||||
export interface RolloutBatch {
|
||||
readonly percent: number;
|
||||
readonly delaySeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array
|
||||
* order; an empty array means the release is fully rolled out immediately.
|
||||
*/
|
||||
export interface UpdateManifest {
|
||||
readonly version: string;
|
||||
readonly publishedAt: string;
|
||||
readonly rollout: readonly RolloutBatch[];
|
||||
}
|
||||
|
||||
export interface UpdateCache {
|
||||
readonly source: 'cdn';
|
||||
readonly checkedAt: string | null;
|
||||
readonly latest: string | null;
|
||||
/** Null when the manifest came from the plain-text fallback or a legacy cache file. */
|
||||
readonly manifest: UpdateManifest | null;
|
||||
}
|
||||
|
||||
export interface UpdateInstallActive {
|
||||
|
|
@ -54,6 +72,7 @@ export function emptyUpdateCache(): UpdateCache {
|
|||
source: 'cdn',
|
||||
checkedAt: null,
|
||||
latest: null,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const KIMI_CODE_BIN_DIR_NAME = 'bin';
|
|||
export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json';
|
||||
export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json';
|
||||
export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock';
|
||||
export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log';
|
||||
export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history';
|
||||
|
||||
// Managed Kimi auth provider key shared with OAuth/SDK config.
|
||||
|
|
@ -45,6 +46,10 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted';
|
|||
// CDN source of truth: all version checks and native install scripts pull from here.
|
||||
export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code';
|
||||
export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`;
|
||||
// Rollout manifest consumed by update checks; the plain-text `/latest` above
|
||||
// stays unchanged forever — already-shipped clients hard-fail on non-semver
|
||||
// bodies, and the CDN install scripts read it for fresh installs.
|
||||
export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`;
|
||||
export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json';
|
||||
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`;
|
||||
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ export class DeviceCodeBoxComponent implements Component {
|
|||
render(width: number): string[] {
|
||||
const { title, url, code, hint } = this.params;
|
||||
const border = (s: string): string => currentTheme.fg('primary', s);
|
||||
const safeWidth = Math.max(28, width);
|
||||
const innerWidth = Math.max(10, safeWidth - 4);
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
const innerWidth = Math.max(1, safeWidth - 4);
|
||||
const pad = ' ';
|
||||
|
||||
const titleLine = truncateToWidth(currentTheme.boldFg('textStrong', title), innerWidth, '…');
|
||||
|
|
@ -52,6 +53,10 @@ export class DeviceCodeBoxComponent implements Component {
|
|||
contentLines.push(truncateToWidth(currentTheme.fg('textDim', hint), innerWidth, '…'));
|
||||
}
|
||||
|
||||
if (safeWidth < 4) {
|
||||
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))];
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'',
|
||||
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
|
||||
|
|
@ -69,6 +74,6 @@ export class DeviceCodeBoxComponent implements Component {
|
|||
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
|
||||
lines.push('');
|
||||
|
||||
return lines;
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,25 @@ export class WelcomeComponent implements Component {
|
|||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(0, width);
|
||||
const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s);
|
||||
const innerWidth = Math.max(10, width - 4);
|
||||
const isLoggedOut = !this.state.model;
|
||||
const activeModel = this.state.availableModels[this.state.model];
|
||||
|
||||
if (safeWidth < 24) {
|
||||
const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!');
|
||||
const prompt = isLoggedOut
|
||||
? chalk.hex(currentTheme.palette.warning)('Run /login or /provider to get started.')
|
||||
: chalk.hex(currentTheme.palette.textDim)('Send /help for help information.');
|
||||
const model = isLoggedOut
|
||||
? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider')
|
||||
: (activeModel?.displayName ?? activeModel?.model ?? this.state.model);
|
||||
return ['', title, prompt, `Model: ${model}`].map((line) =>
|
||||
truncateToWidth(line, safeWidth, '…'),
|
||||
);
|
||||
}
|
||||
|
||||
const innerWidth = Math.max(1, safeWidth - 4);
|
||||
const pad = ' ';
|
||||
|
||||
// Logo + side-by-side text.
|
||||
|
|
@ -36,7 +53,6 @@ export class WelcomeComponent implements Component {
|
|||
textWidth,
|
||||
'…',
|
||||
);
|
||||
const isLoggedOut = !this.state.model;
|
||||
const dim = chalk.hex(currentTheme.palette.textDim);
|
||||
const labelStyle = chalk.bold.hex(currentTheme.palette.textDim);
|
||||
const rightRow1 = truncateToWidth(
|
||||
|
|
@ -53,7 +69,6 @@ export class WelcomeComponent implements Component {
|
|||
renderedHeaderLines = renderDanceWelcomeHeader(logo, textWidth, rightRow1);
|
||||
}
|
||||
|
||||
const activeModel = this.state.availableModels[this.state.model];
|
||||
const modelValue = isLoggedOut
|
||||
? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider')
|
||||
: (activeModel?.displayName ?? activeModel?.model ?? this.state.model);
|
||||
|
|
@ -73,8 +88,8 @@ export class WelcomeComponent implements Component {
|
|||
|
||||
const lines: string[] = [
|
||||
'',
|
||||
primary('╭' + '─'.repeat(width - 2) + '╮'),
|
||||
primary('│') + ' '.repeat(width - 2) + primary('│'),
|
||||
primary('╭' + '─'.repeat(safeWidth - 2) + '╮'),
|
||||
primary('│') + ' '.repeat(safeWidth - 2) + primary('│'),
|
||||
];
|
||||
|
||||
for (const content of contentLines) {
|
||||
|
|
@ -84,10 +99,10 @@ export class WelcomeComponent implements Component {
|
|||
lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│'));
|
||||
}
|
||||
|
||||
lines.push(primary('│') + ' '.repeat(width - 2) + primary('│'));
|
||||
lines.push(primary('╰' + '─'.repeat(width - 2) + '╯'));
|
||||
lines.push(primary('│') + ' '.repeat(safeWidth - 2) + primary('│'));
|
||||
lines.push(primary('╰' + '─'.repeat(safeWidth - 2) + '╯'));
|
||||
lines.push('');
|
||||
|
||||
return lines;
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,8 +89,9 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
|
|||
override render(width: number): string[] {
|
||||
this.input.focused = this.focused && !this.done;
|
||||
|
||||
const safeWidth = Math.max(28, width);
|
||||
const innerWidth = Math.max(10, safeWidth - 4);
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
const innerWidth = Math.max(1, safeWidth - 4);
|
||||
const pad = ' ';
|
||||
|
||||
const border = (s: string): string => currentTheme.fg('primary', s);
|
||||
|
|
@ -116,6 +117,10 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
|
|||
footerLine,
|
||||
];
|
||||
|
||||
if (safeWidth < 4) {
|
||||
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))];
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'',
|
||||
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
|
||||
|
|
@ -132,7 +137,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
|
|||
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
|
||||
lines.push('');
|
||||
|
||||
return lines;
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
|
||||
private submit(value: string): void {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ function maskInputLine(raw: string): string {
|
|||
|
||||
// Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.)
|
||||
// while masking every other visible character.
|
||||
const parts = content.split(/(\x1B(?:\[[0-9;]*m|_pi:c\x07))/);
|
||||
const parts = content.split(/(\u001B(?:\[[0-9;]*m|_pi:c\u0007))/);
|
||||
const maskedContent = parts
|
||||
.map((part, index) => {
|
||||
if (index % 2 === 1) return part; // ANSI sequence
|
||||
|
|
@ -137,8 +137,9 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo
|
|||
this.urlInput.focused = dialogActive && this.activeField === 'url';
|
||||
this.tokenInput.focused = dialogActive && this.activeField === 'token';
|
||||
|
||||
const safeWidth = Math.max(36, width);
|
||||
const innerWidth = Math.max(10, safeWidth - 4);
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
const innerWidth = Math.max(1, safeWidth - 4);
|
||||
const pad = ' ';
|
||||
|
||||
const border = (s: string): string => currentTheme.fg('primary', s);
|
||||
|
|
@ -189,6 +190,10 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo
|
|||
footerLine,
|
||||
];
|
||||
|
||||
if (safeWidth < 4) {
|
||||
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))];
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'',
|
||||
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
|
||||
|
|
@ -205,7 +210,7 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo
|
|||
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
|
||||
lines.push('');
|
||||
|
||||
return lines;
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
|
||||
private toggleField(): void {
|
||||
|
|
|
|||
|
|
@ -67,8 +67,9 @@ export class FeedbackInputDialogComponent extends Container implements Focusable
|
|||
override render(width: number): string[] {
|
||||
this.input.focused = this.focused && !this.done;
|
||||
|
||||
const safeWidth = Math.max(28, width);
|
||||
const innerWidth = Math.max(10, safeWidth - 4);
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
const innerWidth = Math.max(1, safeWidth - 4);
|
||||
const pad = ' ';
|
||||
|
||||
const border = (s: string): string => currentTheme.fg('primary', s);
|
||||
|
|
@ -84,6 +85,10 @@ export class FeedbackInputDialogComponent extends Container implements Focusable
|
|||
|
||||
const contentLines: string[] = [titleLine, '', subtitleLine, '', inputLine, '', footerLine];
|
||||
|
||||
if (safeWidth < 4) {
|
||||
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))];
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'',
|
||||
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
|
||||
|
|
@ -100,7 +105,7 @@ export class FeedbackInputDialogComponent extends Container implements Focusable
|
|||
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
|
||||
lines.push('');
|
||||
|
||||
return lines;
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
|
||||
private submit(value: string): void {
|
||||
|
|
|
|||
|
|
@ -241,8 +241,9 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable
|
|||
override render(width: number): string[] {
|
||||
this.input.focused = this.focused && !this.done;
|
||||
|
||||
const safeWidth = Math.max(28, width);
|
||||
const innerWidth = Math.max(10, safeWidth - 4);
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
const innerWidth = Math.max(1, safeWidth - 4);
|
||||
const pad = ' ';
|
||||
const border = (s: string): string => currentTheme.fg('primary', s);
|
||||
const title = truncateToWidth(
|
||||
|
|
@ -265,6 +266,10 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable
|
|||
ELLIPSIS,
|
||||
);
|
||||
const contentLines = [title, '', subtitle, '', ...inputLines, '', footer];
|
||||
if (safeWidth < 4) {
|
||||
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS))];
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'',
|
||||
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
|
||||
|
|
@ -280,7 +285,7 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable
|
|||
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
|
||||
lines.push('');
|
||||
|
||||
return lines;
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS));
|
||||
}
|
||||
|
||||
private submit(value: string): void {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { formatSessionLabel } from '#/migration/index';
|
||||
import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import { SearchableList } from '#/tui/utils/searchable-list';
|
||||
|
||||
export interface SessionRow {
|
||||
readonly id: string;
|
||||
|
|
@ -73,34 +74,59 @@ function singleLine(text: string): string {
|
|||
return text.replaceAll(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function sessionSearchText(session: SessionRow): string {
|
||||
return singleLine((session.title ?? session.id).trim() || session.id);
|
||||
}
|
||||
|
||||
export class SessionPickerComponent extends Container implements Focusable {
|
||||
private sessions: SessionRow[];
|
||||
private currentSessionId: string;
|
||||
private onSelect: (sessionId: string) => void;
|
||||
private onSelect: (session: SessionRow) => void;
|
||||
private onCancel: () => void;
|
||||
private onToggleScope?: (selectedSessionId: string) => void;
|
||||
private maxVisibleSessions: number;
|
||||
private pageSize: number;
|
||||
private visibleCount: number;
|
||||
private scope: 'cwd' | 'all';
|
||||
private loading: boolean;
|
||||
private list: SearchableList<SessionRow>;
|
||||
|
||||
focused = false;
|
||||
private selectedIndex = 0;
|
||||
|
||||
constructor(opts: {
|
||||
sessions: SessionRow[];
|
||||
loading: boolean;
|
||||
currentSessionId: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
scope?: 'cwd' | 'all';
|
||||
initialSelectedSessionId?: string;
|
||||
pageSize?: number;
|
||||
onSelect: (session: SessionRow) => void;
|
||||
onCancel: () => void;
|
||||
onCtrlC?: () => void;
|
||||
onCtrlD?: () => void;
|
||||
onToggleScope?: (selectedSessionId: string) => void;
|
||||
maxVisibleSessions?: number;
|
||||
}) {
|
||||
super();
|
||||
this.sessions = opts.sessions;
|
||||
this.loading = opts.loading;
|
||||
this.currentSessionId = opts.currentSessionId;
|
||||
this.scope = opts.scope ?? 'cwd';
|
||||
this.onSelect = opts.onSelect;
|
||||
this.onCancel = opts.onCancel;
|
||||
this.onToggleScope = opts.onToggleScope;
|
||||
this.maxVisibleSessions = opts.maxVisibleSessions ?? 4;
|
||||
this.pageSize = Math.max(1, opts.pageSize ?? 50);
|
||||
const initialIndex = this.resolveInitialSelectedIndex(opts.initialSelectedSessionId);
|
||||
this.list = new SearchableList({
|
||||
items: this.sessions,
|
||||
toSearchText: sessionSearchText,
|
||||
pageSize: this.pageSize,
|
||||
initialIndex,
|
||||
searchable: true,
|
||||
});
|
||||
const initialLoadedPages = Math.ceil((initialIndex + 1) / this.pageSize);
|
||||
this.visibleCount = Math.min(this.sessions.length, initialLoadedPages * this.pageSize);
|
||||
this.onCtrlC = opts.onCtrlC;
|
||||
this.onCtrlD = opts.onCtrlD;
|
||||
}
|
||||
|
|
@ -108,6 +134,33 @@ export class SessionPickerComponent extends Container implements Focusable {
|
|||
private readonly onCtrlC?: () => void;
|
||||
private readonly onCtrlD?: () => void;
|
||||
|
||||
private resolveInitialSelectedIndex(initialSelectedSessionId: string | undefined): number {
|
||||
if (initialSelectedSessionId === undefined) return 0;
|
||||
const index = this.sessions.findIndex((session) => session.id === initialSelectedSessionId);
|
||||
return Math.max(index, 0);
|
||||
}
|
||||
|
||||
private filteredSessions(): readonly SessionRow[] {
|
||||
return this.list.view().items;
|
||||
}
|
||||
|
||||
private loadedSessions(sessions: readonly SessionRow[] = this.filteredSessions()): SessionRow[] {
|
||||
return sessions.slice(0, Math.min(sessions.length, this.visibleCount));
|
||||
}
|
||||
|
||||
private syncVisibleCount(previousQuery: string): void {
|
||||
const view = this.list.view();
|
||||
if (view.query !== previousQuery) {
|
||||
this.visibleCount = Math.min(view.items.length, this.pageSize);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedCount = Math.min(view.items.length, this.visibleCount);
|
||||
if (view.selectedIndex >= loadedCount - 1 && loadedCount < view.items.length) {
|
||||
this.visibleCount = Math.min(view.items.length, this.visibleCount + this.pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.ctrl('c'))) {
|
||||
this.onCtrlC?.();
|
||||
|
|
@ -117,21 +170,27 @@ export class SessionPickerComponent extends Container implements Focusable {
|
|||
this.onCtrlD?.();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.ctrl('a'))) {
|
||||
this.onToggleScope?.(this.list.selected()?.id ?? this.currentSessionId);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
if (this.list.clearQuery()) {
|
||||
this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize);
|
||||
return;
|
||||
}
|
||||
this.onCancel();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter) && this.sessions.length > 0) {
|
||||
const session = this.sessions[this.selectedIndex];
|
||||
if (session) this.onSelect(session.id);
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const session = this.list.selected();
|
||||
if (session) this.onSelect(session);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = Math.min(this.sessions.length - 1, this.selectedIndex + 1);
|
||||
|
||||
const previousQuery = this.list.view().query;
|
||||
if (this.list.handleKey(data)) {
|
||||
this.syncVisibleCount(previousQuery);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,9 +206,16 @@ export class SessionPickerComponent extends Container implements Focusable {
|
|||
// prevents the "Rendered line exceeds terminal width" crash (issue #240).
|
||||
private renderLines(width: number): string[] {
|
||||
const lines: string[] = [currentTheme.fg('primary', '─'.repeat(width))];
|
||||
const title = this.scope === 'all' ? 'All sessions' : 'Sessions';
|
||||
const scopeHint =
|
||||
this.onToggleScope === undefined
|
||||
? undefined
|
||||
: this.scope === 'all'
|
||||
? 'Ctrl+A current cwd'
|
||||
: 'Ctrl+A all';
|
||||
|
||||
if (this.loading) {
|
||||
lines.push(currentTheme.boldFg('primary', truncateToWidth('Sessions', width, ELLIPSIS)));
|
||||
lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS)));
|
||||
lines.push(
|
||||
currentTheme.fg('textMuted', truncateToWidth('Loading sessions...', width, ELLIPSIS)),
|
||||
);
|
||||
|
|
@ -158,51 +224,78 @@ export class SessionPickerComponent extends Container implements Focusable {
|
|||
}
|
||||
|
||||
if (this.sessions.length === 0) {
|
||||
lines.push(currentTheme.boldFg('primary', truncateToWidth('Sessions', width, ELLIPSIS)));
|
||||
const hintParts = [scopeHint, 'Esc cancel'].filter(
|
||||
(item): item is string => item !== undefined,
|
||||
);
|
||||
lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS)));
|
||||
lines.push(
|
||||
currentTheme.fg(
|
||||
'textMuted',
|
||||
truncateToWidth('No sessions found. Press Escape to close.', width, ELLIPSIS),
|
||||
),
|
||||
currentTheme.fg('textMuted', truncateToWidth(hintParts.join(' · '), width, ELLIPSIS)),
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
currentTheme.fg('textMuted', truncateToWidth('No sessions found.', width, ELLIPSIS)),
|
||||
);
|
||||
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
|
||||
return lines;
|
||||
}
|
||||
|
||||
const headerLabel = 'Sessions ';
|
||||
const headerHint = '↑↓ navigate · Enter select · Esc cancel';
|
||||
const labelWidth = visibleWidth(headerLabel);
|
||||
const hintBudget = Math.max(0, width - labelWidth);
|
||||
const shownHint = truncateToWidth(headerHint, hintBudget, ELLIPSIS);
|
||||
lines.push(
|
||||
currentTheme.boldFg('primary', headerLabel) + currentTheme.fg('textMuted', shownHint),
|
||||
);
|
||||
const view = this.list.view();
|
||||
const titleSuffix =
|
||||
view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : '';
|
||||
const hintParts = [
|
||||
...(view.query.length > 0 ? ['Backspace clear'] : []),
|
||||
'↑↓ navigate',
|
||||
scopeHint,
|
||||
'Enter select',
|
||||
'Esc cancel',
|
||||
].filter((item): item is string => item !== undefined);
|
||||
|
||||
lines.push(currentTheme.boldFg('primary', title) + titleSuffix);
|
||||
lines.push(currentTheme.fg('textMuted', hintParts.join(' · ')));
|
||||
lines.push('');
|
||||
|
||||
if (view.query.length > 0) {
|
||||
lines.push(currentTheme.fg('primary', 'Search: ') + currentTheme.fg('text', view.query));
|
||||
}
|
||||
|
||||
const loadedSessions = this.loadedSessions(view.items);
|
||||
if (loadedSessions.length === 0) {
|
||||
lines.push(currentTheme.fg('textMuted', truncateToWidth('No matches', width, ELLIPSIS)));
|
||||
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
|
||||
return lines;
|
||||
}
|
||||
const selectedIndex = view.selectedIndex;
|
||||
const visibleStart = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.selectedIndex - Math.floor(this.maxVisibleSessions / 2),
|
||||
Math.max(0, this.sessions.length - this.maxVisibleSessions),
|
||||
selectedIndex - Math.floor(this.maxVisibleSessions / 2),
|
||||
Math.max(0, loadedSessions.length - this.maxVisibleSessions),
|
||||
),
|
||||
);
|
||||
const visibleSessions = this.sessions.slice(
|
||||
const visibleSessions = loadedSessions.slice(
|
||||
visibleStart,
|
||||
visibleStart + this.maxVisibleSessions,
|
||||
);
|
||||
|
||||
for (const [vi, session] of visibleSessions.entries()) {
|
||||
const index = visibleStart + vi;
|
||||
const isSelected = index === this.selectedIndex;
|
||||
const isSelected = index === selectedIndex;
|
||||
const isCurrent = session.id === this.currentSessionId;
|
||||
const card = this.renderSessionCard(width, session, isSelected, isCurrent);
|
||||
lines.push(...card);
|
||||
if (vi < visibleSessions.length - 1) lines.push('');
|
||||
}
|
||||
|
||||
if (this.sessions.length > visibleSessions.length) {
|
||||
const filteredCount = view.items.length;
|
||||
if (loadedSessions.length > visibleSessions.length || view.query.length > 0) {
|
||||
lines.push('');
|
||||
const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${String(this.sessions.length)} sessions`;
|
||||
const totalSuffix =
|
||||
view.query.length > 0
|
||||
? `${String(loadedSessions.length)} loaded / ${String(filteredCount)} matches`
|
||||
: loadedSessions.length === this.sessions.length
|
||||
? `${String(loadedSessions.length)} sessions`
|
||||
: `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`;
|
||||
const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}`;
|
||||
lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS)));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,22 +22,25 @@ const MAX_IMAGE_WIDTH = 40;
|
|||
|
||||
export class ImageThumbnail extends Container {
|
||||
private readonly attachment: ImageAttachment;
|
||||
private lastRenderWidth = 80;
|
||||
private lastBuiltWidth: number | undefined;
|
||||
private lastBuiltInline: boolean | undefined;
|
||||
|
||||
constructor(attachment: ImageAttachment) {
|
||||
super();
|
||||
this.attachment = attachment;
|
||||
this.buildChildren();
|
||||
this.buildChildren(this.lastRenderWidth);
|
||||
}
|
||||
|
||||
private buildChildren(): void {
|
||||
private buildChildren(width: number): void {
|
||||
this.clear();
|
||||
const caps = getCapabilities();
|
||||
const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2';
|
||||
|
||||
if (!supportsInline) {
|
||||
// Non-graphic terminal — show the placeholder text in accent colour so
|
||||
// it's clearly an attachment reference but doesn't shout.
|
||||
this.addChild(new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0));
|
||||
this.lastBuiltWidth = width;
|
||||
this.lastBuiltInline = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -51,16 +54,36 @@ export class ImageThumbnail extends Container {
|
|||
theme,
|
||||
{
|
||||
maxHeightCells: MAX_IMAGE_ROWS,
|
||||
maxWidthCells: MAX_IMAGE_WIDTH,
|
||||
maxWidthCells: Math.max(1, Math.min(MAX_IMAGE_WIDTH, width - 2)),
|
||||
filename: this.attachment.placeholder,
|
||||
},
|
||||
{ widthPx: this.attachment.width, heightPx: this.attachment.height },
|
||||
);
|
||||
this.addChild(image);
|
||||
this.lastBuiltWidth = width;
|
||||
this.lastBuiltInline = true;
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const safeWidth = Math.max(0, width);
|
||||
this.lastRenderWidth = safeWidth;
|
||||
|
||||
if (safeWidth < MAX_IMAGE_WIDTH + 2) {
|
||||
return new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0).render(
|
||||
safeWidth,
|
||||
);
|
||||
}
|
||||
|
||||
const caps = getCapabilities();
|
||||
const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2';
|
||||
if (this.lastBuiltWidth !== safeWidth || this.lastBuiltInline !== supportsInline) {
|
||||
this.buildChildren(safeWidth);
|
||||
}
|
||||
return super.render(safeWidth);
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
this.buildChildren();
|
||||
this.buildChildren(this.lastRenderWidth);
|
||||
super.invalidate();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
* to align after the bullet.
|
||||
*/
|
||||
|
||||
import type { Component } from '@earendil-works/pi-tui';
|
||||
import { Container, Markdown, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
|
||||
|
||||
import { MESSAGE_INDENT } from '#/tui/constant/rendering';
|
||||
import { STATUS_BULLET } from '#/tui/constant/symbols';
|
||||
|
|
@ -52,8 +51,11 @@ export class AssistantMessageComponent implements Component {
|
|||
render(width: number): string[] {
|
||||
if (this.lastText.trim().length === 0) return [];
|
||||
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT;
|
||||
const contentWidth = Math.max(1, width - visibleWidth(prefix));
|
||||
const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix));
|
||||
const contentLines = this.contentContainer.render(contentWidth);
|
||||
|
||||
const lines: string[] = [''];
|
||||
|
|
@ -62,6 +64,6 @@ export class AssistantMessageComponent implements Component {
|
|||
i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT;
|
||||
lines.push(p + contentLines[i]);
|
||||
}
|
||||
return lines;
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Component } from '@earendil-works/pi-tui';
|
||||
import { Text } from '@earendil-works/pi-tui';
|
||||
import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui';
|
||||
|
||||
import { MESSAGE_INDENT } from '#/tui/constant/rendering';
|
||||
import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols';
|
||||
|
|
@ -13,6 +12,9 @@ export class BackgroundAgentStatusComponent implements Component {
|
|||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
const tone: keyof ColorPalette =
|
||||
this.data.phase === 'started'
|
||||
? 'primary'
|
||||
|
|
@ -29,11 +31,11 @@ export class BackgroundAgentStatusComponent implements Component {
|
|||
: '');
|
||||
|
||||
const textComponent = new Text(text, 0, 0);
|
||||
const contentWidth = Math.max(1, width - MESSAGE_INDENT.length);
|
||||
const contentWidth = Math.max(1, safeWidth - MESSAGE_INDENT.length);
|
||||
const contentLines = textComponent.render(contentWidth);
|
||||
return [
|
||||
'',
|
||||
...contentLines.map((line, index) => (index === 0 ? bullet : MESSAGE_INDENT) + line),
|
||||
];
|
||||
].map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,27 +32,36 @@ export class CronMessageComponent implements Component {
|
|||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
const missed = this.data.missedCount !== undefined;
|
||||
const titleToken: keyof ColorPalette = this.data.stale === true || missed ? 'warning' : 'accent';
|
||||
const bullet = currentTheme.boldFg(titleToken, STATUS_BULLET);
|
||||
const bulletWidth = visibleWidth(bullet);
|
||||
const contentWidth = Math.max(1, width - bulletWidth);
|
||||
const contentWidth = Math.max(1, safeWidth - bulletWidth);
|
||||
const continuationIndent = ' '.repeat(bulletWidth);
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const line of this.spacer.render(width)) {
|
||||
for (const line of this.spacer.render(safeWidth)) {
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
const title = currentTheme.boldFg(titleToken, this.title);
|
||||
lines.push(`${bullet}${title}`);
|
||||
const titleLines = new Text(currentTheme.boldFg(titleToken, this.title), 0, 0).render(contentWidth);
|
||||
for (let i = 0; i < titleLines.length; i += 1) {
|
||||
lines.push(`${i === 0 ? bullet : continuationIndent}${titleLines[i]}`);
|
||||
}
|
||||
|
||||
if (this.detail !== undefined) {
|
||||
lines.push(`${' '.repeat(bulletWidth)}${currentTheme.fg('textDim', this.detail)}`);
|
||||
const detailLines = new Text(currentTheme.fg('textDim', this.detail), 0, 0).render(contentWidth);
|
||||
for (const line of detailLines) {
|
||||
lines.push(`${continuationIndent}${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
const promptLines = this.promptText.render(contentWidth);
|
||||
for (const line of promptLines) {
|
||||
lines.push(`${' '.repeat(bulletWidth)}${line}`);
|
||||
lines.push(`${continuationIndent}${line}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,13 @@
|
|||
* Stop after 20 turns (7/20) (or a dim "no stop condition" note)
|
||||
*/
|
||||
|
||||
import type { Component } from '@earendil-works/pi-tui';
|
||||
import { Text, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import {
|
||||
Text,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
type Component,
|
||||
} from '@earendil-works/pi-tui';
|
||||
import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { MESSAGE_INDENT } from '#/tui/constant/rendering';
|
||||
|
|
@ -30,10 +35,18 @@ const MAX_OBJECTIVE_LINES = 6;
|
|||
const MAX_CRITERION_LINES = 3;
|
||||
const LABEL_WIDTH = 11;
|
||||
|
||||
function renderLifecycleLine(label: string): string[] {
|
||||
function renderLifecycleLine(label: string, width: number): string[] {
|
||||
if (width <= 0) return [''];
|
||||
|
||||
const marker = currentTheme.boldFg('primary', STATUS_BULLET);
|
||||
const text = currentTheme.boldFg('primary', label);
|
||||
return ['', marker + text];
|
||||
const text = new Text(currentTheme.boldFg('primary', label), 0, 0);
|
||||
const contentWidth = Math.max(1, width - visibleWidth(STATUS_BULLET));
|
||||
return [
|
||||
'',
|
||||
...text
|
||||
.render(contentWidth)
|
||||
.map((line, index) => (index === 0 ? marker : MESSAGE_INDENT) + line.trimEnd()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -44,17 +57,18 @@ function renderLifecycleLine(label: string): string[] {
|
|||
export class GoalSetMessageComponent implements Component {
|
||||
invalidate(): void {}
|
||||
|
||||
render(_width: number): string[] {
|
||||
return renderLifecycleLine('Goal set');
|
||||
render(width: number): string[] {
|
||||
return renderLifecycleLine('Goal set', width);
|
||||
}
|
||||
}
|
||||
|
||||
export class UpcomingGoalAddedMessageComponent implements Component {
|
||||
invalidate(): void {}
|
||||
|
||||
render(_width: number): string[] {
|
||||
render(width: number): string[] {
|
||||
return renderLifecycleLine(
|
||||
'Upcoming goal added. It will start after the current goal is complete.',
|
||||
width,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -99,8 +113,9 @@ export class GoalStatusMessageComponent implements Component {
|
|||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const panelContentWidth = Math.max(1, width - 6);
|
||||
const panel = new UsagePanelComponent(
|
||||
() => buildGoalReportLines(this.goal),
|
||||
() => buildGoalReportLines(this.goal, panelContentWidth),
|
||||
'primary',
|
||||
goalPanelTitle(this.goal),
|
||||
);
|
||||
|
|
@ -113,7 +128,7 @@ export function goalPanelTitle(goal: GoalSnapshot): string {
|
|||
return ` Goal · ${goal.status} `;
|
||||
}
|
||||
|
||||
export function buildGoalReportLines(goal: GoalSnapshot): string[] {
|
||||
export function buildGoalReportLines(goal: GoalSnapshot, wrapWidth: number = WRAP_WIDTH): string[] {
|
||||
const statusColor = statusToken(goal.status);
|
||||
const bar = (s: string) => currentTheme.fg(statusColor, s);
|
||||
const value = (s: string) => currentTheme.fg('text', s);
|
||||
|
|
@ -127,12 +142,14 @@ export function buildGoalReportLines(goal: GoalSnapshot): string[] {
|
|||
(goal.status === 'paused' && reason !== undefined) || goal.status === 'blocked' || isComplete;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Condition as a blockquote left-trail.
|
||||
for (const line of wrap(goal.objective, WRAP_WIDTH, MAX_OBJECTIVE_LINES)) {
|
||||
// Condition as a blockquote left-trail. Reserve the visible "▌ " prefix before
|
||||
// wrapping so the panel doesn't clip rows that exactly fit the panel interior.
|
||||
const blockquoteWrapWidth = Math.max(1, wrapWidth - visibleWidth('▌ '));
|
||||
for (const line of wrap(goal.objective, blockquoteWrapWidth, MAX_OBJECTIVE_LINES)) {
|
||||
lines.push(`${bar('▌')} ${value(line)}`);
|
||||
}
|
||||
if (goal.completionCriterion !== undefined) {
|
||||
for (const line of wrap(`✓ ${goal.completionCriterion}`, WRAP_WIDTH, MAX_CRITERION_LINES)) {
|
||||
for (const line of wrap(`✓ ${goal.completionCriterion}`, blockquoteWrapWidth, MAX_CRITERION_LINES)) {
|
||||
lines.push(`${bar('▌')} ${muted(line)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -194,22 +211,12 @@ function statusToken(status: GoalStatus): ColorToken {
|
|||
|
||||
/** Word-wrap to `width`, capped at `maxLines` (last line gets an ellipsis when clipped). */
|
||||
function wrap(text: string, width: number, maxLines: number): string[] {
|
||||
const words = text.replaceAll(/\s+/g, ' ').trim().split(' ');
|
||||
const lines: string[] = [];
|
||||
let current = '';
|
||||
for (const word of words) {
|
||||
const candidate = current.length === 0 ? word : `${current} ${word}`;
|
||||
if (candidate.length > width && current.length > 0) {
|
||||
lines.push(current);
|
||||
current = word;
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
if (current.length > 0) lines.push(current);
|
||||
const safeWidth = Math.max(1, width);
|
||||
const lines = wrapTextWithAnsi(text.replaceAll(/\s+/g, ' ').trim(), safeWidth);
|
||||
if (lines.length === 0) return [''];
|
||||
if (lines.length <= maxLines) return lines;
|
||||
const clipped = lines.slice(0, maxLines);
|
||||
clipped[maxLines - 1] = `${clipped[maxLines - 1]!.slice(0, Math.max(0, width - 1))}…`;
|
||||
const lastLine = clipped[maxLines - 1] ?? '';
|
||||
clipped[maxLines - 1] = truncateToWidth(`${lastLine}…`, safeWidth, '…');
|
||||
return clipped;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import type { Component, MarkdownTheme } from '@earendil-works/pi-tui';
|
||||
import { Markdown, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
|
||||
|
|
@ -53,6 +52,12 @@ export class PlanBoxComponent implements Component {
|
|||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
if (safeWidth < LEFT_MARGIN + 4) {
|
||||
return this.markdown.render(Math.max(1, safeWidth)).map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
|
||||
if (this.cachedLines !== undefined && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
|
@ -62,7 +67,7 @@ export class PlanBoxComponent implements Component {
|
|||
// " └──...──┘"
|
||||
// width = LEFT_MARGIN + 1 + horzLen + 1 ⇒ horzLen = width - 4
|
||||
// content width = horzLen - 2 * SIDE_PADDING = width - 6
|
||||
const horzLen = Math.max(2, width - LEFT_MARGIN - 2);
|
||||
const horzLen = Math.max(2, safeWidth - LEFT_MARGIN - 2);
|
||||
const contentWidth = Math.max(1, horzLen - 2 * SIDE_PADDING);
|
||||
|
||||
const paint = (s: string): string => chalk.hex(this.borderHex)(s);
|
||||
|
|
@ -83,17 +88,22 @@ export class PlanBoxComponent implements Component {
|
|||
}
|
||||
lines.push(bottom);
|
||||
|
||||
const fitted = lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
this.cachedLines = fitted;
|
||||
return fitted;
|
||||
}
|
||||
|
||||
private buildTitle(horzLen: number): string {
|
||||
const fallback = ' plan ';
|
||||
const statusSuffix = this.buildStatusSuffix();
|
||||
const fallbackWithStatus = ` plan${statusSuffix} `;
|
||||
const budget = horzLen - 1;
|
||||
const fallbackTitle = visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback;
|
||||
const budget = Math.max(0, horzLen - 1);
|
||||
const fallbackTitle = truncateToWidth(
|
||||
visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback,
|
||||
budget,
|
||||
'…',
|
||||
);
|
||||
const planPath = this.planPath;
|
||||
if (planPath === undefined || planPath.length === 0) return fallbackTitle;
|
||||
const basename = path.basename(planPath);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Component } from '@earendil-works/pi-tui';
|
||||
import { truncateToWidth, type Component } from '@earendil-works/pi-tui';
|
||||
|
||||
import { STATUS_BULLET } from '#/tui/constant/symbols';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
|
|
@ -10,11 +10,14 @@ export class SwarmModeMarkerComponent implements Component {
|
|||
|
||||
invalidate(): void {}
|
||||
|
||||
render(_width: number): string[] {
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
const token = this.state === 'inactive' ? 'textDim' : 'success';
|
||||
const marker = currentTheme.boldFg(token, STATUS_BULLET);
|
||||
const label = currentTheme.boldFg(token, swarmMarkerLabel(this.state));
|
||||
return ['', marker + label];
|
||||
return ['', truncateToWidth(marker + label, safeWidth, '…')];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
* Supports expand/collapse via Ctrl+O (shared with tool output).
|
||||
*/
|
||||
|
||||
import type { Component, TUI } from '@earendil-works/pi-tui';
|
||||
import { Text } from '@earendil-works/pi-tui';
|
||||
import { Text, truncateToWidth, type Component, type TUI } from '@earendil-works/pi-tui';
|
||||
|
||||
import {
|
||||
BRAILLE_SPINNER_FRAMES,
|
||||
|
|
@ -110,8 +109,11 @@ export class ThinkingComponent implements Component {
|
|||
// Leading blank + first PREVIEW_LINES content lines + hint line.
|
||||
const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES);
|
||||
const remaining = contentLines.length - THINKING_PREVIEW_LINES;
|
||||
const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`;
|
||||
const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width));
|
||||
const hintWidth = Math.max(0, width - indentWidth);
|
||||
truncated.push(
|
||||
MESSAGE_INDENT + currentTheme.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`),
|
||||
' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')),
|
||||
);
|
||||
return truncated;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { isAbsolute, relative, sep } from 'node:path';
|
||||
|
||||
import { Container, Text, Spacer, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import type { Component, TUI } from '@earendil-works/pi-tui';
|
||||
import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
|
||||
import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview';
|
||||
|
|
@ -472,19 +472,24 @@ class PrefixedWrappedLine implements Component {
|
|||
invalidate(): void { }
|
||||
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
const prefixWidth = Math.max(
|
||||
visibleWidth(this.firstPrefix),
|
||||
visibleWidth(this.continuationPrefix),
|
||||
);
|
||||
const contentWidth = Math.max(1, width - prefixWidth);
|
||||
const contentWidth = Math.max(1, safeWidth - prefixWidth);
|
||||
const wrapped = new Text(this.text, 0, 0).render(contentWidth);
|
||||
const lines =
|
||||
this.tailLines !== undefined && wrapped.length > this.tailLines
|
||||
? wrapped.slice(wrapped.length - this.tailLines)
|
||||
: wrapped;
|
||||
return lines.map((line, index) =>
|
||||
index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`,
|
||||
);
|
||||
return lines
|
||||
.map((line, index) =>
|
||||
index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`,
|
||||
)
|
||||
.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Component } from '@earendil-works/pi-tui';
|
||||
import { Text } from '@earendil-works/pi-tui';
|
||||
import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui';
|
||||
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
|
||||
|
|
@ -65,6 +64,12 @@ export class TruncatedOutputComponent implements Component {
|
|||
this.textComponent.invalidate();
|
||||
}
|
||||
|
||||
private renderHint(width: number, hint: string): string {
|
||||
const indentWidth = Math.min(this.indent, Math.max(0, width));
|
||||
const hintWidth = Math.max(0, width - indentWidth);
|
||||
return ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…'));
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const contentLines = this.textComponent.render(width);
|
||||
|
||||
|
|
@ -76,7 +81,7 @@ export class TruncatedOutputComponent implements Component {
|
|||
if (this.tail) {
|
||||
const shown = contentLines.slice(contentLines.length - this.maxLines);
|
||||
return [
|
||||
' '.repeat(this.indent) + currentTheme.dim(`... (${String(remaining)} earlier lines)`),
|
||||
this.renderHint(width, `... (${String(remaining)} earlier lines)`),
|
||||
...shown,
|
||||
];
|
||||
}
|
||||
|
|
@ -85,7 +90,7 @@ export class TruncatedOutputComponent implements Component {
|
|||
const hint = this.expandHint
|
||||
? `... (${String(remaining)} more lines, ctrl+o to expand)`
|
||||
: `... (${String(remaining)} more lines)`;
|
||||
return [...shown, ' '.repeat(this.indent) + currentTheme.dim(hint)];
|
||||
return [...shown, this.renderHint(width, hint)];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import type { ColorToken } from '#/tui/theme';
|
|||
|
||||
const LEFT_MARGIN = 2;
|
||||
const SIDE_PADDING = 1;
|
||||
const MIN_INTERIOR_WIDTH = 20;
|
||||
const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING;
|
||||
|
||||
type Colorize = (text: string) => string;
|
||||
|
||||
|
|
@ -219,23 +219,30 @@ export class UsagePanelComponent implements Component {
|
|||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const paint = (s: string): string => currentTheme.fg(this.borderToken, s);
|
||||
const indent = ' '.repeat(LEFT_MARGIN);
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
const availableInterior = Math.max(
|
||||
MIN_INTERIOR_WIDTH,
|
||||
width - LEFT_MARGIN - 2 - 2 * SIDE_PADDING,
|
||||
);
|
||||
const paint = (s: string): string => currentTheme.fg(this.borderToken, s);
|
||||
const availableInterior = safeWidth - BOX_OVERHEAD;
|
||||
if (availableInterior < 1) {
|
||||
return [
|
||||
truncateToWidth(this.title.trim(), safeWidth, '…'),
|
||||
...this.lines.map((line) => truncateToWidth(line, safeWidth, '…')),
|
||||
];
|
||||
}
|
||||
|
||||
const indent = ' '.repeat(LEFT_MARGIN);
|
||||
const longestLine = this.lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
||||
const contentWidth = Math.max(
|
||||
MIN_INTERIOR_WIDTH,
|
||||
Math.min(availableInterior, longestLine, Math.max(longestLine, this.title.length)),
|
||||
1,
|
||||
Math.min(availableInterior, Math.max(longestLine, visibleWidth(this.title))),
|
||||
);
|
||||
const horzLen = contentWidth + 2 * SIDE_PADDING;
|
||||
const title = truncateToWidth(this.title, horzLen, '…');
|
||||
|
||||
const trailingDashLen = Math.max(0, horzLen - this.title.length);
|
||||
const trailingDashLen = Math.max(0, horzLen - visibleWidth(title));
|
||||
const top =
|
||||
indent + paint('╭') + paint(this.title) + paint('─'.repeat(trailingDashLen)) + paint('╮');
|
||||
indent + paint('╭') + paint(title) + paint('─'.repeat(trailingDashLen)) + paint('╮');
|
||||
const bottom = indent + paint('╰' + '─'.repeat(horzLen) + '╯');
|
||||
|
||||
const out: string[] = [top];
|
||||
|
|
@ -245,6 +252,6 @@ export class UsagePanelComponent implements Component {
|
|||
out.push(indent + paint('│') + ' ' + clipped + ' '.repeat(pad) + ' ' + paint('│'));
|
||||
}
|
||||
out.push(bottom);
|
||||
return out;
|
||||
return out.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
* Renders a user message in the transcript.
|
||||
*/
|
||||
|
||||
import type { Component } from '@earendil-works/pi-tui';
|
||||
import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
|
||||
|
||||
import { ImageThumbnail } from '#/tui/components/media/image-thumbnail';
|
||||
import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
|
||||
|
|
@ -28,14 +27,17 @@ export class UserMessageComponent implements Component {
|
|||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(0, width);
|
||||
if (safeWidth <= 0) return [''];
|
||||
|
||||
const bullet = currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET);
|
||||
const bulletWidth = visibleWidth(bullet);
|
||||
const contentWidth = Math.max(1, width - bulletWidth);
|
||||
const contentWidth = Math.max(1, safeWidth - bulletWidth);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Spacer
|
||||
for (const line of this.spacerComponent.render(width)) {
|
||||
for (const line of this.spacerComponent.render(safeWidth)) {
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +57,6 @@ export class UserMessageComponent implements Component {
|
|||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,12 @@ export class EditorKeyboardController {
|
|||
|
||||
if (host.state.appState.streamingPhase !== 'idle') {
|
||||
this.clearPendingExit();
|
||||
|
||||
if (editor.getText().length > 0) {
|
||||
editor.setText('');
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelCurrentStream();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,12 @@ import { resolve } from 'pathe';
|
|||
|
||||
import type { CLIOptions } from '#/cli/options';
|
||||
import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index';
|
||||
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
|
||||
import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history';
|
||||
import { openUrl } from '#/utils/open-url';
|
||||
import { getInputHistoryFile } from '#/utils/paths';
|
||||
import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect';
|
||||
import { quoteShellArg } from '#/utils/shell-quote';
|
||||
|
||||
import { BannerProvider } from './banner/banner-provider';
|
||||
import {
|
||||
|
|
@ -56,7 +58,10 @@ import {
|
|||
import { CompactionComponent } from './components/dialogs/compaction';
|
||||
import { HelpPanelComponent } from './components/dialogs/help-panel';
|
||||
import { QuestionDialogComponent } from './components/dialogs/question-dialog';
|
||||
import { SessionPickerComponent } from './components/dialogs/session-picker';
|
||||
import {
|
||||
SessionPickerComponent,
|
||||
type SessionRow,
|
||||
} from './components/dialogs/session-picker';
|
||||
import {
|
||||
FileMentionProvider,
|
||||
type SlashAutocompleteCommand,
|
||||
|
|
@ -1167,10 +1172,14 @@ export class KimiTUI {
|
|||
session.setQuestionHandler(createQuestionAskHandler(this.questionController));
|
||||
}
|
||||
|
||||
async fetchSessions(): Promise<void> {
|
||||
async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise<void> {
|
||||
this.state.loadingSessions = true;
|
||||
this.state.sessionsScope = scope;
|
||||
try {
|
||||
const sessions = await this.harness.listSessions({ workDir: this.state.appState.workDir });
|
||||
const sessions =
|
||||
scope === 'all'
|
||||
? await this.harness.listSessions({})
|
||||
: await this.harness.listSessions({ workDir: this.state.appState.workDir });
|
||||
this.state.sessions = sessionRowsForPicker(
|
||||
sessions,
|
||||
this.state.appState.sessionId,
|
||||
|
|
@ -1208,6 +1217,18 @@ export class KimiTUI {
|
|||
this.updateQueueDisplay();
|
||||
}
|
||||
|
||||
private async showResumeOtherWorkDirHint(session: SessionRow): Promise<void> {
|
||||
this.hideSessionPicker();
|
||||
const command = `cd ${quoteShellArg(session.work_dir)} && kimi --resume ${quoteShellArg(session.id)}`;
|
||||
const message = `Current session is in a different working directory.\n To resume, run: ${command}`;
|
||||
try {
|
||||
await copyTextToClipboard(command);
|
||||
this.showStatus(`${message}\n Command copied to clipboard`, 'warning');
|
||||
} catch {
|
||||
this.showStatus(`${message}\n Failed to copy command to clipboard`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
private async resumeSession(targetSessionId: string): Promise<boolean> {
|
||||
if (targetSessionId === this.state.appState.sessionId) {
|
||||
this.showStatus('Already on this session.');
|
||||
|
|
@ -1840,33 +1861,87 @@ export class KimiTUI {
|
|||
this.restoreEditor();
|
||||
}
|
||||
|
||||
private sessionPickerOptions: {
|
||||
readonly applyStartupModes: boolean;
|
||||
readonly closeOnCancel: boolean;
|
||||
readonly forwardEditorExit: boolean;
|
||||
} = {
|
||||
applyStartupModes: false,
|
||||
closeOnCancel: false,
|
||||
forwardEditorExit: false,
|
||||
};
|
||||
private sessionPickerScopeRequestToken = 0;
|
||||
|
||||
async showSessionPicker(): Promise<void> {
|
||||
await this.fetchSessions();
|
||||
this.mountSessionPicker({
|
||||
onCancel: () => {
|
||||
this.hideSessionPicker();
|
||||
},
|
||||
await this.openSessionPicker({
|
||||
applyStartupModes: false,
|
||||
closeOnCancel: false,
|
||||
forwardEditorExit: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async bootstrapFromPicker(): Promise<void> {
|
||||
await this.fetchSessions();
|
||||
this.mountSessionPicker({
|
||||
await this.openSessionPicker({
|
||||
applyStartupModes: true,
|
||||
closeOnCancel: true,
|
||||
forwardEditorExit: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async openSessionPicker(options: {
|
||||
readonly applyStartupModes: boolean;
|
||||
readonly closeOnCancel: boolean;
|
||||
readonly forwardEditorExit: boolean;
|
||||
}): Promise<void> {
|
||||
this.sessionPickerOptions = options;
|
||||
await this.fetchSessions('cwd');
|
||||
this.mountSessionPicker({
|
||||
applyStartupModes: options.applyStartupModes,
|
||||
onCancel: () => {
|
||||
this.hideSessionPicker();
|
||||
void this.stop();
|
||||
if (options.closeOnCancel) void this.stop();
|
||||
},
|
||||
onCtrlC: () => {
|
||||
this.state.editor.onCtrlC?.();
|
||||
},
|
||||
onCtrlD: () => {
|
||||
this.state.editor.onCtrlD?.();
|
||||
onCtrlC: options.forwardEditorExit
|
||||
? () => {
|
||||
this.state.editor.onCtrlC?.();
|
||||
}
|
||||
: undefined,
|
||||
onCtrlD: options.forwardEditorExit
|
||||
? () => {
|
||||
this.state.editor.onCtrlD?.();
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private async toggleSessionPickerScope(selectedSessionId: string): Promise<void> {
|
||||
const requestToken = ++this.sessionPickerScopeRequestToken;
|
||||
const nextScope = this.state.sessionsScope === 'cwd' ? 'all' : 'cwd';
|
||||
await this.fetchSessions(nextScope);
|
||||
if (requestToken !== this.sessionPickerScopeRequestToken) return;
|
||||
if (this.state.activeDialog !== 'session-picker') return;
|
||||
this.mountSessionPicker({
|
||||
initialSelectedSessionId: selectedSessionId,
|
||||
applyStartupModes: this.sessionPickerOptions.applyStartupModes,
|
||||
onCancel: () => {
|
||||
this.hideSessionPicker();
|
||||
if (this.sessionPickerOptions.closeOnCancel) void this.stop();
|
||||
},
|
||||
onCtrlC: this.sessionPickerOptions.forwardEditorExit
|
||||
? () => {
|
||||
this.state.editor.onCtrlC?.();
|
||||
}
|
||||
: undefined,
|
||||
onCtrlD: this.sessionPickerOptions.forwardEditorExit
|
||||
? () => {
|
||||
this.state.editor.onCtrlD?.();
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
hideSessionPicker(): void {
|
||||
this.sessionPickerScopeRequestToken += 1;
|
||||
this.editorKeyboard.clearPendingExit();
|
||||
this.state.activeDialog = null;
|
||||
this.restoreEditor();
|
||||
|
|
@ -1876,6 +1951,7 @@ export class KimiTUI {
|
|||
readonly onCancel: () => void;
|
||||
readonly onCtrlC?: () => void;
|
||||
readonly onCtrlD?: () => void;
|
||||
readonly initialSelectedSessionId?: string;
|
||||
// CLI mode flags (--auto/--yolo/--plan) target the session picked at
|
||||
// startup (bare --session); later /sessions switches keep the picked
|
||||
// session's own persisted modes.
|
||||
|
|
@ -1887,29 +1963,45 @@ export class KimiTUI {
|
|||
sessions: this.state.sessions,
|
||||
loading: this.state.loadingSessions,
|
||||
currentSessionId: this.state.appState.sessionId,
|
||||
onSelect: (sessionId: string) => {
|
||||
void this.resumeSession(sessionId)
|
||||
.then(async (switched) => {
|
||||
if (!switched) {
|
||||
return;
|
||||
}
|
||||
if (options.applyStartupModes === true) {
|
||||
await this.applyStartupModesToResumedSession(this.requireSession());
|
||||
this.applyStartupPermissionAndPlanToAppState();
|
||||
}
|
||||
this.hideSessionPicker();
|
||||
})
|
||||
.catch((error) => {
|
||||
scope: this.state.sessionsScope,
|
||||
initialSelectedSessionId: options.initialSelectedSessionId,
|
||||
pageSize: 50,
|
||||
onSelect: (session: SessionRow) => {
|
||||
void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch(
|
||||
(error) => {
|
||||
this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
onCancel: options.onCancel,
|
||||
onCtrlC: options.onCtrlC,
|
||||
onCtrlD: options.onCtrlD,
|
||||
onToggleScope: (selectedSessionId: string) => {
|
||||
void this.toggleSessionPickerScope(selectedSessionId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleSessionPickerSelect(
|
||||
session: SessionRow,
|
||||
applyStartupModes: boolean,
|
||||
): Promise<void> {
|
||||
if (resolve(session.work_dir) !== resolve(this.state.appState.workDir)) {
|
||||
await this.showResumeOtherWorkDirHint(session);
|
||||
if (applyStartupModes) await this.stop(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const switched = await this.resumeSession(session.id);
|
||||
if (!switched) return;
|
||||
if (applyStartupModes) {
|
||||
await this.applyStartupModesToResumedSession(this.requireSession());
|
||||
this.applyStartupPermissionAndPlanToAppState();
|
||||
}
|
||||
this.hideSessionPicker();
|
||||
}
|
||||
|
||||
private showApprovalPanel(payload: ApprovalPanelData): void {
|
||||
this.patchLivePane({ pendingApproval: { data: payload } });
|
||||
notifyTerminalOnce(this.state, `approval:${payload.id}`, {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export interface TUIState {
|
|||
toolOutputExpanded: boolean;
|
||||
sessions: SessionRow[];
|
||||
loadingSessions: boolean;
|
||||
sessionsScope: 'cwd' | 'all';
|
||||
activeDialog: 'session-picker' | 'help' | null;
|
||||
tasksBrowser: TasksBrowserState | undefined;
|
||||
externalEditorRunning: boolean;
|
||||
|
|
@ -94,6 +95,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
|
|||
toolOutputExpanded: false,
|
||||
sessions: [],
|
||||
loadingSessions: false,
|
||||
sessionsScope: 'cwd',
|
||||
activeDialog: null,
|
||||
tasksBrowser: undefined,
|
||||
externalEditorRunning: false,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface ClipboardModule {
|
|||
availableFormats?(): string[];
|
||||
hasText?(): boolean;
|
||||
getText?(): Promise<string>;
|
||||
setText?(text: string): Promise<void>;
|
||||
hasImage(): boolean;
|
||||
getImageBinary(): Promise<Array<number>>;
|
||||
}
|
||||
|
|
|
|||
55
apps/kimi-code/src/utils/clipboard/clipboard-text.ts
Normal file
55
apps/kimi-code/src/utils/clipboard/clipboard-text.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { clipboard } from './clipboard-native';
|
||||
|
||||
function runClipboardCommand(command: string, args: readonly string[], input: string): void {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8', input });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status === 0) return;
|
||||
|
||||
const detail = result.stderr.trim();
|
||||
throw new Error(
|
||||
detail.length > 0
|
||||
? `${command} exited with code ${String(result.status)}: ${detail}`
|
||||
: `${command} exited with code ${String(result.status)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function copyWithPlatformCommand(text: string): Promise<void> {
|
||||
const commands =
|
||||
process.platform === 'darwin'
|
||||
? [{ command: 'pbcopy', args: [] as string[] }]
|
||||
: process.platform === 'win32'
|
||||
? [{ command: 'clip.exe', args: [] as string[] }]
|
||||
: [
|
||||
{ command: 'wl-copy', args: [] as string[] },
|
||||
{ command: 'xclip', args: ['-selection', 'clipboard'] },
|
||||
];
|
||||
|
||||
let lastError: unknown;
|
||||
for (const candidate of commands) {
|
||||
try {
|
||||
runClipboardCommand(candidate.command, candidate.args, text);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError instanceof Error) throw lastError;
|
||||
throw new Error('No clipboard command is available.');
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
const clipboardModule = clipboard;
|
||||
if (clipboardModule?.setText !== undefined) {
|
||||
try {
|
||||
await clipboardModule.setText(text);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to platform clipboard commands below.
|
||||
}
|
||||
}
|
||||
|
||||
await copyWithPlatformCommand(text);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME,
|
||||
KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME,
|
||||
KIMI_CODE_UPDATE_DIR_NAME,
|
||||
KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME,
|
||||
KIMI_CODE_UPDATE_STATE_FILE_NAME,
|
||||
} from '#/constant/app';
|
||||
|
||||
|
|
@ -69,6 +70,13 @@ export function getUpdateInstallLockFile(): string {
|
|||
return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the rollout decision log: `<dataDir>/updates/rollout.log`.
|
||||
*/
|
||||
export function getUpdateRolloutLogFile(): string {
|
||||
return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the user input history file for a given working directory.
|
||||
* Layout: `<share_dir>/user-history/<md5(cwd)>.jsonl`.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { quoteShellArg } from '#/utils/shell-quote';
|
||||
|
||||
export function resolveEditorCommand(configured?: string | null): string | undefined {
|
||||
const candidates = [configured, process.env['VISUAL'], process.env['EDITOR']];
|
||||
for (const c of candidates) {
|
||||
|
|
@ -57,19 +59,3 @@ export async function editInExternalEditor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote the appended temp-file path so spaces survive shell parsing.
|
||||
*/
|
||||
function quotePosixArg(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function quoteCmdArg(value: string): string {
|
||||
return `"${value.replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
return process.platform === 'win32'
|
||||
? quoteCmdArg(value)
|
||||
: quotePosixArg(value);
|
||||
}
|
||||
|
|
|
|||
11
apps/kimi-code/src/utils/shell-quote.ts
Normal file
11
apps/kimi-code/src/utils/shell-quote.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export function quoteShellArg(value: string): string {
|
||||
return process.platform === 'win32' ? quoteCmdArg(value) : quotePosixArg(value);
|
||||
}
|
||||
|
||||
function quotePosixArg(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function quoteCmdArg(value: string): string {
|
||||
return `"${value.replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ describe('update cache', () => {
|
|||
source: 'cdn',
|
||||
checkedAt: '2026-04-23T08:00:00.000Z',
|
||||
latest: '0.5.0',
|
||||
manifest: null,
|
||||
} as const;
|
||||
|
||||
await writeUpdateCache(cache);
|
||||
|
|
@ -64,6 +65,68 @@ describe('update cache', () => {
|
|||
expect(getUpdateStateFile()).toBe(join(dir, 'updates', 'latest.json'));
|
||||
await expect(readUpdateCache()).resolves.toEqual(cache);
|
||||
});
|
||||
|
||||
it('writes and reads back a cache carrying a rollout manifest', async () => {
|
||||
const cache = {
|
||||
source: 'cdn',
|
||||
checkedAt: '2026-04-23T08:00:00.000Z',
|
||||
latest: '0.5.0',
|
||||
manifest: {
|
||||
version: '0.5.0',
|
||||
publishedAt: '2026-04-23T07:00:00.000Z',
|
||||
rollout: [
|
||||
{ percent: 30, delaySeconds: 0 },
|
||||
{ percent: 30, delaySeconds: 43_200 },
|
||||
{ percent: 40, delaySeconds: 86_400 },
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
await writeUpdateCache(cache);
|
||||
|
||||
await expect(readUpdateCache()).resolves.toEqual(cache);
|
||||
});
|
||||
|
||||
it('reads a legacy cache file without a manifest field as manifest null', async () => {
|
||||
mkdirSync(join(dir, 'updates'), { recursive: true });
|
||||
writeFileSync(
|
||||
getUpdateStateFile(),
|
||||
JSON.stringify({
|
||||
source: 'cdn',
|
||||
checkedAt: '2026-04-23T08:00:00.000Z',
|
||||
latest: '0.5.0',
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
await expect(readUpdateCache()).resolves.toEqual({
|
||||
source: 'cdn',
|
||||
checkedAt: '2026-04-23T08:00:00.000Z',
|
||||
latest: '0.5.0',
|
||||
manifest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps latest and treats a malformed manifest field as null', async () => {
|
||||
mkdirSync(join(dir, 'updates'), { recursive: true });
|
||||
writeFileSync(
|
||||
getUpdateStateFile(),
|
||||
JSON.stringify({
|
||||
source: 'cdn',
|
||||
checkedAt: '2026-04-23T08:00:00.000Z',
|
||||
latest: '0.5.0',
|
||||
manifest: { version: 'not-semver', publishedAt: 'nope', rollout: 'bad' },
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
await expect(readUpdateCache()).resolves.toEqual({
|
||||
source: 'cdn',
|
||||
checkedAt: '2026-04-23T08:00:00.000Z',
|
||||
latest: '0.5.0',
|
||||
manifest: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update install state', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fetchLatestVersionFromCdn } from '#/cli/update/cdn';
|
||||
import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
|
||||
import { fetchLatestFromCdn, fetchLatestVersionFromCdn } from '#/cli/update/cdn';
|
||||
import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
|
||||
|
||||
function mockFetchOk(body: string): typeof fetch {
|
||||
return vi.fn(async () => ({
|
||||
|
|
@ -19,11 +19,44 @@ function mockFetchStatus(status: number): typeof fetch {
|
|||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
type Route = { readonly status?: number; readonly body?: string } | Error;
|
||||
|
||||
/** URL-routed fetch mock: unrouted URLs return 404. */
|
||||
function mockRoutedFetch(routes: Record<string, Route>): typeof fetch {
|
||||
return vi.fn(async (input: string | URL) => {
|
||||
const route = routes[String(input)];
|
||||
if (route === undefined) {
|
||||
return { ok: false, status: 404, text: async () => '' };
|
||||
}
|
||||
if (route instanceof Error) throw route;
|
||||
const status = route.status ?? 200;
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => route.body ?? '',
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const MANIFEST_BODY = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
version: '2.0.0',
|
||||
publishedAt: '2026-06-12T00:00:00.000Z',
|
||||
rollout: [
|
||||
{ percent: 30, delaySeconds: 0 },
|
||||
{ percent: 30, delaySeconds: 43_200 },
|
||||
{ percent: 40, delaySeconds: 86_400 },
|
||||
],
|
||||
});
|
||||
|
||||
describe('fetchLatestVersionFromCdn', () => {
|
||||
it('returns the trimmed semver returned by CDN /latest', async () => {
|
||||
const f = mockFetchOk(' 0.5.0\n');
|
||||
await expect(fetchLatestVersionFromCdn(f)).resolves.toBe('0.5.0');
|
||||
expect(f).toHaveBeenCalledWith(KIMI_CODE_CDN_LATEST_URL);
|
||||
expect(f).toHaveBeenCalledWith(
|
||||
KIMI_CODE_CDN_LATEST_URL,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when response is non-2xx', async () => {
|
||||
|
|
@ -47,3 +80,154 @@ describe('fetchLatestVersionFromCdn', () => {
|
|||
await expect(fetchLatestVersionFromCdn(f)).rejects.toThrow(/network down/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLatestFromCdn', () => {
|
||||
it('parses latest.json and returns the manifest', async () => {
|
||||
const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY } });
|
||||
await expect(fetchLatestFromCdn(f)).resolves.toEqual({
|
||||
latest: '2.0.0',
|
||||
manifest: {
|
||||
version: '2.0.0',
|
||||
publishedAt: '2026-06-12T00:00:00.000Z',
|
||||
rollout: [
|
||||
{ percent: 30, delaySeconds: 0 },
|
||||
{ percent: 30, delaySeconds: 43_200 },
|
||||
{ percent: 40, delaySeconds: 86_400 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(f).toHaveBeenCalledWith(
|
||||
KIMI_CODE_CDN_LATEST_JSON_URL,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(f).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores unknown manifest fields (lenient parsing)', async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 99,
|
||||
version: '2.0.0',
|
||||
publishedAt: '2026-06-12T00:00:00.000Z',
|
||||
rollout: [],
|
||||
futureField: { nested: true },
|
||||
});
|
||||
const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } });
|
||||
const result = await fetchLatestFromCdn(f);
|
||||
expect(result.manifest).toEqual({
|
||||
version: '2.0.0',
|
||||
publishedAt: '2026-06-12T00:00:00.000Z',
|
||||
rollout: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults a missing rollout to an empty plan (fully rolled out)', async () => {
|
||||
const body = JSON.stringify({
|
||||
version: '2.0.0',
|
||||
publishedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } });
|
||||
const result = await fetchLatestFromCdn(f);
|
||||
expect(result.manifest?.rollout).toEqual([]);
|
||||
});
|
||||
|
||||
const fallbackCases: ReadonlyArray<readonly [string, Route]> = [
|
||||
['latest.json is missing (HTTP 404)', { status: 404 }],
|
||||
['latest.json fetch throws', new Error('network down')],
|
||||
['body is not valid JSON', { body: 'not json {' }],
|
||||
['version is not semver', { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }],
|
||||
['publishedAt is unparseable', { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }],
|
||||
['a batch percent is out of range', {
|
||||
body: JSON.stringify({
|
||||
version: '2.0.0',
|
||||
publishedAt: '2026-06-12T00:00:00.000Z',
|
||||
rollout: [{ percent: 150, delaySeconds: 0 }],
|
||||
}),
|
||||
}],
|
||||
['a batch delay is negative', {
|
||||
body: JSON.stringify({
|
||||
version: '2.0.0',
|
||||
publishedAt: '2026-06-12T00:00:00.000Z',
|
||||
rollout: [{ percent: 100, delaySeconds: -1 }],
|
||||
}),
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [name, route] of fallbackCases) {
|
||||
it(`falls back to plain /latest when ${name}`, async () => {
|
||||
const f = mockRoutedFetch({
|
||||
[KIMI_CODE_CDN_LATEST_JSON_URL]: route,
|
||||
[KIMI_CODE_CDN_LATEST_URL]: { body: '1.9.0\n' },
|
||||
});
|
||||
await expect(fetchLatestFromCdn(f)).resolves.toEqual({
|
||||
latest: '1.9.0',
|
||||
manifest: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('throws when both latest.json and plain /latest fail', async () => {
|
||||
const f = mockRoutedFetch({
|
||||
[KIMI_CODE_CDN_LATEST_JSON_URL]: { status: 500 },
|
||||
[KIMI_CODE_CDN_LATEST_URL]: { status: 500 },
|
||||
});
|
||||
await expect(fetchLatestFromCdn(f)).rejects.toThrow(/HTTP 500/);
|
||||
});
|
||||
|
||||
it('propagates the plain /latest error when the fallback also breaks', async () => {
|
||||
const f = mockRoutedFetch({
|
||||
[KIMI_CODE_CDN_LATEST_JSON_URL]: new Error('json down'),
|
||||
[KIMI_CODE_CDN_LATEST_URL]: { body: 'not-a-version' },
|
||||
});
|
||||
await expect(fetchLatestFromCdn(f)).rejects.toThrow(/invalid semver/);
|
||||
});
|
||||
|
||||
it('falls back to plain /latest when latest.json hangs past the request timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const f = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||
if (String(input) === KIMI_CODE_CDN_LATEST_JSON_URL) {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new Error('aborted'));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
if (String(input) === KIMI_CODE_CDN_LATEST_URL) {
|
||||
return { ok: true, status: 200, text: async () => '1.9.0\n' };
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => '' };
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = fetchLatestFromCdn(f);
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
latest: '1.9.0',
|
||||
manifest: null,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when plain /latest also hangs past the request timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const f = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new Error('aborted'));
|
||||
}, { once: true });
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = fetchLatestFromCdn(f);
|
||||
const expectation = expect(result).rejects.toThrow(/aborted/);
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
|
||||
await expectation;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,8 +15,14 @@ import { promptForInstallChoice } from '#/cli/update/prompt';
|
|||
import type * as PromptModule from '#/cli/update/prompt';
|
||||
import { refreshUpdateCache } from '#/cli/update/refresh';
|
||||
import type * as RefreshModule from '#/cli/update/refresh';
|
||||
import type * as RolloutModule from '#/cli/update/rollout';
|
||||
import { detectInstallSource } from '#/cli/update/source';
|
||||
import { emptyUpdateCache, type UpdateCache, type UpdateInstallState } from '#/cli/update/types';
|
||||
import {
|
||||
emptyUpdateCache,
|
||||
type UpdateCache,
|
||||
type UpdateInstallState,
|
||||
type UpdateManifest,
|
||||
} from '#/cli/update/types';
|
||||
import type { TuiConfig } from '#/tui/config';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
|
|
@ -28,6 +34,8 @@ const mocks = vi.hoisted(() => ({
|
|||
detectInstallSource: vi.fn(),
|
||||
promptForInstallChoice: vi.fn(),
|
||||
refreshUpdateCache: vi.fn(),
|
||||
resolveUpdateDeviceId: vi.fn(),
|
||||
appendRolloutDecisionLog: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -81,6 +89,16 @@ vi.mock('../../../src/cli/update/refresh', async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/cli/update/rollout', async () => {
|
||||
const actual = await vi.importActual<typeof RolloutModule>('../../../src/cli/update/rollout.js');
|
||||
return {
|
||||
...actual,
|
||||
resolveUpdateDeviceId: mocks.resolveUpdateDeviceId,
|
||||
// Stubbed so preflight tests never write a real rollout.log.
|
||||
appendRolloutDecisionLog: mocks.appendRolloutDecisionLog,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:child_process', async () => {
|
||||
const actual = await vi.importActual<typeof ChildProcess>('node:child_process');
|
||||
return {
|
||||
|
|
@ -94,9 +112,43 @@ function cacheWith(version: string): UpdateCache {
|
|||
source: 'cdn',
|
||||
checkedAt: '2026-04-23T08:00:00.000Z',
|
||||
latest: version,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
|
||||
function manifestFor(version: string, overrides: Partial<UpdateManifest> = {}): UpdateManifest {
|
||||
return {
|
||||
version,
|
||||
publishedAt: '2020-01-01T00:00:00.000Z',
|
||||
rollout: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cacheWithManifest(manifest: UpdateManifest): UpdateCache {
|
||||
return {
|
||||
source: 'cdn',
|
||||
checkedAt: '2026-04-23T08:00:00.000Z',
|
||||
latest: manifest.version,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every bucket delayed by 24h and the clock just started: nobody is eligible. */
|
||||
function heldForEveryone(version: string): UpdateManifest {
|
||||
return manifestFor(version, {
|
||||
publishedAt: new Date(Date.now() - 1_000).toISOString(),
|
||||
rollout: [{ percent: 100, delaySeconds: 86_400 }],
|
||||
});
|
||||
}
|
||||
|
||||
/** Every bucket immediate and publishedAt long past: everybody is eligible. */
|
||||
function releasedForEveryone(version: string): UpdateManifest {
|
||||
return manifestFor(version, {
|
||||
rollout: [{ percent: 100, delaySeconds: 0 }],
|
||||
});
|
||||
}
|
||||
|
||||
function installState(overrides: Partial<UpdateInstallState> = {}): UpdateInstallState {
|
||||
return {
|
||||
active: null,
|
||||
|
|
@ -177,6 +229,8 @@ describe('runUpdatePreflight', () => {
|
|||
mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState());
|
||||
mocks.writeUpdateInstallState.mockResolvedValue(undefined);
|
||||
mocks.loadTuiConfig.mockResolvedValue(tuiConfig());
|
||||
mocks.resolveUpdateDeviceId.mockReturnValue('test-device');
|
||||
mocks.appendRolloutDecisionLog.mockResolvedValue(undefined);
|
||||
mocks.tryAcquireUpdateInstallLock.mockResolvedValue({
|
||||
filePath: '/tmp/kimi-update-install.lock',
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -780,6 +834,230 @@ describe('runUpdatePreflight', () => {
|
|||
source: 'npm-global',
|
||||
}));
|
||||
});
|
||||
|
||||
describe('rollout gating', () => {
|
||||
it('hides a cached update whose batch is not yet eligible', async () => {
|
||||
const held = cacheWithManifest(heldForEveryone('0.5.0'));
|
||||
mocks.readUpdateCache.mockResolvedValue(held);
|
||||
mocks.refreshUpdateCache.mockResolvedValue(held);
|
||||
mocks.detectInstallSource.mockResolvedValue('npm-global');
|
||||
const { stdout, options } = captureOutput();
|
||||
|
||||
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
|
||||
await flushBackgroundInstall();
|
||||
|
||||
expect(stdout.join('')).toBe('');
|
||||
expect(promptForInstallChoice).not.toHaveBeenCalled();
|
||||
expect(detectInstallSource).not.toHaveBeenCalled();
|
||||
expect(mocks.spawn).not.toHaveBeenCalled();
|
||||
// The launch still refreshes the cache in the background so the device
|
||||
// flips to eligible purely by time passing.
|
||||
expect(refreshUpdateCache).toHaveBeenCalledTimes(1);
|
||||
// Both checks of this launch are recorded in the rollout log.
|
||||
expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
phase: 'startup-cache',
|
||||
reason: 'held',
|
||||
current: '0.4.0',
|
||||
latest: '0.5.0',
|
||||
bucket: expect.any(Number),
|
||||
delaySeconds: 86_400,
|
||||
eligibleAt: expect.any(String),
|
||||
}));
|
||||
expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
phase: 'background-refresh',
|
||||
reason: 'held',
|
||||
}));
|
||||
});
|
||||
|
||||
it('starts the background install once the device batch is eligible', async () => {
|
||||
const released = cacheWithManifest(releasedForEveryone('0.5.0'));
|
||||
mocks.readUpdateCache.mockResolvedValue(released);
|
||||
mocks.refreshUpdateCache.mockResolvedValue(released);
|
||||
mocks.detectInstallSource.mockResolvedValue('npm-global');
|
||||
mockSpawnExit(0);
|
||||
const { options } = captureOutput();
|
||||
const track = vi.fn();
|
||||
|
||||
await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue');
|
||||
await flushBackgroundInstall();
|
||||
|
||||
expect(mocks.spawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^npm(\.cmd)?$/),
|
||||
['install', '-g', '@moonshot-ai/kimi-code@0.5.0'],
|
||||
{ detached: true, stdio: 'ignore' },
|
||||
);
|
||||
expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({
|
||||
target_version: '0.5.0',
|
||||
rollout_bucket: expect.any(Number),
|
||||
rollout_delay_seconds: 0,
|
||||
rollout_from_manifest: true,
|
||||
}));
|
||||
expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
phase: 'startup-cache',
|
||||
reason: 'eligible',
|
||||
target: '0.5.0',
|
||||
}));
|
||||
});
|
||||
|
||||
it('prompts with rollout telemetry when eligible and auto-install is disabled', async () => {
|
||||
disableAutoInstall();
|
||||
const released = cacheWithManifest(releasedForEveryone('0.5.0'));
|
||||
mocks.readUpdateCache.mockResolvedValue(released);
|
||||
mocks.refreshUpdateCache.mockResolvedValue(released);
|
||||
mocks.detectInstallSource.mockResolvedValue('npm-global');
|
||||
mocks.promptForInstallChoice.mockResolvedValue('skip');
|
||||
const { options } = captureOutput();
|
||||
const track = vi.fn();
|
||||
|
||||
await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue');
|
||||
|
||||
expect(mocks.promptForInstallChoice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ target: { version: '0.5.0' } }),
|
||||
);
|
||||
expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({
|
||||
latest: '0.5.0',
|
||||
rollout_bucket: expect.any(Number),
|
||||
rollout_delay_seconds: 0,
|
||||
rollout_from_manifest: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses the refreshed manifest for rollout telemetry when the prompt target changes', async () => {
|
||||
disableAutoInstall();
|
||||
const cached = cacheWithManifest(manifestFor('0.6.0', {
|
||||
publishedAt: '2020-01-01T00:00:00.000Z',
|
||||
rollout: [{ percent: 100, delaySeconds: 0 }],
|
||||
}));
|
||||
const refreshed = cacheWithManifest(manifestFor('0.7.0', {
|
||||
publishedAt: '2020-01-01T00:00:00.000Z',
|
||||
rollout: [{ percent: 100, delaySeconds: 43_200 }],
|
||||
}));
|
||||
mocks.readUpdateCache.mockResolvedValue(cached);
|
||||
mocks.refreshUpdateCache.mockResolvedValue(refreshed);
|
||||
mocks.detectInstallSource.mockResolvedValue('npm-global');
|
||||
mocks.promptForInstallChoice.mockResolvedValue('skip');
|
||||
const { options } = captureOutput();
|
||||
const track = vi.fn();
|
||||
|
||||
await expect(runUpdatePreflight('0.5.0', { ...options, track })).resolves.toBe('continue');
|
||||
|
||||
expect(mocks.promptForInstallChoice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ target: { version: '0.7.0' } }),
|
||||
);
|
||||
expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({
|
||||
latest: '0.7.0',
|
||||
rollout_bucket: expect.any(Number),
|
||||
rollout_delay_seconds: 43_200,
|
||||
rollout_from_manifest: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('suppresses the manual-command notice while a homebrew device batch is held', async () => {
|
||||
const held = cacheWithManifest(heldForEveryone('0.5.0'));
|
||||
mocks.readUpdateCache.mockResolvedValue(held);
|
||||
mocks.refreshUpdateCache.mockResolvedValue(held);
|
||||
mocks.detectInstallSource.mockResolvedValue('homebrew');
|
||||
const { stdout, options } = captureOutput();
|
||||
|
||||
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
|
||||
await flushBackgroundInstall();
|
||||
|
||||
expect(stdout.join('')).toBe('');
|
||||
expect(mocks.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start a fresh-check background install while the refreshed manifest is held', async () => {
|
||||
mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache());
|
||||
mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.5.0')));
|
||||
mocks.detectInstallSource.mockResolvedValue('npm-global');
|
||||
const { options } = captureOutput();
|
||||
|
||||
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
|
||||
await flushBackgroundInstall();
|
||||
|
||||
expect(refreshUpdateCache).toHaveBeenCalledTimes(1);
|
||||
expect(detectInstallSource).not.toHaveBeenCalled();
|
||||
expect(mocks.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays silent when the user-visible refresh reveals a held newer version', async () => {
|
||||
disableAutoInstall();
|
||||
mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(releasedForEveryone('0.6.0')));
|
||||
mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.7.0')));
|
||||
mocks.detectInstallSource.mockResolvedValue('npm-global');
|
||||
const { stdout, options } = captureOutput();
|
||||
|
||||
await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue');
|
||||
|
||||
expect(stdout.join('')).toBe('');
|
||||
expect(promptForInstallChoice).not.toHaveBeenCalled();
|
||||
expect(mocks.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('KIMI_CODE_EXPERIMENTAL_FLAG bypasses the rollout: held devices still update', async () => {
|
||||
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1');
|
||||
const held = cacheWithManifest(heldForEveryone('0.5.0'));
|
||||
mocks.readUpdateCache.mockResolvedValue(held);
|
||||
mocks.refreshUpdateCache.mockResolvedValue(held);
|
||||
mocks.detectInstallSource.mockResolvedValue('npm-global');
|
||||
mockSpawnExit(0);
|
||||
const { options } = captureOutput();
|
||||
const track = vi.fn();
|
||||
|
||||
await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue');
|
||||
await flushBackgroundInstall();
|
||||
|
||||
expect(mocks.spawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^npm(\.cmd)?$/),
|
||||
['install', '-g', '@moonshot-ai/kimi-code@0.5.0'],
|
||||
{ detached: true, stdio: 'ignore' },
|
||||
);
|
||||
expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({
|
||||
target_version: '0.5.0',
|
||||
rollout_bypassed: true,
|
||||
}));
|
||||
expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
phase: 'startup-cache',
|
||||
reason: 'experimental',
|
||||
target: '0.5.0',
|
||||
}));
|
||||
});
|
||||
|
||||
it('KIMI_CODE_NO_AUTO_UPDATE still wins over the experimental flag', async () => {
|
||||
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1');
|
||||
vi.stubEnv('KIMI_CODE_NO_AUTO_UPDATE', '1');
|
||||
mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(releasedForEveryone('0.5.0')));
|
||||
const { options } = captureOutput();
|
||||
|
||||
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
|
||||
|
||||
expect(readUpdateCache).not.toHaveBeenCalled();
|
||||
expect(mocks.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats any plan older than 24h as fully rolled out', async () => {
|
||||
disableAutoInstall();
|
||||
const staleRollout = manifestFor('0.5.0', {
|
||||
publishedAt: new Date(Date.now() - 25 * 3_600 * 1_000).toISOString(),
|
||||
rollout: [
|
||||
{ percent: 30, delaySeconds: 0 },
|
||||
{ percent: 30, delaySeconds: 43_200 },
|
||||
{ percent: 40, delaySeconds: 86_400 },
|
||||
],
|
||||
});
|
||||
mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout));
|
||||
mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout));
|
||||
mocks.detectInstallSource.mockResolvedValue('npm-global');
|
||||
mocks.promptForInstallChoice.mockResolvedValue('skip');
|
||||
const { options } = captureOutput();
|
||||
|
||||
await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue');
|
||||
|
||||
expect(mocks.promptForInstallChoice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ target: { version: '0.5.0' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnForSource native', () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { refreshUpdateCache } from '#/cli/update/refresh';
|
||||
import type { UpdateManifest } from '#/cli/update/types';
|
||||
|
||||
const MANIFEST: UpdateManifest = {
|
||||
version: '0.5.0',
|
||||
publishedAt: '2026-05-20T12:00:00.000Z',
|
||||
rollout: [
|
||||
{ percent: 30, delaySeconds: 0 },
|
||||
{ percent: 30, delaySeconds: 43_200 },
|
||||
{ percent: 40, delaySeconds: 86_400 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('refreshUpdateCache', () => {
|
||||
it('writes a fresh cache on successful fetch', async () => {
|
||||
it('writes a fresh cache carrying the manifest on successful fetch', async () => {
|
||||
const writeCache = vi.fn(async () => {});
|
||||
const result = await refreshUpdateCache({
|
||||
fetchLatest: async () => '0.5.0',
|
||||
fetchLatest: async () => ({ latest: '0.5.0', manifest: MANIFEST }),
|
||||
writeCache,
|
||||
now: () => new Date('2026-05-20T12:34:56.000Z'),
|
||||
});
|
||||
|
|
@ -15,12 +26,26 @@ describe('refreshUpdateCache', () => {
|
|||
source: 'cdn',
|
||||
checkedAt: '2026-05-20T12:34:56.000Z',
|
||||
latest: '0.5.0',
|
||||
manifest: MANIFEST,
|
||||
});
|
||||
expect(writeCache).toHaveBeenCalledWith({
|
||||
expect(writeCache).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it('writes a null manifest when the fetch fell back to plain text', async () => {
|
||||
const writeCache = vi.fn(async () => {});
|
||||
const result = await refreshUpdateCache({
|
||||
fetchLatest: async () => ({ latest: '0.5.0', manifest: null }),
|
||||
writeCache,
|
||||
now: () => new Date('2026-05-20T12:34:56.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
source: 'cdn',
|
||||
checkedAt: '2026-05-20T12:34:56.000Z',
|
||||
latest: '0.5.0',
|
||||
manifest: null,
|
||||
});
|
||||
expect(writeCache).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it('propagates fetch errors and skips writeCache so the cache is preserved', async () => {
|
||||
|
|
|
|||
366
apps/kimi-code/test/cli/update/rollout.test.ts
Normal file
366
apps/kimi-code/test/cli/update/rollout.test.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
appendRolloutDecisionLog,
|
||||
decidePassiveUpdateTarget,
|
||||
isRolloutBypassedByExperimentalEnv,
|
||||
isRolloutEligible,
|
||||
MAX_ROLLOUT_DELAY_SECONDS,
|
||||
rolloutBucket,
|
||||
rolloutDelayForBucket,
|
||||
rolloutDelaySeconds,
|
||||
resolveUpdateDeviceId,
|
||||
selectPassiveUpdateTarget,
|
||||
} from '#/cli/update/rollout';
|
||||
import type { RolloutBatch, UpdateManifest } from '#/cli/update/types';
|
||||
|
||||
const STANDARD_ROLLOUT: readonly RolloutBatch[] = [
|
||||
{ percent: 30, delaySeconds: 0 },
|
||||
{ percent: 30, delaySeconds: 43_200 },
|
||||
{ percent: 40, delaySeconds: 86_400 },
|
||||
];
|
||||
|
||||
const PUBLISHED_AT = '2026-06-12T00:00:00.000Z';
|
||||
const PUBLISHED_AT_MS = Date.parse(PUBLISHED_AT);
|
||||
|
||||
function makeManifest(overrides: Partial<UpdateManifest> = {}): UpdateManifest {
|
||||
return {
|
||||
version: '2.0.0',
|
||||
publishedAt: PUBLISHED_AT,
|
||||
rollout: STANDARD_ROLLOUT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function secondsAfterPublish(seconds: number): Date {
|
||||
return new Date(PUBLISHED_AT_MS + seconds * 1000);
|
||||
}
|
||||
|
||||
describe('rolloutBucket', () => {
|
||||
it('is deterministic and within 0-99', () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const bucket = rolloutBucket(`device-${i}`, '2.0.0');
|
||||
expect(bucket).toBe(rolloutBucket(`device-${i}`, '2.0.0'));
|
||||
expect(bucket).toBeGreaterThanOrEqual(0);
|
||||
expect(bucket).toBeLessThan(100);
|
||||
expect(Number.isInteger(bucket)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches pinned vectors (regression guard for the hash layout)', () => {
|
||||
expect(rolloutBucket('device-a', '1.0.0')).toBe(65);
|
||||
expect(rolloutBucket('device-b', '1.0.0')).toBe(76);
|
||||
expect(rolloutBucket('fixed-device', '2.0.0')).toBe(26);
|
||||
});
|
||||
|
||||
it('reshuffles buckets when the version changes', () => {
|
||||
expect(rolloutBucket('device-a', '1.0.1')).toBe(79);
|
||||
expect(rolloutBucket('device-a', '1.0.1')).not.toBe(rolloutBucket('device-a', '1.0.0'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('rolloutDelayForBucket', () => {
|
||||
it('maps buckets to batches at the exact boundaries', () => {
|
||||
expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 0)).toBe(0);
|
||||
expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 29)).toBe(0);
|
||||
expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 30)).toBe(43_200);
|
||||
expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 59)).toBe(43_200);
|
||||
expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 60)).toBe(86_400);
|
||||
expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 99)).toBe(86_400);
|
||||
});
|
||||
|
||||
it('clamps oversized delays to 24h', () => {
|
||||
const rollout: readonly RolloutBatch[] = [{ percent: 100, delaySeconds: 999_999 }];
|
||||
expect(rolloutDelayForBucket(rollout, 50)).toBe(MAX_ROLLOUT_DELAY_SECONDS);
|
||||
});
|
||||
|
||||
it('assigns buckets not covered by the plan to the slowest cohort', () => {
|
||||
const rollout: readonly RolloutBatch[] = [{ percent: 30, delaySeconds: 0 }];
|
||||
expect(rolloutDelayForBucket(rollout, 29)).toBe(0);
|
||||
expect(rolloutDelayForBucket(rollout, 30)).toBe(MAX_ROLLOUT_DELAY_SECONDS);
|
||||
expect(rolloutDelayForBucket(rollout, 99)).toBe(MAX_ROLLOUT_DELAY_SECONDS);
|
||||
});
|
||||
|
||||
it('tolerates percents summing past 100', () => {
|
||||
const rollout: readonly RolloutBatch[] = [
|
||||
{ percent: 60, delaySeconds: 0 },
|
||||
{ percent: 60, delaySeconds: 43_200 },
|
||||
];
|
||||
expect(rolloutDelayForBucket(rollout, 59)).toBe(0);
|
||||
expect(rolloutDelayForBucket(rollout, 99)).toBe(43_200);
|
||||
});
|
||||
|
||||
it('treats an empty plan as fully rolled out', () => {
|
||||
expect(rolloutDelayForBucket([], 0)).toBe(0);
|
||||
expect(rolloutDelayForBucket([], 99)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rolloutDelaySeconds', () => {
|
||||
it('splits 10k devices roughly 30/30/40 across the standard plan', () => {
|
||||
const counts = new Map<number, number>([
|
||||
[0, 0],
|
||||
[43_200, 0],
|
||||
[86_400, 0],
|
||||
]);
|
||||
const manifest = makeManifest();
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const delay = rolloutDelaySeconds(manifest, `device-${i}`);
|
||||
counts.set(delay, (counts.get(delay) ?? 0) + 1);
|
||||
}
|
||||
expect(counts.get(0)).toBeGreaterThanOrEqual(2_700);
|
||||
expect(counts.get(0)).toBeLessThanOrEqual(3_300);
|
||||
expect(counts.get(43_200)).toBeGreaterThanOrEqual(2_700);
|
||||
expect(counts.get(43_200)).toBeLessThanOrEqual(3_300);
|
||||
expect(counts.get(86_400)).toBeGreaterThanOrEqual(3_700);
|
||||
expect(counts.get(86_400)).toBeLessThanOrEqual(4_300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRolloutEligible', () => {
|
||||
const delayedForEveryone = makeManifest({
|
||||
rollout: [{ percent: 100, delaySeconds: 43_200 }],
|
||||
});
|
||||
|
||||
it('is not eligible before publishedAt + delay', () => {
|
||||
const justBefore = new Date(PUBLISHED_AT_MS + 43_200 * 1000 - 1);
|
||||
expect(isRolloutEligible(delayedForEveryone, 'device-a', justBefore)).toBe(false);
|
||||
});
|
||||
|
||||
it('is eligible exactly at publishedAt + delay', () => {
|
||||
expect(isRolloutEligible(delayedForEveryone, 'device-a', secondsAfterPublish(43_200))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is not eligible while publishedAt is still in the future', () => {
|
||||
const manifest = makeManifest({ rollout: [] });
|
||||
expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(-3_600))).toBe(false);
|
||||
});
|
||||
|
||||
it('is always eligible 24h after publish regardless of the plan', () => {
|
||||
const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 999_999 }] });
|
||||
expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(86_400))).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open when publishedAt cannot be parsed', () => {
|
||||
const manifest = makeManifest({ publishedAt: 'not-a-date' });
|
||||
expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(-999_999))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectPassiveUpdateTarget', () => {
|
||||
const now = secondsAfterPublish(60);
|
||||
|
||||
it('falls back to plain latest when manifest is null', () => {
|
||||
expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now)).toEqual({
|
||||
version: '2.0.0',
|
||||
});
|
||||
expect(selectPassiveUpdateTarget('1.0.0', null, null, 'device-a', now)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the manifest version is not newer', () => {
|
||||
const manifest = makeManifest({ rollout: [] });
|
||||
expect(selectPassiveUpdateTarget('2.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull();
|
||||
expect(selectPassiveUpdateTarget('3.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the target once the device batch is eligible', () => {
|
||||
const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 0 }] });
|
||||
expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now)).toEqual({
|
||||
version: '2.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the target while the device batch is not yet eligible', () => {
|
||||
const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] });
|
||||
expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decidePassiveUpdateTarget', () => {
|
||||
const now = secondsAfterPublish(60);
|
||||
|
||||
it('reports no-latest when nothing is known yet', () => {
|
||||
const decision = decidePassiveUpdateTarget('1.0.0', null, null, 'device-a', now);
|
||||
expect(decision).toMatchObject({ target: null, reason: 'no-latest' });
|
||||
});
|
||||
|
||||
it('reports not-newer when the known version is not an upgrade', () => {
|
||||
expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', null, 'device-a', now)).toMatchObject({
|
||||
target: null,
|
||||
reason: 'not-newer',
|
||||
});
|
||||
const manifest = makeManifest({ rollout: [] });
|
||||
expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', manifest, 'device-a', now)).toMatchObject({
|
||||
target: null,
|
||||
reason: 'not-newer',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no-manifest legacy visibility when only plain latest is known', () => {
|
||||
const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now);
|
||||
expect(decision).toMatchObject({
|
||||
target: { version: '2.0.0' },
|
||||
reason: 'no-manifest',
|
||||
bucket: null,
|
||||
delaySeconds: null,
|
||||
eligibleAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports held with bucket, delay and eligibleAt while the batch is gated', () => {
|
||||
const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] });
|
||||
const decision = decidePassiveUpdateTarget(
|
||||
'1.0.0',
|
||||
'2.0.0',
|
||||
manifest,
|
||||
'device-a',
|
||||
secondsAfterPublish(60),
|
||||
);
|
||||
expect(decision).toMatchObject({
|
||||
target: null,
|
||||
reason: 'held',
|
||||
bucket: rolloutBucket('device-a', '2.0.0'),
|
||||
delaySeconds: 86_400,
|
||||
eligibleAt: new Date(PUBLISHED_AT_MS + 86_400 * 1000).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports eligible once the batch delay has passed', () => {
|
||||
const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 43_200 }] });
|
||||
const decision = decidePassiveUpdateTarget(
|
||||
'1.0.0',
|
||||
'2.0.0',
|
||||
manifest,
|
||||
'device-a',
|
||||
secondsAfterPublish(43_200),
|
||||
);
|
||||
expect(decision).toMatchObject({
|
||||
target: { version: '2.0.0' },
|
||||
reason: 'eligible',
|
||||
delaySeconds: 43_200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendRolloutDecisionLog', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'kimi-rollout-log-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('appends one JSON line per decision', async () => {
|
||||
const file = join(dir, 'updates', 'rollout.log');
|
||||
await appendRolloutDecisionLog({ phase: 'startup-cache', reason: 'held' }, file);
|
||||
await appendRolloutDecisionLog({ phase: 'prompt-refresh', reason: 'eligible' }, file);
|
||||
|
||||
const lines = readFileSync(file, 'utf-8').trim().split('\n');
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(JSON.parse(lines[0] ?? '')).toMatchObject({ phase: 'startup-cache', reason: 'held' });
|
||||
expect(JSON.parse(lines[1] ?? '')).toMatchObject({ phase: 'prompt-refresh', reason: 'eligible' });
|
||||
});
|
||||
|
||||
it('resets the file once it grows past the size cap', async () => {
|
||||
const file = join(dir, 'rollout.log');
|
||||
writeFileSync(file, 'x'.repeat(300 * 1024), 'utf-8');
|
||||
await appendRolloutDecisionLog({ reason: 'eligible' }, file);
|
||||
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
expect(content.length).toBeLessThan(1024);
|
||||
expect(JSON.parse(content.trim())).toMatchObject({ reason: 'eligible' });
|
||||
});
|
||||
|
||||
it('never throws on unwritable paths', async () => {
|
||||
await expect(
|
||||
appendRolloutDecisionLog({ reason: 'held' }, '/dev/null/nope/rollout.log'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUpdateDeviceId', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'kimi-rollout-device-id-'));
|
||||
process.env['KIMI_CODE_HOME'] = dir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not create the telemetry device id file when one is missing', () => {
|
||||
const deviceId = resolveUpdateDeviceId();
|
||||
|
||||
expect(deviceId).toMatch(/^[0-9a-f-]+$/);
|
||||
expect(existsSync(join(dir, 'device_id'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses an existing telemetry device id without rewriting it', () => {
|
||||
writeFileSync(join(dir, 'device_id'), 'existing-device-id', 'utf-8');
|
||||
|
||||
expect(resolveUpdateDeviceId()).toBe('existing-device-id');
|
||||
expect(readFileSync(join(dir, 'device_id'), 'utf-8')).toBe('existing-device-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('experimental flag bypass', () => {
|
||||
const now = secondsAfterPublish(60);
|
||||
const heldManifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] });
|
||||
|
||||
it('bypasses a held rollout and reports experimental', () => {
|
||||
const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', heldManifest, 'device-a', now, true);
|
||||
expect(decision).toMatchObject({
|
||||
target: { version: '2.0.0' },
|
||||
reason: 'experimental',
|
||||
bucket: null,
|
||||
delaySeconds: null,
|
||||
eligibleAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('still reports not-newer / no-latest under bypass', () => {
|
||||
expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', heldManifest, 'device-a', now, true)).toMatchObject({
|
||||
target: null,
|
||||
reason: 'not-newer',
|
||||
});
|
||||
expect(decidePassiveUpdateTarget('1.0.0', null, null, 'device-a', now, true)).toMatchObject({
|
||||
target: null,
|
||||
reason: 'no-latest',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks plain-latest visibility as experimental when bypassing', () => {
|
||||
expect(decidePassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now, true)).toMatchObject({
|
||||
target: { version: '2.0.0' },
|
||||
reason: 'experimental',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRolloutBypassedByExperimentalEnv', () => {
|
||||
it('is on for the usual truthy values of KIMI_CODE_EXPERIMENTAL_FLAG', () => {
|
||||
for (const value of ['1', 'true', 'YES', ' on ']) {
|
||||
expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: value })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('is off when unset, blank, or falsy', () => {
|
||||
expect(isRolloutBypassedByExperimentalEnv({})).toBe(false);
|
||||
expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '' })).toBe(false);
|
||||
expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '0' })).toBe(false);
|
||||
expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: 'off' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,11 +4,15 @@ import { handleUpgrade } from '#/cli/sub/upgrade';
|
|||
import type { InstallPromptChoiceValue } from '#/cli/update/prompt';
|
||||
import type { InstallSource, UpdateCache } from '#/cli/update/types';
|
||||
|
||||
function cacheWith(version: string | null): UpdateCache {
|
||||
function cacheWith(
|
||||
version: string | null,
|
||||
manifest: UpdateCache['manifest'] = null,
|
||||
): UpdateCache {
|
||||
return {
|
||||
source: 'cdn',
|
||||
checkedAt: '2026-04-23T08:00:00.000Z',
|
||||
latest: version,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +38,7 @@ function captureOutput(): {
|
|||
|
||||
function createDeps(overrides: {
|
||||
readonly latest?: string | null;
|
||||
readonly manifest?: UpdateCache['manifest'];
|
||||
readonly source?: InstallSource;
|
||||
readonly isInteractive?: boolean;
|
||||
readonly promptForInstallChoice?: () => Promise<InstallPromptChoiceValue>;
|
||||
|
|
@ -48,7 +53,9 @@ function createDeps(overrides: {
|
|||
) => Promise<void>>().mockResolvedValue(undefined);
|
||||
|
||||
return {
|
||||
refreshUpdateCache: vi.fn().mockResolvedValue(cacheWith(overrides.latest ?? '0.5.0')),
|
||||
refreshUpdateCache: vi
|
||||
.fn()
|
||||
.mockResolvedValue(cacheWith(overrides.latest ?? '0.5.0', overrides.manifest ?? null)),
|
||||
detectInstallSource: vi.fn().mockResolvedValue(overrides.source ?? 'npm-global'),
|
||||
promptForInstallChoice:
|
||||
overrides.promptForInstallChoice ?? vi.fn().mockResolvedValue('install'),
|
||||
|
|
@ -206,4 +213,23 @@ describe('handleUpgrade', () => {
|
|||
}));
|
||||
expect(stderr.join('')).toContain('error: failed to check for updates: cdn unavailable');
|
||||
});
|
||||
|
||||
it('ignores rollout gating: installs the latest version while every batch is still held', async () => {
|
||||
const { stdout, writable } = captureOutput();
|
||||
const deps = createDeps({
|
||||
latest: '0.5.0',
|
||||
// Published seconds ago with every device delayed by 24h — passive
|
||||
// update surfaces would hide this version, manual upgrade must not.
|
||||
manifest: {
|
||||
version: '0.5.0',
|
||||
publishedAt: new Date(Date.now() - 1_000).toISOString(),
|
||||
rollout: [{ percent: 100, delaySeconds: 86_400 }],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0);
|
||||
|
||||
expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin');
|
||||
expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DeviceCodeBoxComponent } from '#/tui/components/chrome/device-code-box';
|
||||
|
|
@ -60,4 +61,19 @@ describe('DeviceCodeBoxComponent', () => {
|
|||
const joined = component.render(80).map(strip).join('\n');
|
||||
expect(joined).not.toContain('Press Ctrl-C');
|
||||
});
|
||||
|
||||
it('keeps every line within narrow widths', () => {
|
||||
const component = new DeviceCodeBoxComponent({
|
||||
title,
|
||||
url,
|
||||
code,
|
||||
hint,
|
||||
});
|
||||
|
||||
for (const width of [39, 20, 10, 4]) {
|
||||
for (const line of component.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
@ -91,4 +92,12 @@ describe('WelcomeComponent', () => {
|
|||
|
||||
expect(off).toBe(base);
|
||||
});
|
||||
|
||||
it('keeps every line within the requested width on narrow terminals', () => {
|
||||
for (const width of [0, 1, 2, 4, 10, 39, 80]) {
|
||||
for (const line of new WelcomeComponent(appState).render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog';
|
||||
|
||||
describe('ApiKeyInputDialogComponent', () => {
|
||||
it('keeps every line within narrow widths', () => {
|
||||
const dialog = new ApiKeyInputDialogComponent(
|
||||
'Kimi Code',
|
||||
['Paste your API key below.', 'It will be stored locally.'],
|
||||
() => {},
|
||||
);
|
||||
dialog.focused = true;
|
||||
|
||||
for (const width of [39, 20, 10]) {
|
||||
for (const line of dialog.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
|
|
@ -67,4 +68,14 @@ describe('CustomRegistryImportDialogComponent', () => {
|
|||
value: { url: 'https://example.com/api.json', apiKey: 'sk-tok' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps every line within narrow widths', () => {
|
||||
const { dialog } = makeDialog('https://example.com/very/long/registry/path.json');
|
||||
|
||||
for (const width of [39, 35, 20, 10]) {
|
||||
for (const line of dialog.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
@ -55,6 +56,16 @@ describe('FeedbackInputDialogComponent', () => {
|
|||
expect(rendered).toContain(`${ansiOpen}╰`);
|
||||
});
|
||||
|
||||
it('keeps every line within narrow widths', () => {
|
||||
const { dialog } = makeDialog();
|
||||
|
||||
for (const width of [39, 20, 10, 4]) {
|
||||
for (const line of dialog.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('typing then pressing Enter submits the trimmed value', () => {
|
||||
const { dialog, collected } = makeDialog();
|
||||
for (const ch of 'hello world ') {
|
||||
|
|
|
|||
|
|
@ -245,6 +245,19 @@ describe('GoalQueueEditDialogComponent', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps the edit dialog within narrow widths', () => {
|
||||
const dialog = new GoalQueueEditDialogComponent({
|
||||
goal: goal('g1', 'A very long queued objective for width testing'),
|
||||
onDone: vi.fn(),
|
||||
});
|
||||
|
||||
for (const width of [24, 20, 10]) {
|
||||
for (const line of dialog.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps accepting input after save returns control to the mounted dialog', () => {
|
||||
const onDone = vi.fn();
|
||||
const dialog = new GoalQueueEditDialogComponent({
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ function renderPlain(component: SessionPickerComponent, width = 120): string {
|
|||
return stripAnsi(component.render(width).join('\n'));
|
||||
}
|
||||
|
||||
const BACKSPACE = String.fromCodePoint(127);
|
||||
const ESC = String.fromCodePoint(27);
|
||||
|
||||
describe('SessionPickerComponent', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -308,4 +311,402 @@ describe('SessionPickerComponent', () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('calls onToggleScope with the selected session id when Ctrl+A is pressed', () => {
|
||||
const onToggleScope = vi.fn();
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [
|
||||
{
|
||||
id: 'ses_a',
|
||||
title: 'Session A',
|
||||
work_dir: '/tmp/project-a',
|
||||
updated_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'ses_b',
|
||||
title: 'Session B',
|
||||
work_dir: '/tmp/project-b',
|
||||
updated_at: 2,
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
scope: 'cwd',
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onToggleScope,
|
||||
});
|
||||
|
||||
component.handleInput('\u001B[B');
|
||||
component.handleInput('\u0001');
|
||||
|
||||
expect(onToggleScope).toHaveBeenCalledOnce();
|
||||
expect(onToggleScope).toHaveBeenCalledWith('ses_b');
|
||||
});
|
||||
|
||||
it('calls onToggleScope with the current session id when Ctrl+A is pressed with no sessions', () => {
|
||||
const onToggleScope = vi.fn();
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [],
|
||||
loading: false,
|
||||
currentSessionId: 'ses_current',
|
||||
scope: 'cwd',
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onToggleScope,
|
||||
});
|
||||
|
||||
component.handleInput('\u0001');
|
||||
|
||||
expect(onToggleScope).toHaveBeenCalledOnce();
|
||||
expect(onToggleScope).toHaveBeenCalledWith('ses_current');
|
||||
});
|
||||
|
||||
it('renders the Ctrl+A all-sessions hint when the current cwd has no sessions', () => {
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [],
|
||||
loading: false,
|
||||
currentSessionId: 'ses_current',
|
||||
scope: 'cwd',
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onToggleScope: vi.fn(),
|
||||
});
|
||||
|
||||
const output = renderPlain(component);
|
||||
|
||||
expect(output).toContain('No sessions found.');
|
||||
expect(output).toContain('Ctrl+A all');
|
||||
});
|
||||
|
||||
it('renders all-sessions scope header and Ctrl+A current-cwd hint', () => {
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [
|
||||
{
|
||||
id: 'ses_all',
|
||||
title: 'All scope session',
|
||||
work_dir: '/tmp/project',
|
||||
updated_at: 1,
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
scope: 'all',
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onToggleScope: vi.fn(),
|
||||
});
|
||||
|
||||
const output = renderPlain(component);
|
||||
|
||||
expect(output).toContain('All sessions');
|
||||
expect(output).toContain('↑↓ navigate · Ctrl+A current cwd · Enter select · Esc cancel');
|
||||
});
|
||||
|
||||
it('selects the full session row on Enter', () => {
|
||||
const onSelect = vi.fn();
|
||||
const session = {
|
||||
id: 'ses_row',
|
||||
title: 'Row session',
|
||||
work_dir: '/tmp/project-row',
|
||||
updated_at: 1,
|
||||
};
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [session],
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
scope: 'cwd',
|
||||
onSelect,
|
||||
onCancel: vi.fn(),
|
||||
});
|
||||
|
||||
component.handleInput('\r');
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
expect(onSelect).toHaveBeenCalledWith(session);
|
||||
});
|
||||
|
||||
it('loads the next 50 sessions after moving past the loaded page', () => {
|
||||
const now = new Date('2026-05-11T12:00:00.000Z').getTime();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: Array.from({ length: 120 }, (_, index) => ({
|
||||
id: `ses_${String(index).padStart(4, '0')}`,
|
||||
title: `Session ${String(index).padStart(4, '0')}`,
|
||||
work_dir: '/tmp/project',
|
||||
updated_at: now - index * 1000,
|
||||
})),
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
scope: 'all',
|
||||
pageSize: 50,
|
||||
maxVisibleSessions: 4,
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
});
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
component.handleInput('\u001B[B');
|
||||
}
|
||||
|
||||
const output = renderPlain(component);
|
||||
|
||||
expect(output).toContain('Session 0050');
|
||||
expect(output).toContain('Showing 49-52 of 100 loaded / 120 sessions');
|
||||
});
|
||||
|
||||
it('keeps initial selected session id and loads enough pages for it', () => {
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: Array.from({ length: 80 }, (_, index) => ({
|
||||
id: `ses_${String(index).padStart(4, '0')}`,
|
||||
title: `Session ${String(index).padStart(4, '0')}`,
|
||||
work_dir: '/tmp/project',
|
||||
updated_at: index,
|
||||
})),
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
scope: 'all',
|
||||
initialSelectedSessionId: 'ses_0070',
|
||||
pageSize: 50,
|
||||
maxVisibleSessions: 4,
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
});
|
||||
|
||||
const output = renderPlain(component);
|
||||
|
||||
expect(output).toContain('Session 0070');
|
||||
expect(output).toContain('Showing 69-72 of 80 sessions');
|
||||
});
|
||||
|
||||
it('shows type-to-search copy only when the query is empty', () => {
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [
|
||||
{
|
||||
id: 'ses_search_copy',
|
||||
title: 'Search copy session',
|
||||
work_dir: '/tmp/project',
|
||||
updated_at: 1,
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
});
|
||||
|
||||
const output = renderPlain(component);
|
||||
|
||||
expect(output).toContain('Sessions (type to search)');
|
||||
expect(output).not.toContain('Search:');
|
||||
|
||||
component.handleInput('x');
|
||||
const searchOutput = renderPlain(component);
|
||||
|
||||
expect(searchOutput).toContain('Search: x');
|
||||
expect(searchOutput).not.toContain('Sessions (type to search)');
|
||||
});
|
||||
|
||||
it('fuzzy-filters by session name only when typing', () => {
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [
|
||||
{
|
||||
id: 'ses_alpha',
|
||||
title: 'Alpha session',
|
||||
last_prompt: 'needleprompt do not match',
|
||||
work_dir: '/tmp/needleprompt',
|
||||
updated_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'ses_beta',
|
||||
title: 'Beta session',
|
||||
last_prompt: 'other prompt',
|
||||
work_dir: '/tmp/other',
|
||||
updated_at: 2,
|
||||
},
|
||||
{
|
||||
id: 'ses_fuzzy',
|
||||
title: 'N1e2e3d4l5e session',
|
||||
last_prompt: 'prompt only',
|
||||
work_dir: '/tmp/project',
|
||||
updated_at: 3,
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
});
|
||||
|
||||
component.handleInput('n');
|
||||
component.handleInput('e');
|
||||
component.handleInput('e');
|
||||
component.handleInput('d');
|
||||
component.handleInput('l');
|
||||
component.handleInput('e');
|
||||
|
||||
const output = renderPlain(component);
|
||||
|
||||
expect(output).toContain('Search: needle');
|
||||
expect(output).toContain('N1e2e3d4l5e session');
|
||||
expect(output).not.toContain('Alpha session');
|
||||
expect(output).not.toContain('Beta session');
|
||||
});
|
||||
|
||||
it('clears the query on Backspace and cancels on Esc only after the query is empty', () => {
|
||||
const onCancel = vi.fn();
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [
|
||||
{
|
||||
id: 'ses_alpha',
|
||||
title: 'Alpha session',
|
||||
work_dir: '/tmp/project',
|
||||
updated_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'ses_beta',
|
||||
title: 'Beta session',
|
||||
work_dir: '/tmp/project',
|
||||
updated_at: 2,
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
onSelect: vi.fn(),
|
||||
onCancel,
|
||||
});
|
||||
|
||||
component.handleInput('z');
|
||||
expect(renderPlain(component)).toContain('Search: z');
|
||||
|
||||
component.handleInput(BACKSPACE);
|
||||
expect(renderPlain(component)).not.toContain('Search:');
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
|
||||
component.handleInput('z');
|
||||
expect(renderPlain(component)).toContain('Search: z');
|
||||
|
||||
component.handleInput(ESC);
|
||||
expect(renderPlain(component)).not.toContain('Search:');
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
|
||||
component.handleInput(ESC);
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('selects the filtered session row on Enter', () => {
|
||||
const onSelect = vi.fn();
|
||||
const target = {
|
||||
id: 'ses_gamma',
|
||||
title: 'Gamma session',
|
||||
work_dir: '/tmp/project-gamma',
|
||||
updated_at: 3,
|
||||
};
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [
|
||||
{
|
||||
id: 'ses_alpha',
|
||||
title: 'Alpha session',
|
||||
work_dir: '/tmp/project-alpha',
|
||||
updated_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'ses_beta',
|
||||
title: 'Beta session',
|
||||
work_dir: '/tmp/project-beta',
|
||||
updated_at: 2,
|
||||
},
|
||||
target,
|
||||
],
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
onSelect,
|
||||
onCancel: vi.fn(),
|
||||
});
|
||||
|
||||
component.handleInput('g');
|
||||
component.handleInput('a');
|
||||
component.handleInput('m');
|
||||
component.handleInput('\r');
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
expect(onSelect).toHaveBeenCalledWith(target);
|
||||
});
|
||||
|
||||
it('loads the next 50 matching sessions after moving past the filtered page', () => {
|
||||
const now = new Date('2026-05-11T12:00:00.000Z').getTime();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [
|
||||
...Array.from({ length: 80 }, (_, index) => ({
|
||||
id: `ses_needle_${String(index).padStart(4, '0')}`,
|
||||
title: `Needle ${String(index).padStart(4, '0')}`,
|
||||
work_dir: '/tmp/project',
|
||||
updated_at: now - index * 1000,
|
||||
})),
|
||||
...Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `ses_other_${String(index).padStart(4, '0')}`,
|
||||
title: `Other ${String(index).padStart(4, '0')}`,
|
||||
work_dir: '/tmp/project',
|
||||
updated_at: now - (80 + index) * 1000,
|
||||
})),
|
||||
],
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
pageSize: 50,
|
||||
maxVisibleSessions: 4,
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
});
|
||||
|
||||
component.handleInput('n');
|
||||
component.handleInput('e');
|
||||
component.handleInput('e');
|
||||
component.handleInput('d');
|
||||
component.handleInput('l');
|
||||
component.handleInput('e');
|
||||
for (let i = 0; i < 50; i++) {
|
||||
component.handleInput('\u001B[B');
|
||||
}
|
||||
|
||||
const output = renderPlain(component);
|
||||
|
||||
expect(output).toContain('Needle 0050');
|
||||
expect(output).toContain('Showing 49-52 of 80 loaded / 80 matches');
|
||||
});
|
||||
|
||||
it('calls onToggleScope with the selected filtered session id when Ctrl+A is pressed', () => {
|
||||
const onToggleScope = vi.fn();
|
||||
const component = new SessionPickerComponent({
|
||||
sessions: [
|
||||
{
|
||||
id: 'ses_alpha',
|
||||
title: 'Alpha session',
|
||||
work_dir: '/tmp/project-a',
|
||||
updated_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'ses_beta',
|
||||
title: 'Beta session',
|
||||
work_dir: '/tmp/project-b',
|
||||
updated_at: 2,
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
currentSessionId: '',
|
||||
scope: 'cwd',
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onToggleScope,
|
||||
});
|
||||
|
||||
component.handleInput('b');
|
||||
component.handleInput('e');
|
||||
component.handleInput('t');
|
||||
component.handleInput('a');
|
||||
component.handleInput('\u0001');
|
||||
|
||||
expect(onToggleScope).toHaveBeenCalledOnce();
|
||||
expect(onToggleScope).toHaveBeenCalledWith('ses_beta');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ImageThumbnail } from '#/tui/components/media/image-thumbnail';
|
||||
import type { ImageAttachment } from '#/tui/utils/image-attachment-store';
|
||||
|
||||
const getCapabilitiesMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@earendil-works/pi-tui', async () => {
|
||||
const actual = (await vi.importActual('@earendil-works/pi-tui')) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
getCapabilities: getCapabilitiesMock,
|
||||
};
|
||||
});
|
||||
|
||||
const image: ImageAttachment = {
|
||||
id: 1,
|
||||
kind: 'image',
|
||||
bytes: new Uint8Array([137, 80, 78, 71]),
|
||||
mime: 'image/png',
|
||||
width: 800,
|
||||
height: 600,
|
||||
placeholder: '[image #1 (800×600)]',
|
||||
};
|
||||
|
||||
describe('ImageThumbnail', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('keeps rendered output within narrow widths', () => {
|
||||
getCapabilitiesMock.mockReturnValue({ images: undefined } as never);
|
||||
const component = new ImageThumbnail(image);
|
||||
|
||||
for (const width of [39, 20, 3, 1]) {
|
||||
for (const line of component.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not rebuild inline image children on repeated same-width renders', () => {
|
||||
getCapabilitiesMock.mockReturnValue({ images: 'kitty' } as never);
|
||||
const bufferFrom = vi.spyOn(Buffer, 'from');
|
||||
const component = new ImageThumbnail(image);
|
||||
bufferFrom.mockClear();
|
||||
|
||||
component.render(80);
|
||||
component.render(80);
|
||||
|
||||
expect(bufferFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,17 @@ describe('AssistantMessageComponent', () => {
|
|||
expect(visibleWidth(lines[1] ?? '')).toBe(8);
|
||||
});
|
||||
|
||||
it('keeps assistant lines within very narrow widths', () => {
|
||||
const component = new AssistantMessageComponent();
|
||||
component.updateContent('abcdef');
|
||||
|
||||
for (const width of [1, 2, 4, 10, 39]) {
|
||||
for (const line of component.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('renders unknown markdown fence languages as plain text without stderr noise', () => {
|
||||
const stderr = captureProcessWrite('stderr');
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status';
|
||||
|
|
@ -43,4 +44,18 @@ describe('BackgroundAgentStatusComponent', () => {
|
|||
'✗ explore agent failed in background (Explore project structure · boom)',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps status lines within very narrow widths', () => {
|
||||
const component = new BackgroundAgentStatusComponent({
|
||||
phase: 'started',
|
||||
headline: 'explore agent started in background',
|
||||
detail: 'Explore project structure',
|
||||
});
|
||||
|
||||
for (const width of [1, 2, 4, 10, 39]) {
|
||||
for (const line of component.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SwarmModeMarkerComponent } from '#/tui/components/messages/swarm-markers';
|
||||
import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers';
|
||||
import type { GoalChange } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
|
|
@ -107,3 +108,15 @@ describe('GoalMarkerComponent', () => {
|
|||
expect(strip(marker.render(80))).not.toContain('(ctrl+o)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SwarmModeMarkerComponent', () => {
|
||||
it('keeps marker lines within very narrow widths', () => {
|
||||
const marker = new SwarmModeMarkerComponent('active');
|
||||
|
||||
for (const width of [1, 2, 10, 39]) {
|
||||
for (const line of marker.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGoalReportLines,
|
||||
|
|
@ -115,6 +116,14 @@ describe('GoalSetMessageComponent', () => {
|
|||
chalk.hex(darkColors.primary).bold('Goal set'),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the lifecycle line within narrow widths', () => {
|
||||
for (const width of [39, 20, 10, 4]) {
|
||||
for (const line of new GoalSetMessageComponent().render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpcomingGoalAddedMessageComponent', () => {
|
||||
|
|
@ -131,6 +140,14 @@ describe('UpcomingGoalAddedMessageComponent', () => {
|
|||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps the upcoming-goal confirmation within narrow widths', () => {
|
||||
for (const width of [39, 20, 10, 4]) {
|
||||
for (const line of new UpcomingGoalAddedMessageComponent().render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoalStatusMessageComponent', () => {
|
||||
|
|
@ -140,6 +157,24 @@ describe('GoalStatusMessageComponent', () => {
|
|||
expect(rendered[0]).toBe('');
|
||||
expect(strip([rendered[1]!])).toContain('╭ Goal · active ');
|
||||
});
|
||||
|
||||
it('wraps objective blockquotes without clipping them at 80 columns', () => {
|
||||
const rendered = new GoalStatusMessageComponent(
|
||||
goal({ objective: 'word '.repeat(30).trim() }),
|
||||
).render(80);
|
||||
|
||||
expect(strip(rendered)).not.toContain('...');
|
||||
});
|
||||
|
||||
it('keeps the status box within narrow widths', () => {
|
||||
const rendered = new GoalStatusMessageComponent(goal({ objective: '管理飞书日历的技能描述 '.repeat(4).trim() }));
|
||||
|
||||
for (const width of [39, 24, 20, 10]) {
|
||||
for (const line of rendered.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoalCompletionMessageComponent', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CronMessageComponent } from '#/tui/components/messages/cron-message';
|
||||
import { NoticeMessageComponent } from '#/tui/components/messages/status-message';
|
||||
|
||||
function strip(text: string): string {
|
||||
|
|
@ -19,3 +21,21 @@ describe('NoticeComponent', () => {
|
|||
expect(lines[2]).toContain('Plan will be created here: /tmp/plans/test-plan.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CronMessageComponent', () => {
|
||||
it('keeps title, detail, and prompt within narrow widths', () => {
|
||||
const component = new CronMessageComponent('Please investigate the reminder payload and report back.', {
|
||||
cron: '*/15 * * * *',
|
||||
jobId: 'job-with-a-very-long-identifier-for-width-testing',
|
||||
recurring: true,
|
||||
missedCount: 3,
|
||||
stale: true,
|
||||
});
|
||||
|
||||
for (const width of [39, 20, 10, 4]) {
|
||||
for (const line of component.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { TUI } from '@earendil-works/pi-tui';
|
||||
import { visibleWidth, type TUI } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ThinkingComponent } from '#/tui/components/messages/thinking';
|
||||
|
|
@ -80,4 +80,13 @@ describe('ThinkingComponent', () => {
|
|||
expect(collapsed).not.toContain('line7');
|
||||
expect(collapsed).toContain('ctrl+o to expand');
|
||||
});
|
||||
|
||||
it('keeps the finalized truncation footer within the requested render width', () => {
|
||||
const component = new ThinkingComponent(longThinking, true, 'live');
|
||||
component.finalize();
|
||||
|
||||
for (const line of component.render(37)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(37);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { TUI } from '@earendil-works/pi-tui';
|
||||
import { visibleWidth, type TUI } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
|
|
@ -49,6 +49,27 @@ describe('ToolCallComponent', () => {
|
|||
expect(out).not.toContain(`${String.fromCodePoint(0x23fa, 0xfe0e)} Used Read`);
|
||||
});
|
||||
|
||||
it('keeps collapsed tool-call lines within very narrow widths', () => {
|
||||
const component = new ToolCallComponent(
|
||||
{
|
||||
id: 'call_narrow_read',
|
||||
name: 'Read',
|
||||
args: { path: 'very/long/path/to/foo.ts' },
|
||||
},
|
||||
{
|
||||
tool_call_id: 'call_narrow_read',
|
||||
output: 'content',
|
||||
is_error: false,
|
||||
},
|
||||
);
|
||||
|
||||
for (const width of [1, 2, 4, 10, 39]) {
|
||||
for (const line of component.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps collapsed tool results short and expands on demand', () => {
|
||||
const component = new ToolCallComponent(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated';
|
||||
|
|
@ -59,4 +60,18 @@ describe('TruncatedOutputComponent', () => {
|
|||
expect(out).toContain('d');
|
||||
expect(out).not.toContain('more lines, ctrl+o');
|
||||
});
|
||||
|
||||
it('keeps the truncation footer within the requested render width', () => {
|
||||
const output = Array.from({ length: 20 }, (_, i) => `line ${String(i)}`).join('\n');
|
||||
const component = new TruncatedOutputComponent(output, {
|
||||
expanded: false,
|
||||
isError: false,
|
||||
maxLines: 3,
|
||||
indent: 2,
|
||||
});
|
||||
|
||||
for (const line of component.render(37)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(37);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,6 +68,16 @@ describe('UsagePanelComponent', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps the bordered panel within narrow terminal widths', () => {
|
||||
const component = new UsagePanelComponent(() => ['Session usage', ' kimi input 2.0k'], 'primary');
|
||||
|
||||
for (const width of [39, 24, 20, 10, 4, 1]) {
|
||||
for (const line of component.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rebuilds its body from the active palette on invalidate', () => {
|
||||
// Emit the resolved palette value as visible text so the assertion holds
|
||||
// regardless of chalk's colour level in the test environment.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { UserMessageComponent } from '#/tui/components/messages/user-message';
|
||||
|
|
@ -20,4 +21,14 @@ describe('UserMessageComponent', () => {
|
|||
expect(out).not.toContain('\u001B_G');
|
||||
expect(out).not.toContain('\u001B]1337;File=');
|
||||
});
|
||||
|
||||
it('keeps user lines within very narrow widths', () => {
|
||||
const component = new UserMessageComponent('please inspect the attached output', []);
|
||||
|
||||
for (const width of [1, 2, 4, 10, 39]) {
|
||||
for (const line of component.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PlanBoxComponent } from '#/tui/components/messages/plan-box';
|
||||
|
|
@ -89,6 +90,21 @@ describe('PlanBoxComponent', () => {
|
|||
expect(top).not.toContain('plan:');
|
||||
});
|
||||
|
||||
it('keeps every line within narrow widths', () => {
|
||||
const box = new PlanBoxComponent(
|
||||
'# Hello\n\n' + 'step with a fairly long description '.repeat(4),
|
||||
theme,
|
||||
darkColors.success,
|
||||
'/tmp/projects/foo/.kimi-code/plans/very-long-slug-name.md',
|
||||
);
|
||||
|
||||
for (const width of [39, 14, 10, 8, 4, 1]) {
|
||||
for (const line of box.render(width)) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('renders all plan lines without a truncation footer', () => {
|
||||
const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n');
|
||||
const box = new PlanBoxComponent(plan, theme, darkColors.success);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ describe('createTUIState', () => {
|
|||
expect(state.activeDialog).toBeNull();
|
||||
expect(state.externalEditorRunning).toBe(false);
|
||||
expect(state.loadingSessions).toBe(false);
|
||||
expect(state.sessionsScope).toBe('cwd');
|
||||
expect(state.activitySpinner).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1181,12 +1181,32 @@ command = "vim"
|
|||
const { driver, session } = await makeDriver();
|
||||
|
||||
driver.state.appState.streamingPhase = 'waiting';
|
||||
driver.state.editor.setText('draft while streaming');
|
||||
driver.state.editor.onEscape?.();
|
||||
|
||||
expect(session.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(driver.state.editor.getText()).toBe('draft while streaming');
|
||||
|
||||
session.cancel.mockClear();
|
||||
driver.state.appState.streamingPhase = 'waiting';
|
||||
driver.state.editor.setText('');
|
||||
driver.state.editor.onCtrlC?.();
|
||||
|
||||
expect(session.cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears streaming editor text before cancelling the active turn on Ctrl-C', async () => {
|
||||
const { driver, session } = await makeDriver();
|
||||
|
||||
driver.state.appState.streamingPhase = 'waiting';
|
||||
driver.state.editor.setText('draft while streaming');
|
||||
|
||||
driver.state.editor.onCtrlC?.();
|
||||
|
||||
expect(driver.state.editor.getText()).toBe('');
|
||||
expect(session.cancel).not.toHaveBeenCalled();
|
||||
expect(driver.state.appState.streamingPhase).toBe('waiting');
|
||||
|
||||
driver.state.editor.onCtrlC?.();
|
||||
|
||||
expect(session.cancel).toHaveBeenCalledTimes(1);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/co
|
|||
import { BannerComponent } from '#/tui/components/chrome/banner';
|
||||
import { WelcomeComponent } from '#/tui/components/chrome/welcome';
|
||||
import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui';
|
||||
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
|
||||
import {
|
||||
DISABLE_TERMINAL_THEME_REPORTING,
|
||||
ENABLE_TERMINAL_THEME_REPORTING,
|
||||
|
|
@ -20,6 +21,11 @@ vi.mock('#/tui/commands/prompts', async (importOriginal) => {
|
|||
const actual = await importOriginal<typeof import('#/tui/commands/prompts')>();
|
||||
return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() };
|
||||
});
|
||||
vi.mock('#/utils/clipboard/clipboard-text', () => ({
|
||||
copyTextToClipboard: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const copyTextToClipboardMock = vi.mocked(copyTextToClipboard);
|
||||
|
||||
interface StartupDriver {
|
||||
state: TUIState;
|
||||
|
|
@ -694,6 +700,282 @@ describe('KimiTUI startup', () => {
|
|||
expect(driver.state.appState.planMode).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles the sessions picker from current cwd to all sessions with Ctrl+A', async () => {
|
||||
const currentWorkDirSession = {
|
||||
id: 'ses-cwd',
|
||||
title: 'Current cwd session',
|
||||
workDir: '/tmp/proj-a',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const otherWorkDirSession = {
|
||||
id: 'ses-other-cwd',
|
||||
title: 'Other cwd session',
|
||||
workDir: '/tmp/proj-b',
|
||||
updatedAt: Date.now() - 1000,
|
||||
};
|
||||
const listSessions = vi.fn(async (input: { workDir?: string } = {}) => {
|
||||
if (input.workDir === '/tmp/proj-a') return [currentWorkDirSession];
|
||||
return [currentWorkDirSession, otherWorkDirSession];
|
||||
});
|
||||
const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions });
|
||||
const driver = makeDriver(harness, makeStartupInput());
|
||||
await expect(driver.init()).resolves.toBe(false);
|
||||
|
||||
await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker();
|
||||
const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void };
|
||||
picker.handleInput('\u0001');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(listSessions).toHaveBeenNthCalledWith(1, { workDir: '/tmp/proj-a' });
|
||||
expect(listSessions).toHaveBeenNthCalledWith(2, {});
|
||||
expect(driver.state.sessionsScope).toBe('all');
|
||||
expect(driver.state.sessions.map((session) => session.id)).toEqual([
|
||||
'ses-cwd',
|
||||
'ses-other-cwd',
|
||||
]);
|
||||
});
|
||||
|
||||
it('toggles the sessions picker from all sessions back to current cwd with Ctrl+A', async () => {
|
||||
const currentWorkDirSession = {
|
||||
id: 'ses-cwd',
|
||||
title: 'Current cwd session',
|
||||
workDir: '/tmp/proj-a',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const otherWorkDirSession = {
|
||||
id: 'ses-other-cwd',
|
||||
title: 'Other cwd session',
|
||||
workDir: '/tmp/proj-b',
|
||||
updatedAt: Date.now() - 1000,
|
||||
};
|
||||
const listSessions = vi.fn(async (input: { workDir?: string } = {}) => {
|
||||
if (input.workDir === '/tmp/proj-a') return [currentWorkDirSession];
|
||||
return [currentWorkDirSession, otherWorkDirSession];
|
||||
});
|
||||
const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions });
|
||||
const driver = makeDriver(harness, makeStartupInput());
|
||||
await expect(driver.init()).resolves.toBe(false);
|
||||
|
||||
await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker();
|
||||
const firstPicker = driver.state.editorContainer.children[0] as { handleInput(data: string): void };
|
||||
firstPicker.handleInput('\u0001');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const allPicker = driver.state.editorContainer.children[0] as { handleInput(data: string): void };
|
||||
allPicker.handleInput('\u0001');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(listSessions).toHaveBeenNthCalledWith(3, { workDir: '/tmp/proj-a' });
|
||||
expect(driver.state.sessionsScope).toBe('cwd');
|
||||
expect(driver.state.sessions.map((session) => session.id)).toEqual(['ses-cwd']);
|
||||
});
|
||||
|
||||
it('does not remount the session picker after it is closed while a scope toggle is pending', async () => {
|
||||
const currentWorkDirSession = {
|
||||
id: 'ses-cwd',
|
||||
title: 'Current cwd session',
|
||||
workDir: '/tmp/proj-a',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const otherWorkDirSession = {
|
||||
id: 'ses-other-cwd',
|
||||
title: 'Other cwd session',
|
||||
workDir: '/tmp/proj-b',
|
||||
updatedAt: Date.now() - 1000,
|
||||
};
|
||||
let resolveAllSessions: ((value: unknown[]) => void) | undefined;
|
||||
const listSessions = vi.fn((input: { workDir?: string } = {}) => {
|
||||
if (input.workDir === '/tmp/proj-a') return Promise.resolve([currentWorkDirSession]);
|
||||
return new Promise<unknown[]>((resolve) => {
|
||||
resolveAllSessions = resolve;
|
||||
});
|
||||
});
|
||||
const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions });
|
||||
const driver = makeDriver(harness, makeStartupInput());
|
||||
const mountSessionPicker = vi.spyOn(
|
||||
driver as unknown as { mountSessionPicker(options: unknown): void },
|
||||
'mountSessionPicker',
|
||||
);
|
||||
await expect(driver.init()).resolves.toBe(false);
|
||||
|
||||
await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker();
|
||||
expect(mountSessionPicker).toHaveBeenCalledTimes(1);
|
||||
|
||||
const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void };
|
||||
picker.handleInput('\u0001');
|
||||
(driver as unknown as { hideSessionPicker(): void }).hideSessionPicker();
|
||||
resolveAllSessions?.([currentWorkDirSession, otherWorkDirSession]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(driver.state.activeDialog).toBeNull();
|
||||
expect(mountSessionPicker).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears the sessions picker search query when toggling scope with Ctrl+A', async () => {
|
||||
const currentWorkDirSession = {
|
||||
id: 'ses-cwd',
|
||||
title: 'Current cwd session',
|
||||
workDir: '/tmp/proj-a',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const otherWorkDirSession = {
|
||||
id: 'ses-other-cwd',
|
||||
title: 'Other cwd session',
|
||||
workDir: '/tmp/proj-b',
|
||||
updatedAt: Date.now() - 1000,
|
||||
};
|
||||
const listSessions = vi.fn(async (input: { workDir?: string } = {}) => {
|
||||
if (input.workDir === '/tmp/proj-a') return [currentWorkDirSession];
|
||||
return [currentWorkDirSession, otherWorkDirSession];
|
||||
});
|
||||
const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions });
|
||||
const driver = makeDriver(harness, makeStartupInput());
|
||||
await expect(driver.init()).resolves.toBe(false);
|
||||
|
||||
await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker();
|
||||
const firstPicker = driver.state.editorContainer.children[0] as {
|
||||
handleInput(data: string): void;
|
||||
render(width: number): string[];
|
||||
};
|
||||
firstPicker.handleInput('c');
|
||||
firstPicker.handleInput('w');
|
||||
firstPicker.handleInput('d');
|
||||
expect(firstPicker.render(160).join('\n')).toContain('Search: cwd');
|
||||
|
||||
firstPicker.handleInput('\u0001');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const allPicker = driver.state.editorContainer.children[0] as {
|
||||
handleInput(data: string): void;
|
||||
render(width: number): string[];
|
||||
};
|
||||
const output = allPicker.render(160).join('\n');
|
||||
|
||||
expect(driver.state.sessionsScope).toBe('all');
|
||||
expect(output).toContain('All sessions');
|
||||
expect(output).toContain('(type to search)');
|
||||
expect(output).not.toContain('Search: cwd');
|
||||
});
|
||||
|
||||
it('does not resume a session from a different cwd and shows a cd hint', async () => {
|
||||
const currentWorkDirSession = {
|
||||
id: 'ses-cwd',
|
||||
title: 'Current cwd session',
|
||||
workDir: '/tmp/proj-a',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const otherWorkDirSession = {
|
||||
id: 'ses-other-cwd',
|
||||
title: 'Other cwd session',
|
||||
workDir: '/tmp/proj-b',
|
||||
updatedAt: Date.now() - 1000,
|
||||
};
|
||||
const resumeSession = vi.fn(async () => makeSession({ id: 'ses-other-cwd' }));
|
||||
const harness = makeHarness(makeSession({ id: 'ses-current' }), {
|
||||
resumeSession,
|
||||
listSessions: vi.fn(async () => [currentWorkDirSession, otherWorkDirSession]),
|
||||
});
|
||||
const driver = makeDriver(harness, makeStartupInput());
|
||||
await expect(driver.init()).resolves.toBe(false);
|
||||
copyTextToClipboardMock.mockClear();
|
||||
|
||||
await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker();
|
||||
const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void };
|
||||
picker.handleInput('\u001B[B');
|
||||
picker.handleInput('\r');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(driver.state.activeDialog).toBeNull();
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
|
||||
"cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
const transcript = driver.state.transcriptContainer.render(160).join('\n');
|
||||
expect(transcript).toContain('Current session is in a different working directory.');
|
||||
expect(transcript).toContain(
|
||||
"To resume, run: cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
expect(transcript).toContain(
|
||||
"To resume, run: cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
expect(transcript).toContain('Command copied to clipboard');
|
||||
});
|
||||
|
||||
it('copies a shell-safe resume command for another cwd with metacharacters', async () => {
|
||||
const currentWorkDirSession = {
|
||||
id: 'ses-cwd',
|
||||
title: 'Current cwd session',
|
||||
workDir: '/tmp/proj-a',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const otherWorkDirSession = {
|
||||
id: 'ses-other-cwd',
|
||||
title: 'Other cwd session',
|
||||
workDir: '/tmp/proj$(touch /tmp/pwned)',
|
||||
updatedAt: Date.now() - 1000,
|
||||
};
|
||||
const resumeSession = vi.fn(async () => makeSession({ id: 'ses-other-cwd' }));
|
||||
const harness = makeHarness(makeSession({ id: 'ses-current' }), {
|
||||
resumeSession,
|
||||
listSessions: vi.fn(async () => [currentWorkDirSession, otherWorkDirSession]),
|
||||
});
|
||||
const driver = makeDriver(harness, makeStartupInput());
|
||||
await expect(driver.init()).resolves.toBe(false);
|
||||
copyTextToClipboardMock.mockClear();
|
||||
|
||||
await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker();
|
||||
const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void };
|
||||
picker.handleInput('\u001B[B');
|
||||
picker.handleInput('\r');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
|
||||
"cd '/tmp/proj$(touch /tmp/pwned)' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
const transcript = driver.state.transcriptContainer.render(160).join('\n');
|
||||
expect(transcript).toContain(
|
||||
"To resume, run: cd '/tmp/proj$(touch /tmp/pwned)' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
});
|
||||
|
||||
it('exits after picking another cwd from the startup picker', async () => {
|
||||
const currentWorkDirSession = {
|
||||
id: 'ses-cwd',
|
||||
title: 'Current cwd session',
|
||||
workDir: '/tmp/proj-a',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const otherWorkDirSession = {
|
||||
id: 'ses-other-cwd',
|
||||
title: 'Other cwd session',
|
||||
workDir: '/tmp/proj-b',
|
||||
updatedAt: Date.now() - 1000,
|
||||
};
|
||||
const resumeSession = vi.fn(async () => makeSession({ id: 'ses-other-cwd' }));
|
||||
const harness = makeHarness(makeSession({ id: 'ses-current' }), {
|
||||
resumeSession,
|
||||
listSessions: vi.fn(async () => [currentWorkDirSession, otherWorkDirSession]),
|
||||
});
|
||||
const driver = makeDriver(harness, makeStartupInput({ session: '' }));
|
||||
const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined);
|
||||
copyTextToClipboardMock.mockClear();
|
||||
|
||||
await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false);
|
||||
await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker();
|
||||
|
||||
const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void };
|
||||
picker.handleInput('\u001B[B');
|
||||
picker.handleInput('\r');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
|
||||
"cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
expect(stop).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('does not apply startup flags when switching sessions via the /sessions picker', async () => {
|
||||
const initial = makeSession({ id: 'ses-1' });
|
||||
const picked = makeSession({
|
||||
|
|
|
|||
56
apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts
Normal file
56
apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clipboard } from '#/utils/clipboard/clipboard-native';
|
||||
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawnSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/utils/clipboard/clipboard-native', () => ({
|
||||
clipboard: {
|
||||
setText: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const clipboardMock = clipboard as unknown as { setText: ReturnType<typeof vi.fn> };
|
||||
const spawnSyncMock = vi.mocked(spawnSync);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spawnSyncMock.mockImplementation(() => {
|
||||
throw new Error('platform clipboard fallback should not run');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyTextToClipboard', () => {
|
||||
it('copies text with the native clipboard when available', async () => {
|
||||
clipboardMock.setText.mockResolvedValue(undefined);
|
||||
|
||||
await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined();
|
||||
expect(clipboardMock.setText).toHaveBeenCalledWith('cd "/tmp/proj-b"');
|
||||
});
|
||||
|
||||
it('keeps native clipboard method context when copying text', async () => {
|
||||
clipboardMock.setText.mockImplementation(function (this: unknown, text: string): void {
|
||||
expect(this).toBe(clipboardMock);
|
||||
expect(text).toBe('cd "/tmp/proj-b"');
|
||||
});
|
||||
|
||||
await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws an Error when all platform clipboard commands fail', async () => {
|
||||
clipboardMock.setText = undefined as unknown as ReturnType<typeof vi.fn>;
|
||||
spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType<typeof spawnSync>);
|
||||
|
||||
await expect(copyTextToClipboard('cd "/tmp/proj-b"')).rejects.toBeInstanceOf(Error);
|
||||
await expect(copyTextToClipboard('cd "/tmp/proj-b"')).rejects.toThrow(
|
||||
/(?:clip\.exe|pbcopy|wl-copy|xclip) exited with code 1: missing/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,8 @@ export default defineConfig({
|
|||
format: ['esm'],
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
dts: false,
|
||||
hash: false,
|
||||
banner: {
|
||||
js: [
|
||||
'#!/usr/bin/env node',
|
||||
|
|
@ -38,4 +40,8 @@ export default defineConfig({
|
|||
// PTY fails with "Failed to load native module: pty.node".
|
||||
neverBundle: ['node-pty'],
|
||||
},
|
||||
outputOptions: {
|
||||
codeSplitting: false,
|
||||
entryFileNames: 'main.mjs',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ $KIMI_CODE_HOME (default: ~/.kimi-code)
|
|||
├── updates/
|
||||
│ ├── latest.json
|
||||
│ ├── install.json
|
||||
│ └── install.lock
|
||||
│ ├── install.lock
|
||||
│ └── rollout.log
|
||||
└── user-history/
|
||||
└── <md5(workDir)>.jsonl
|
||||
```
|
||||
|
|
@ -93,7 +94,7 @@ The first time the `Grep` tool needs ripgrep, the CLI can automatically download
|
|||
|
||||
When reporting a bug, prefer exporting the relevant session with `kimi export` (see [kimi command](../reference/kimi-command.md)); the session log is included in the export by default. Add `--no-include-global-log` if you do not want to share the global log.
|
||||
|
||||
The three files under `updates/` (`latest.json`, `install.json`, `install.lock`) are maintained automatically by the auto-update mechanism and normally do not need manual editing.
|
||||
The files under `updates/` (`latest.json`, `install.json`, `install.lock`, `rollout.log`) are maintained automatically by the auto-update mechanism and normally do not need manual editing. `rollout.log` records which staged-rollout case each update check hit, which helps explain when a device will receive a new release.
|
||||
|
||||
## Input history
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,45 @@ outline: 2
|
|||
|
||||
This page documents the changes in each Kimi Code CLI release.
|
||||
|
||||
## 0.15.0 (2026-06-15)
|
||||
|
||||
### Features
|
||||
|
||||
- Add an all-sessions picker view with name search, paginated browsing, and clipboard-ready resume commands for sessions in other working directories.
|
||||
- Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Recover resumed sessions when an interrupted tool call result was not recorded.
|
||||
- Stop writing resume version markers into persisted agent metadata.
|
||||
- Do not carry obsolete legacy loop, background, plan, yolo, or unknown experimental flags into migrated config files.
|
||||
- Repair mismatched JSON Schema types emitted by Xcode 26.5 MCP server for Moonshot compatibility.
|
||||
|
||||
### Polish
|
||||
|
||||
- Keep TUI components within narrow terminal widths by wrapping, compacting, or truncating lines that could exceed the render width.
|
||||
- Prompt the CLI to show one brief same-language status sentence before non-trivial tool calls.
|
||||
- Extend the same-language rule to the model's reasoning, so thinking follows the user's language while keeping code and technical terms in their original form.
|
||||
- Read media files using header-detected types before falling back to media extensions.
|
||||
- Prioritize clearing draft editor text before Ctrl-C cancels an active stream.
|
||||
- Collapse hidden directories in the workspace prompt and explain how to inspect them.
|
||||
- Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked.
|
||||
- Show the all-sessions toggle hint when the current working directory has no sessions.
|
||||
- Clarify that compaction summaries must be emitted in the final answer.
|
||||
- Clarify AGENTS.md prompt guidance and mark truncated instruction files.
|
||||
|
||||
### Refactors
|
||||
|
||||
- Resolve model capabilities through a static lookup instead of instantiating a temporary provider.
|
||||
- Decouple agent skill access from session-specific registry implementations.
|
||||
- Optimize the npm packaging system.
|
||||
|
||||
## 0.14.3 (2026-06-14)
|
||||
|
||||
### Polish
|
||||
|
||||
- Refresh provider model metadata before opening the model picker.
|
||||
|
||||
## 0.14.2 (2026-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code)
|
|||
├── updates/
|
||||
│ ├── latest.json
|
||||
│ ├── install.json
|
||||
│ └── install.lock
|
||||
│ ├── install.lock
|
||||
│ └── rollout.log
|
||||
└── user-history/
|
||||
└── <md5(workDir)>.jsonl
|
||||
```
|
||||
|
|
@ -93,7 +94,7 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code)
|
|||
|
||||
报 bug 时,优先用 `kimi export` 导出相关会话(详见 [kimi 命令](../reference/kimi-command.md));会话日志默认包含在导出包里。不想分享全局日志时加 `--no-include-global-log`。
|
||||
|
||||
`updates/` 下的三个文件(`latest.json`、`install.json`、`install.lock`)由自动更新机制维护,通常无需手动编辑。
|
||||
`updates/` 下的文件(`latest.json`、`install.json`、`install.lock`、`rollout.log`)由自动更新机制维护,通常无需手动编辑。`rollout.log` 记录每次更新检查命中的灰度分批情况,可用于排查设备何时能收到新版本。
|
||||
|
||||
## 输入历史
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,45 @@ outline: 2
|
|||
|
||||
本页记录 Kimi Code CLI 每个版本的变更内容。
|
||||
|
||||
## 0.15.0(2026-06-15)
|
||||
|
||||
### 新功能
|
||||
|
||||
- 新增全会话选择器视图,支持按名称搜索、分页浏览,以及为其他工作目录中的会话生成可复制的恢复命令。
|
||||
- 新增对 legacy SSE MCP server 的支持,与 stdio 和 streamable HTTP 传输方式并存。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复中断的工具调用结果未被记录时,已恢复会话无法继续使用的问题。
|
||||
- 停止将恢复版本标记写入持久化的 agent 元数据。
|
||||
- 迁移后的配置文件中不再包含已废弃的 legacy loop、background、plan、yolo 或未知的实验性 flag。
|
||||
- 修复 Xcode 26.5 MCP server 发出的 JSON Schema 类型与 Moonshot 不兼容的问题。
|
||||
|
||||
### 优化
|
||||
|
||||
- 通过换行、压缩或截断可能超出渲染宽度的行,使 TUI 组件保持在窄终端宽度内。
|
||||
- 在调用较重要的工具前,提示 CLI 以用户当前语言显示一句简短的状态说明。
|
||||
- 将同语言规则扩展到模型的推理过程,使思考内容跟随用户语言,同时保留代码和技术术语的原始形式。
|
||||
- 读取媒体文件时优先使用文件头检测到的类型,再回退到媒体扩展名。
|
||||
- 在 Ctrl-C 取消活跃流之前,优先清除草稿编辑器文本。
|
||||
- 在工作区提示词中折叠隐藏目录,并说明如何查看它们。
|
||||
- 在已加载 skill 的上下文块中包含 skill 的目录,以便 agent 在调用 skill 后能够定位其打包资源(脚本、模板)。
|
||||
- 当前工作目录没有会话时,显示全会话切换提示。
|
||||
- 明确压缩摘要必须在最终答案中输出。
|
||||
- 明确 AGENTS.md 提示词指导,并标记被截断的指令文件。
|
||||
|
||||
### 重构
|
||||
|
||||
- 通过静态查找而非实例化临时 provider 来解析模型能力。
|
||||
- 将 agent skill 访问与 session 特定的注册表实现解耦。
|
||||
- 优化 npm 打包系统。
|
||||
|
||||
## 0.14.3(2026-06-14)
|
||||
|
||||
### 优化
|
||||
|
||||
- 在打开模型选择器前刷新供应商模型元数据。
|
||||
|
||||
## 0.14.2(2026-06-12)
|
||||
|
||||
### 修复
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
# @moonshot-ai/acp-adapter
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#744](https://github.com/MoonshotAI/kimi-code/pull/744) [`18f299f`](https://github.com/MoonshotAI/kimi-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e) - Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83), [`8a92db6`](https://github.com/MoonshotAI/kimi-code/commit/8a92db6a0c110a21c6e6e86622f498e836178e5f), [`e10b25f`](https://github.com/MoonshotAI/kimi-code/commit/e10b25f9be18ca64aada0d0a3cab0e02fdbd46df), [`c6a9967`](https://github.com/MoonshotAI/kimi-code/commit/c6a996756cd8f1fb317b6eee6f4e668eebc7dc14), [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83), [`9cef896`](https://github.com/MoonshotAI/kimi-code/commit/9cef89656311974a57e6675f474ea6c2adb1d8e9), [`046856b`](https://github.com/MoonshotAI/kimi-code/commit/046856b740afb604132e914f1fc489de72394036), [`4578f05`](https://github.com/MoonshotAI/kimi-code/commit/4578f05f44101f24d45c6452e2a6993cbb52e331), [`a562ef5`](https://github.com/MoonshotAI/kimi-code/commit/a562ef54e537a36211c48f0fe19e9252e83397a0), [`18f299f`](https://github.com/MoonshotAI/kimi-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e), [`ecd7a0a`](https://github.com/MoonshotAI/kimi-code/commit/ecd7a0afb646d14a14c780a4088fd8a59da134ad), [`1eb363f`](https://github.com/MoonshotAI/kimi-code/commit/1eb363f655aa44abc1e5c3af89016f00764ecc95)]:
|
||||
- @moonshot-ai/agent-core@0.13.0
|
||||
- @moonshot-ai/kimi-code-sdk@0.9.3
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@moonshot-ai/acp-adapter",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "Agent Client Protocol adapter for kimi-code",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,39 @@
|
|||
# @moonshot-ai/agent-core
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#744](https://github.com/MoonshotAI/kimi-code/pull/744) [`18f299f`](https://github.com/MoonshotAI/kimi-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e) - Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Clarify AGENTS.md prompt guidance and mark truncated instruction files.
|
||||
|
||||
- [#780](https://github.com/MoonshotAI/kimi-code/pull/780) [`8a92db6`](https://github.com/MoonshotAI/kimi-code/commit/8a92db6a0c110a21c6e6e86622f498e836178e5f) - Prompt the CLI to show one brief same-language status sentence before non-trivial tool calls.
|
||||
|
||||
- [#786](https://github.com/MoonshotAI/kimi-code/pull/786) [`e10b25f`](https://github.com/MoonshotAI/kimi-code/commit/e10b25f9be18ca64aada0d0a3cab0e02fdbd46df) - Stop writing resume version markers into persisted agent metadata.
|
||||
|
||||
- [#768](https://github.com/MoonshotAI/kimi-code/pull/768) [`c6a9967`](https://github.com/MoonshotAI/kimi-code/commit/c6a996756cd8f1fb317b6eee6f4e668eebc7dc14) - Recover resumed sessions when an interrupted tool call result was not recorded.
|
||||
|
||||
- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Collapse hidden directories in the workspace prompt and explain how to inspect them.
|
||||
|
||||
- [#766](https://github.com/MoonshotAI/kimi-code/pull/766) [`9cef896`](https://github.com/MoonshotAI/kimi-code/commit/9cef89656311974a57e6675f474ea6c2adb1d8e9) - Clarify that compaction summaries must be emitted in the final answer.
|
||||
|
||||
- [#765](https://github.com/MoonshotAI/kimi-code/pull/765) [`046856b`](https://github.com/MoonshotAI/kimi-code/commit/046856b740afb604132e914f1fc489de72394036) - Read media files using header-detected types before falling back to media extensions.
|
||||
|
||||
- [#785](https://github.com/MoonshotAI/kimi-code/pull/785) [`4578f05`](https://github.com/MoonshotAI/kimi-code/commit/4578f05f44101f24d45c6452e2a6993cbb52e331) - Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked.
|
||||
|
||||
- [#784](https://github.com/MoonshotAI/kimi-code/pull/784) [`a562ef5`](https://github.com/MoonshotAI/kimi-code/commit/a562ef54e537a36211c48f0fe19e9252e83397a0) - Decouple agent skill access from session-specific registry implementations.
|
||||
|
||||
- [#776](https://github.com/MoonshotAI/kimi-code/pull/776) [`ecd7a0a`](https://github.com/MoonshotAI/kimi-code/commit/ecd7a0afb646d14a14c780a4088fd8a59da134ad) - Resolve model capabilities through a static lookup instead of instantiating a temporary provider.
|
||||
|
||||
- [#787](https://github.com/MoonshotAI/kimi-code/pull/787) [`1eb363f`](https://github.com/MoonshotAI/kimi-code/commit/1eb363f655aa44abc1e5c3af89016f00764ecc95) - Extend the same-language rule to the model's reasoning, so thinking follows the user's language while keeping code and technical terms in their original form.
|
||||
|
||||
- Updated dependencies [[`73be7ba`](https://github.com/MoonshotAI/kimi-code/commit/73be7ba17d41df7999d4c1fba410994e7024eb7b), [`18f299f`](https://github.com/MoonshotAI/kimi-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e), [`ecd7a0a`](https://github.com/MoonshotAI/kimi-code/commit/ecd7a0afb646d14a14c780a4088fd8a59da134ad)]:
|
||||
- @moonshot-ai/kosong@0.4.5
|
||||
- @moonshot-ai/protocol@0.3.0
|
||||
|
||||
## 0.12.3
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@moonshot-ai/agent-core",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"description": "The unified agent engine for Kimi",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -158,7 +158,9 @@ export class DefaultCompactionStrategy implements CompactionStrategy {
|
|||
* results for one exchange land consecutively before the next non-tool
|
||||
* message. So if the suffix starts with a tool result, its `asst_w_tc`
|
||||
* must be in the compacted prefix, which would orphan that result
|
||||
* (e.g. splitting between tool_a and tool_b of a parallel call).
|
||||
* (e.g. splitting between tool_a and tool_b of a parallel call), AND
|
||||
* - the compacted prefix itself does not end with an unresolved tool
|
||||
* exchange, because pending tool results must remain in the retained tail.
|
||||
*/
|
||||
function canSplitAfter(messages: readonly Message[], index: number): boolean {
|
||||
const m = messages[index];
|
||||
|
|
@ -166,5 +168,22 @@ function canSplitAfter(messages: readonly Message[], index: number): boolean {
|
|||
if (m.role === 'user') return false;
|
||||
if (m.role === 'assistant' && m.toolCalls.length > 0) return false;
|
||||
if (messages[index + 1]?.role === 'tool') return false;
|
||||
if (prefixEndsWithOpenToolExchange(messages, index)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function prefixEndsWithOpenToolExchange(messages: readonly Message[], index: number): boolean {
|
||||
if (messages[index]?.role !== 'tool') return false;
|
||||
|
||||
let toolResultCount = 0;
|
||||
for (let i = index; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message === undefined) return false;
|
||||
if (message.role === 'tool') {
|
||||
toolResultCount++;
|
||||
continue;
|
||||
}
|
||||
return message.role === 'assistant' && message.toolCalls.length > toolResultCount;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>';
|
|||
const TOOL_EMPTY_ERROR_STATUS =
|
||||
'<system>ERROR: Tool execution failed. Tool output is empty.</system>';
|
||||
const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.';
|
||||
const TOOL_INTERRUPTED_ON_RESUME_OUTPUT =
|
||||
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.';
|
||||
|
||||
// Invariant: _history must not contain an unresolved tool call exchange except
|
||||
// at the tail. When the tail is unresolved, pendingToolResultIds is exactly the
|
||||
// set of missing tool result ids for that tail exchange; appendMessage keeps
|
||||
// later messages in deferredMessages until those ids are resolved.
|
||||
export class ContextMemory {
|
||||
private _history: ContextMessage[] = [];
|
||||
private _tokenCount = 0;
|
||||
|
|
@ -210,6 +216,24 @@ export class ContextMemory {
|
|||
this.pushHistory(...trimTrailingOpenToolExchange(source.project(source.history)));
|
||||
}
|
||||
|
||||
finishResume(): void {
|
||||
const interruptedToolCallIds = [...this.pendingToolResultIds];
|
||||
this.openSteps.clear();
|
||||
if (interruptedToolCallIds.length === 0) return;
|
||||
|
||||
for (const toolCallId of interruptedToolCallIds) {
|
||||
this.appendLoopEvent({
|
||||
type: 'tool.result',
|
||||
parentUuid: toolCallId,
|
||||
toolCallId,
|
||||
result: {
|
||||
output: TOOL_INTERRUPTED_ON_RESUME_OUTPUT,
|
||||
isError: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
appendLoopEvent(event: LoopRecordedEvent): void {
|
||||
this.agent.records.logRecord({
|
||||
type: 'context.append_loop_event',
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import { FlagResolver, type ExperimentalFlagResolver } from '../flags';
|
|||
import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profile';
|
||||
import type { ModelProvider } from '../session/provider-manager';
|
||||
import type { SessionSubagentHost } from '../session/subagent-host';
|
||||
import type { SkillRegistry } from '../skill';
|
||||
import { noopTelemetryClient, type TelemetryClient } from '../telemetry';
|
||||
import type { PromisableMethods } from '../utils/types';
|
||||
import { BackgroundManager, BackgroundTaskPersistence } from './background';
|
||||
|
|
@ -46,6 +45,7 @@ import {
|
|||
} from './records';
|
||||
import { ReplayBuilder } from './replay';
|
||||
import { SkillManager } from './skill';
|
||||
import type { SkillRegistry } from './skill/types';
|
||||
import { SwarmMode } from './swarm';
|
||||
import { ToolManager } from './tool/index';
|
||||
import { TurnFlow } from './turn';
|
||||
|
|
@ -86,7 +86,6 @@ export interface AgentOptions {
|
|||
readonly log?: Logger;
|
||||
readonly telemetry?: TelemetryClient | undefined;
|
||||
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
|
||||
readonly appVersion?: string;
|
||||
readonly experimentalFlags?: ExperimentalFlagResolver;
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +109,6 @@ export class Agent {
|
|||
readonly hooks?: HookEngine;
|
||||
readonly log: Logger;
|
||||
readonly telemetry: TelemetryClient;
|
||||
readonly appVersion?: string;
|
||||
readonly experimentalFlags: ExperimentalFlagResolver;
|
||||
|
||||
readonly blobStore: BlobStore | undefined;
|
||||
|
|
@ -147,7 +145,6 @@ export class Agent {
|
|||
this.subagentHost = options.subagentHost;
|
||||
this.mcp = options.mcp;
|
||||
this.hooks = options.hookEngine;
|
||||
this.appVersion = options.appVersion;
|
||||
this.log = options.log ?? log;
|
||||
this.telemetry = options.telemetry ?? noopTelemetryClient;
|
||||
this.experimentalFlags = options.experimentalFlags ?? new FlagResolver();
|
||||
|
|
@ -296,11 +293,17 @@ export class Agent {
|
|||
|
||||
async resume(): Promise<{ warning?: string }> {
|
||||
const result = await this.records.replay();
|
||||
this.goal.normalizeAfterReplay();
|
||||
await this.background.loadFromDisk();
|
||||
await this.background.reconcile();
|
||||
await this.cron?.loadFromDisk();
|
||||
this.turn.finishResume();
|
||||
try {
|
||||
this.replayBuilder.postRestoring = true;
|
||||
this.goal.normalizeAfterReplay();
|
||||
await this.background.loadFromDisk();
|
||||
await this.background.reconcile();
|
||||
await this.cron?.loadFromDisk();
|
||||
this.context.finishResume();
|
||||
this.turn.finishResume();
|
||||
} finally {
|
||||
this.replayBuilder.postRestoring = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -153,7 +153,6 @@ export class AgentRecords {
|
|||
type: 'metadata',
|
||||
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
|
||||
created_at: Date.now(),
|
||||
app_version: this.agent.appVersion,
|
||||
});
|
||||
this.metadataInitialized = true;
|
||||
}
|
||||
|
|
@ -217,20 +216,6 @@ export class AgentRecords {
|
|||
await this.agent.blobStore.rehydrateParts(msg.content);
|
||||
}
|
||||
}
|
||||
const firstRecord = replayedRecords[0];
|
||||
if (
|
||||
firstRecord?.type === 'metadata' &&
|
||||
firstRecord.app_version !== this.agent.appVersion
|
||||
) {
|
||||
this.persistence.append({
|
||||
type: 'metadata',
|
||||
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
|
||||
created_at: Date.now(),
|
||||
app_version: this.agent.appVersion,
|
||||
resumed: true,
|
||||
});
|
||||
await this.persistence.flush();
|
||||
}
|
||||
return { warning };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ export interface AgentRecordEvents {
|
|||
metadata: {
|
||||
protocol_version: string;
|
||||
created_at: number;
|
||||
app_version?: string;
|
||||
resumed?: boolean;
|
||||
};
|
||||
|
||||
forked: {};
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ import type { AgentReplayRecord, AgentReplayRecordPayload } from '../..';
|
|||
import type { ContextMessage } from '../context';
|
||||
|
||||
export class ReplayBuilder {
|
||||
postRestoring = false;
|
||||
captureLiveRecords = false;
|
||||
protected readonly records: AgentReplayRecord[] = [];
|
||||
|
||||
constructor(public readonly agent: Agent) {}
|
||||
|
||||
push(record: AgentReplayRecordPayload): void {
|
||||
if (this.captureLiveRecords || this.agent.records.restoring) {
|
||||
if (this.captureLiveRecords || this.agent.records.restoring || this.postRestoring) {
|
||||
this.records.push({
|
||||
...record,
|
||||
time: this.agent.records.restoring?.time ?? Date.now(),
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ import type { ContentPart } from '@moonshot-ai/kosong';
|
|||
|
||||
import type { Agent } from '..';
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
import { isUserActivatableSkillType, type SkillRegistry } from '../../skill';
|
||||
import { isUserActivatableSkillType } from '../../skill';
|
||||
import type { SkillActivationOrigin } from '../context';
|
||||
import { renderUserSlashSkillPrompt } from './prompt';
|
||||
import type { SkillRegistry } from './types';
|
||||
|
||||
export type { SkillRegistry } from './types';
|
||||
|
||||
export class SkillManager {
|
||||
constructor(
|
||||
|
|
@ -34,6 +37,7 @@ export class SkillManager {
|
|||
skillArgs,
|
||||
skillContent,
|
||||
skillSource: skill.source,
|
||||
skillDir: skill.dir,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ export interface RenderSkillPromptInput {
|
|||
readonly skillArgs: string;
|
||||
readonly skillContent: string;
|
||||
readonly skillSource?: SkillSource | undefined;
|
||||
/**
|
||||
* Absolute directory containing the skill's SKILL.md and any bundled
|
||||
* resources (scripts, templates, data files). Surfaced on the loaded
|
||||
* block so the agent can locate those resources with relative paths —
|
||||
* without it, a skill that ships helper scripts is unusable unless the
|
||||
* author manually embeds `${KIMI_SKILL_DIR}` in the body.
|
||||
*/
|
||||
readonly skillDir?: string | undefined;
|
||||
}
|
||||
|
||||
interface RenderSkillLoadedBlockInput extends RenderSkillPromptInput {
|
||||
|
|
@ -47,6 +55,7 @@ function renderSkillAttributes(input: RenderSkillLoadedBlockInput): string {
|
|||
['name', input.skillName],
|
||||
['trigger', input.trigger],
|
||||
['source', input.skillSource],
|
||||
['dir', input.skillDir],
|
||||
['args', input.skillArgs],
|
||||
];
|
||||
|
||||
|
|
|
|||
10
packages/agent-core/src/agent/skill/types.ts
Normal file
10
packages/agent-core/src/agent/skill/types.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { SkillDefinition } from '../../skill';
|
||||
|
||||
export interface SkillRegistry {
|
||||
getSkill(name: string): SkillDefinition | undefined;
|
||||
getPluginSkill(pluginId: string, name: string): SkillDefinition | undefined;
|
||||
renderSkillPrompt(skill: SkillDefinition, rawArgs: string): string;
|
||||
listInvocableSkills(): readonly SkillDefinition[];
|
||||
getSkillRoots(): readonly string[];
|
||||
getModelSkillListing(): string;
|
||||
}
|
||||
|
|
@ -424,6 +424,10 @@ export class ToolManager {
|
|||
);
|
||||
}
|
||||
|
||||
refreshBuiltinTools(): void {
|
||||
this.initializeBuiltinTools();
|
||||
}
|
||||
|
||||
private createVideoUploader(provider: ChatProvider): b.VideoUploader | undefined {
|
||||
const uploadVideo = provider.uploadVideo?.bind(provider);
|
||||
if (uploadVideo === undefined) return undefined;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { listDirectory } from '../tools/support/list-directory';
|
|||
import type { SystemPromptContext } from './types';
|
||||
|
||||
const AGENTS_MD_MAX_BYTES = 32 * 1024;
|
||||
const AGENTS_MD_TRUNCATION_MARKER =
|
||||
'<!-- Some AGENTS.md files were truncated or omitted to fit the 32 KB budget -->';
|
||||
const S_IFMT = 0o170000;
|
||||
const S_IFREG = 0o100000;
|
||||
|
||||
|
|
@ -16,7 +18,7 @@ export async function prepareSystemPromptContext(
|
|||
brandHome?: string,
|
||||
): Promise<PreparedSystemPromptContext> {
|
||||
const [cwdListing, agentsMd] = await Promise.all([
|
||||
listDirectory(kaos),
|
||||
listDirectory(kaos, undefined, { collapseHiddenDirs: true }),
|
||||
loadAgentsMd(kaos, brandHome),
|
||||
]);
|
||||
return { cwdListing, agentsMd };
|
||||
|
|
@ -126,6 +128,7 @@ function renderAgentFiles(files: readonly AgentFile[]): string {
|
|||
if (files.length === 0) return '';
|
||||
|
||||
let remaining = AGENTS_MD_MAX_BYTES;
|
||||
let didTruncate = false;
|
||||
const budgeted: Array<AgentFile | undefined> = Array.from({ length: files.length });
|
||||
|
||||
for (let i = files.length - 1; i >= 0; i--) {
|
||||
|
|
@ -138,21 +141,25 @@ function renderAgentFiles(files: readonly AgentFile[]): string {
|
|||
if (remaining <= 0) {
|
||||
budgeted[i] = { path: file.path, content: '' };
|
||||
remaining = 0;
|
||||
didTruncate = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = file.content;
|
||||
if (byteLength(content) > remaining) {
|
||||
content = truncateUtf8(content, remaining).trim();
|
||||
didTruncate = true;
|
||||
}
|
||||
remaining -= byteLength(content);
|
||||
budgeted[i] = { path: file.path, content };
|
||||
}
|
||||
|
||||
return budgeted
|
||||
const rendered = budgeted
|
||||
.filter((file): file is AgentFile => file !== undefined && file.content.length > 0)
|
||||
.map((file) => `${annotationFor(file.path)}${file.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
return didTruncate ? `${AGENTS_MD_TRUNCATION_MARKER}\n${rendered}` : rendered;
|
||||
}
|
||||
|
||||
function truncateUtf8(text: string, maxBytes: number): string {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Your primary goal is to help users with software engineering tasks by taking act
|
|||
|
||||
The user's messages may contain questions and/or task descriptions in natural language, code snippets, logs, file paths, or other forms of information. Read them, understand them and do what the user requested. For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly. For anything else, default to taking action with tools. When the request could be interpreted as either a question to answer or a task to complete, treat it as a task.
|
||||
|
||||
When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools (e.g., `Write`, `Bash`) to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools.
|
||||
When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools (e.g., `Write`, `Bash`) to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. When calling tools, do not provide detailed explanations or chain-of-thought. For simple requests, call tools directly. For non-trivial or multi-step tasks, first emit one short user-visible sentence in the same language as the user describing what you will do next, then call the tool(s). You MUST follow the description of each tool and its parameters when calling tools.
|
||||
|
||||
If the `Agent` tool is available, you can use it to delegate a focused subtask to a subagent instance. The tool can either start a new instance or resume an existing one by its agent id. Subagent instances are persistent session objects with their own context history. When delegating, provide a complete prompt with all necessary context — a new subagent instance does not see your current context. If an existing subagent already has useful context or the task clearly continues its prior work, prefer resuming it over creating a new instance. Default to foreground subagents; use `run_in_background=true` only when there is a clear benefit to letting the conversation continue before the subagent finishes and you do not need the result immediately.
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ If the `Bash`, `TaskList`, `TaskOutput`, and `TaskStop` tools are available and
|
|||
|
||||
If a foreground tool call or a background agent requests approval, the approval is coordinated through the unified approval runtime and surfaced through the root UI channel. Do not assume approvals are local to a single subagent turn.
|
||||
|
||||
When responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise.
|
||||
When responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise. This applies to your reasoning and thinking as well, not just your final reply — think in the user's language, while keeping code, commands, identifiers, file paths, and technical terms in their original form.
|
||||
|
||||
# General Guidelines for Coding
|
||||
|
||||
|
|
@ -85,13 +85,15 @@ The current date and time in ISO format is `{{ KIMI_NOW }}`. This is only a refe
|
|||
|
||||
The current working directory is `{{ KIMI_WORK_DIR }}`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters.
|
||||
|
||||
Use this as your basic understanding of the project structure. The tree only shows the first two levels for normal directories; entries marked "... and N more" indicate additional contents. Hidden directories are shown as entries only; their contents are intentionally omitted to reduce noise.
|
||||
|
||||
If the task requires inspecting hidden paths, use `Glob` to discover them (for example `.*`, `.github/**`, `.agents/**`, or `.git/**`), use `Read` for known non-sensitive hidden files, and use `Grep` to search hidden file contents. `Grep` searches hidden files by default but excludes VCS metadata and sensitive files such as `.env`, credential stores, and SSH keys. Use `Bash` only for raw listings like `ls -A` when a dedicated tool is not appropriate.
|
||||
|
||||
The directory listing of current working directory is:
|
||||
|
||||
```
|
||||
{{ KIMI_WORK_DIR_LS }}
|
||||
```
|
||||
|
||||
Use this as your basic understanding of the project structure. The tree only shows the first two levels; entries marked "... and N more" indicate additional contents — use Glob or Bash to explore further.
|
||||
{% if KIMI_ADDITIONAL_DIRS_INFO %}
|
||||
|
||||
## Additional Directories
|
||||
|
|
@ -103,29 +105,17 @@ The following directories have been added to the workspace. You can read, write,
|
|||
|
||||
# Project Information
|
||||
|
||||
Markdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root.
|
||||
Markdown files named `AGENTS.md` contain agent-specific instructions such as project structure, build commands, coding style, testing expectations, and user preferences. `README.md` files are still useful for human-facing project context; `AGENTS.md` files are the focused instruction source for coding agents.
|
||||
|
||||
> Why `AGENTS.md`?
|
||||
>
|
||||
> `README.md` files are for humans: quick starts, project descriptions, and contribution guidelines. `AGENTS.md` complements this by containing the extra, sometimes detailed context coding agents need: build steps, tests, and conventions that might clutter a README or aren’t relevant to human contributors.
|
||||
>
|
||||
> We intentionally kept it separate to:
|
||||
>
|
||||
> - Give agents a clear, predictable place for instructions.
|
||||
> - Keep `README`s concise and focused on human contributors.
|
||||
> - Provide precise, agent-focused guidance that complements existing `README` and docs.
|
||||
`AGENTS.md` files can appear at any level of the project tree, including inside `.kimi-code/` directories. When multiple `AGENTS.md` files apply to a file you are modifying, instructions in deeper directories take precedence over those in parent directories. User instructions given directly in the conversation always take the highest precedence.
|
||||
|
||||
The `AGENTS.md` instructions (merged from all applicable directories):
|
||||
When working on files in subdirectories, check whether those directories contain their own `AGENTS.md` with more specific guidance. You may also check `README`/`README.md` files for more information about the project. If you modified any files, styles, structures, configurations, workflows, or other conventions mentioned in `AGENTS.md` files, update the corresponding `AGENTS.md` files to keep them current.
|
||||
|
||||
`````````
|
||||
The applicable `AGENTS.md` instructions are:
|
||||
|
||||
```````
|
||||
{{ KIMI_AGENTS_MD }}
|
||||
`````````
|
||||
|
||||
`AGENTS.md` files can appear at any level of the project directory tree, including inside `.kimi-code/` directories. Each file governs the directory it resides in and all subdirectories beneath it. When multiple `AGENTS.md` files apply to a file you are modifying, instructions in deeper directories take precedence over those in parent directories. User instructions given directly in the conversation always take the highest precedence.
|
||||
|
||||
When working on files in subdirectories, always check whether those directories contain their own `AGENTS.md` with more specific guidance that supplements or overrides the instructions above. You may also check `README`/`README.md` files for more information about the project.
|
||||
|
||||
If you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date.
|
||||
```````
|
||||
|
||||
# Skills
|
||||
|
||||
|
|
@ -140,18 +130,18 @@ Skills are modular extensions that provide:
|
|||
- Tool integrations: Pre-configured tool chains for specific operations
|
||||
- Reference material: Documentation, templates, and examples
|
||||
|
||||
## Available skills
|
||||
|
||||
Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**.
|
||||
|
||||
{{ KIMI_SKILLS }}
|
||||
|
||||
## How to use skills
|
||||
|
||||
Identify the skills that are likely to be useful for the tasks you are currently working on, read the skill file for detailed instructions, guidelines, scripts and more.
|
||||
|
||||
Only read skill details when needed to conserve the context window.
|
||||
|
||||
## Available skills
|
||||
|
||||
Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**.
|
||||
|
||||
{{ KIMI_SKILLS }}
|
||||
|
||||
# Ultimate Reminders
|
||||
|
||||
At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Environment } from '@moonshot-ai/kaos';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { SkillRegistry } from '../skill';
|
||||
import type { SkillRegistry } from '../agent/skill/types';
|
||||
|
||||
export const RawSubagentProfileSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ import {
|
|||
import type { ProviderManager } from './provider-manager';
|
||||
import {
|
||||
registerBuiltinSkills,
|
||||
SessionSkillRegistry,
|
||||
resolveSkillRoots,
|
||||
SkillRegistry,
|
||||
summarizeSkill,
|
||||
type SkillRoot,
|
||||
type SkillSummary,
|
||||
|
|
@ -138,7 +138,7 @@ async function waitForSettlementOrTimeout(
|
|||
export class Session {
|
||||
readonly rpc: SDKSessionRPC;
|
||||
readonly telemetry: TelemetryClient;
|
||||
readonly skills: SkillRegistry;
|
||||
readonly skills: SessionSkillRegistry;
|
||||
readonly agents: Map<string, AgentEntry> = new Map();
|
||||
readonly mcp: McpConnectionManager;
|
||||
readonly log: Logger;
|
||||
|
|
@ -182,7 +182,7 @@ export class Session {
|
|||
this.telemetry = options.telemetry ?? noopTelemetryClient;
|
||||
this.toolKaos = options.kaos;
|
||||
this.persistenceKaos = options.persistenceKaos ?? options.kaos;
|
||||
this.skills = new SkillRegistry({
|
||||
this.skills = new SessionSkillRegistry({
|
||||
sessionId: options.id,
|
||||
});
|
||||
this.mcp = new McpConnectionManager({
|
||||
|
|
@ -234,6 +234,7 @@ export class Session {
|
|||
|
||||
async resume(): Promise<{ warning?: string }> {
|
||||
await this.skillsReady;
|
||||
this.log.info('session resume', { app_version: this.options.appVersion });
|
||||
const { agents } = await this.readMetadata();
|
||||
this.agents.clear();
|
||||
// Only the main agent is needed to reopen the session; subagents replay
|
||||
|
|
@ -573,7 +574,6 @@ export class Session {
|
|||
telemetry: this.telemetry,
|
||||
log: this.log.createChild({ agentId: id }),
|
||||
pluginSessionStarts: type === 'main' ? this.options.pluginSessionStarts : undefined,
|
||||
appVersion: this.options.appVersion,
|
||||
experimentalFlags: this.experimentalFlags,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Logger } from '#/logging/types';
|
||||
import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong';
|
||||
import { APIStatusError, createProvider, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong';
|
||||
import { APIStatusError, getModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong';
|
||||
import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig } from '../config';
|
||||
import { ErrorCodes, isKimiError, KimiError } from '../errors';
|
||||
|
||||
|
|
@ -201,8 +201,7 @@ function resolveModelCapabilities(
|
|||
provider: KosongProviderConfig,
|
||||
): ModelCapability {
|
||||
const declared = new Set((alias.capabilities ?? []).map((c) => c.trim().toLowerCase()));
|
||||
const probe = createProvider(providerForCapabilityProbe(provider));
|
||||
const detected = probe.getCapability?.(provider.model) ?? UNKNOWN_CAPABILITY;
|
||||
const detected = getModelCapability(provider.type, provider.model);
|
||||
|
||||
return {
|
||||
image_in: declared.has('image_in') || detected.image_in,
|
||||
|
|
@ -297,14 +296,6 @@ function defaultHeadersField(
|
|||
return { defaultHeaders: { ...headers } };
|
||||
}
|
||||
|
||||
function providerForCapabilityProbe(provider: KosongProviderConfig): KosongProviderConfig {
|
||||
const apiKey = provider.apiKey && provider.apiKey.length > 0 ? provider.apiKey : 'capability-probe';
|
||||
if (provider.type === 'vertexai') {
|
||||
return { ...provider, vertexai: false, project: undefined, location: undefined, apiKey };
|
||||
}
|
||||
return { ...provider, apiKey };
|
||||
}
|
||||
|
||||
function providerApiKey(provider: ProviderConfig): string | undefined {
|
||||
switch (provider.type) {
|
||||
case 'anthropic':
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { SkillRegistry } from '../registry';
|
||||
import type { SessionSkillRegistry } from '../registry';
|
||||
import { CUSTOM_THEME_SKILL } from './custom-theme';
|
||||
import { IMPORT_FROM_CC_CODEX_SKILL } from './import-from-cc-codex';
|
||||
import { MCP_CONFIG_SKILL } from './mcp-config';
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from './sub-skill';
|
||||
import { UPDATE_CONFIG_SKILL } from './update-config';
|
||||
|
||||
export function registerBuiltinSkills(registry: SkillRegistry): void {
|
||||
export function registerBuiltinSkills(registry: SessionSkillRegistry): void {
|
||||
registry.registerBuiltinSkill(MCP_CONFIG_SKILL);
|
||||
registry.registerBuiltinSkill(IMPORT_FROM_CC_CODEX_SKILL);
|
||||
registry.registerBuiltinSkill(UPDATE_CONFIG_SKILL);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { expandSkillParameters, skillArgumentNames } from './parser';
|
|||
import { discoverSkills, type DiscoverSkillsOptions } from './scanner';
|
||||
import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types';
|
||||
import { isInlineSkillType, normalizeSkillName } from './types';
|
||||
import type { SkillRegistry as AgentSkillRegistry } from '../agent/skill/types';
|
||||
import { escapeXmlAttr } from '../utils/xml-escape';
|
||||
|
||||
const LISTING_DESC_MAX = 250;
|
||||
|
|
@ -22,7 +23,7 @@ export interface SkillRegistryOptions {
|
|||
readonly sessionId?: string;
|
||||
}
|
||||
|
||||
export class SkillRegistry {
|
||||
export class SessionSkillRegistry implements AgentSkillRegistry {
|
||||
private readonly byName = new Map<string, SkillDefinition>();
|
||||
private readonly byPluginAndName = new Map<string, SkillDefinition>();
|
||||
private readonly roots: string[] = [];
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ export class SkillTool implements BuiltinTool<SkillToolInput> {
|
|||
skillArgs,
|
||||
skillContent,
|
||||
skillSource: skill.source,
|
||||
skillDir: skill.dir,
|
||||
trigger: promptTrigger,
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ import type { Kaos } from '@moonshot-ai/kaos';
|
|||
export const LIST_DIR_ROOT_WIDTH = 30;
|
||||
export const LIST_DIR_CHILD_WIDTH = 10;
|
||||
|
||||
export interface ListDirectoryOptions {
|
||||
readonly collapseHiddenDirs?: boolean;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
readonly name: string;
|
||||
readonly isDir: boolean;
|
||||
|
|
@ -53,12 +57,20 @@ async function collectEntries(
|
|||
return { entries: all.slice(0, maxWidth), total: all.length, readable: true };
|
||||
}
|
||||
|
||||
function shouldCollapseDirectory(entry: Entry, options: ListDirectoryOptions): boolean {
|
||||
return options.collapseHiddenDirs === true && entry.isDir && entry.name.startsWith('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a 2-level tree listing of `workDir` suitable for inclusion in a
|
||||
* tool error message. Returns `"(empty directory)"` if the directory is
|
||||
* empty, or an error marker line if the directory itself is unreadable.
|
||||
*/
|
||||
export async function listDirectory(kaos: Kaos, workDir: string = kaos.getcwd()): Promise<string> {
|
||||
export async function listDirectory(
|
||||
kaos: Kaos,
|
||||
workDir: string = kaos.getcwd(),
|
||||
options: ListDirectoryOptions = {},
|
||||
): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
const { entries, total, readable } = await collectEntries(
|
||||
kaos,
|
||||
|
|
@ -77,6 +89,7 @@ export async function listDirectory(kaos: Kaos, workDir: string = kaos.getcwd())
|
|||
|
||||
if (isDir) {
|
||||
lines.push(`${connector}${name}/`);
|
||||
if (shouldCollapseDirectory(entry, options)) continue;
|
||||
const childPrefix = isLast ? ' ' : '│ ';
|
||||
const childDir = join(workDir, name);
|
||||
const child = await collectEntries(kaos, childDir, LIST_DIR_CHILD_WIDTH);
|
||||
|
|
|
|||
|
|
@ -197,12 +197,12 @@ describe('FullCompaction', () => {
|
|||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "recent user three" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
|
||||
[wire] full_compaction.begin { "source": "manual", "instruction": "Keep the important test facts.", "time": "<time>" }
|
||||
[emit] compaction.started { "trigger": "manual", "instruction": "Keep the important test facts." }
|
||||
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 495, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 120, "maxContextTokens": 256000, "contextUsage": 0.00046875, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 495, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 495, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 120, "maxContextTokens": 256000, "contextUsage": 0.00046875, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] full_compaction.complete { "time": "<time>" }
|
||||
[emit] compaction.completed { "result": { "summary": "Compacted summary.", "compactedCount": 6, "tokensBefore": 39, "tokensAfter": 5 } }
|
||||
[wire] context.apply_compaction { "summary": "Compacted summary.", "compactedCount": 6, "tokensBefore": 39, "tokensAfter": 5, "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 5, "maxContextTokens": 256000, "contextUsage": 0.00001953125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 495, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 495, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 5, "maxContextTokens": 256000, "contextUsage": 0.00001953125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 520, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
`);
|
||||
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
||||
system: <system-prompt>
|
||||
|
|
@ -262,7 +262,7 @@ describe('FullCompaction', () => {
|
|||
duration_ms: expect.any(Number),
|
||||
compacted_count: 6,
|
||||
retry_count: 0,
|
||||
inputOther: 495,
|
||||
inputOther: 520,
|
||||
output: 8,
|
||||
inputCacheRead: 0,
|
||||
inputCacheCreation: 0,
|
||||
|
|
@ -879,13 +879,29 @@ describe('FullCompaction', () => {
|
|||
messages:
|
||||
user: text "old user one"
|
||||
assistant: text "old assistant one"
|
||||
user: text "run both tools"
|
||||
assistant: [] calls call_open_one:LookupOne { "query": "one" }, call_open_two:LookupTwo { "query": "two" }
|
||||
tool[call_open_one]: text "one result"
|
||||
user: text <compaction-instruction>
|
||||
`);
|
||||
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
|
||||
'assistant',
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
]);
|
||||
ctx.dispatch({
|
||||
type: 'context.append_loop_event',
|
||||
event: {
|
||||
type: 'tool.result',
|
||||
parentUuid: 'call_open_two',
|
||||
toolCallId: 'call_open_two',
|
||||
result: { output: 'two result' },
|
||||
},
|
||||
});
|
||||
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
|
||||
'assistant',
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'tool',
|
||||
]);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
|
@ -911,12 +927,12 @@ describe('FullCompaction', () => {
|
|||
[wire] full_compaction.begin { "source": "manual", "time": "<time>" }
|
||||
[emit] compaction.started { "trigger": "manual" }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "new user while compacting" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
|
||||
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 473, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 80, "maxContextTokens": 256000, "contextUsage": 0.0003125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 473, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 473, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 80, "maxContextTokens": 256000, "contextUsage": 0.0003125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] full_compaction.complete { "time": "<time>" }
|
||||
[emit] compaction.completed { "result": { "summary": "Compacted prefix.", "compactedCount": 4, "tokensBefore": 25, "tokensAfter": 5 } }
|
||||
[wire] context.apply_compaction { "summary": "Compacted prefix.", "compactedCount": 4, "tokensBefore": 25, "tokensAfter": 5, "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 5, "maxContextTokens": 256000, "contextUsage": 0.00001953125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 473, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 473, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 5, "maxContextTokens": 256000, "contextUsage": 0.00001953125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 499, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
`);
|
||||
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
||||
system: <system-prompt>
|
||||
|
|
@ -965,8 +981,8 @@ describe('FullCompaction', () => {
|
|||
[emit] compaction.started { "trigger": "manual" }
|
||||
[wire] context.clear { "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 0, "maxContextTokens": 256000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual" }
|
||||
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 473, "output": 7, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 0, "maxContextTokens": 256000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 473, "output": 7, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 473, "output": 7, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 499, "output": 7, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 0, "maxContextTokens": 256000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 499, "output": 7, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 499, "output": 7, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] full_compaction.cancel { "time": "<time>" }
|
||||
[emit] compaction.cancelled {}
|
||||
`);
|
||||
|
|
@ -1009,12 +1025,12 @@ describe('FullCompaction', () => {
|
|||
[wire] full_compaction.begin { "source": "auto", "time": "<time>" }
|
||||
[emit] compaction.started { "trigger": "auto" }
|
||||
[emit] compaction.blocked { "turnId": 0 }
|
||||
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 472, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 950000, "maxContextTokens": 256000, "contextUsage": 3.7109375, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 472, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 472, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 950000, "maxContextTokens": 256000, "contextUsage": 3.7109375, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] full_compaction.complete { "time": "<time>" }
|
||||
[emit] compaction.completed { "result": { "summary": "Auto compacted summary.", "compactedCount": 4, "tokensBefore": 46, "tokensAfter": 28 } }
|
||||
[wire] context.apply_compaction { "summary": "Auto compacted summary.", "compactedCount": 4, "tokensBefore": 46, "tokensAfter": 28, "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 28, "maxContextTokens": 256000, "contextUsage": 0.000109375, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 472, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 472, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 28, "maxContextTokens": 256000, "contextUsage": 0.000109375, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 498, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
|
||||
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
||||
[emit] assistant.delta { "turnId": 0, "delta": "I can answer after compaction." }
|
||||
|
|
@ -1022,7 +1038,7 @@ describe('FullCompaction', () => {
|
|||
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 31, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }, "time": "<time>" }
|
||||
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 31, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
|
||||
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 31, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 42, "maxContextTokens": 256000, "contextUsage": 0.0001640625, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 503, "output": 20, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 503, "output": 20, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 31, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 42, "maxContextTokens": 256000, "contextUsage": 0.0001640625, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 529, "output": 20, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 529, "output": 20, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 31, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "completed" }
|
||||
`);
|
||||
expect(ctx.llmInputs()).toMatchInlineSnapshot(`
|
||||
|
|
@ -1152,6 +1168,9 @@ describe('FullCompaction', () => {
|
|||
|
||||
expect(ctx.agent.context.history.map((m) => m.role)).toEqual([
|
||||
'assistant',
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
]);
|
||||
|
||||
ctx.dispatch({
|
||||
|
|
@ -1166,6 +1185,9 @@ describe('FullCompaction', () => {
|
|||
|
||||
expect(ctx.agent.context.history.map((m) => m.role)).toEqual([
|
||||
'assistant',
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'tool',
|
||||
'user',
|
||||
]);
|
||||
|
|
@ -1670,12 +1692,12 @@ describe('FullCompaction', () => {
|
|||
[wire] full_compaction.begin { "source": "auto", "time": "<time>" }
|
||||
[emit] compaction.started { "trigger": "auto" }
|
||||
[emit] compaction.blocked { "turnId": 0 }
|
||||
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 456, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 456, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 456, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] full_compaction.complete { "time": "<time>" }
|
||||
[emit] compaction.completed { "result": { "summary": "First compacted summary.", "compactedCount": 1, "tokensBefore": 8, "tokensAfter": 6 } }
|
||||
[wire] context.apply_compaction { "summary": "First compacted summary.", "compactedCount": 1, "tokensBefore": 8, "tokensAfter": 6, "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 6, "maxContextTokens": 1000000, "contextUsage": 0.000006, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 456, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 456, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 6, "maxContextTokens": 1000000, "contextUsage": 0.000006, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 482, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
|
||||
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
||||
[emit] assistant.delta { "turnId": 0, "delta": "I need a tool." }
|
||||
|
|
@ -1688,7 +1710,7 @@ describe('FullCompaction', () => {
|
|||
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 9, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }
|
||||
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 9, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
|
||||
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 9, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
|
||||
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 20, "maxContextTokens": 1000000, "contextUsage": 0.00002, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 465, "output": 20, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 465, "output": 20, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 9, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 20, "maxContextTokens": 1000000, "contextUsage": 0.00002, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 491, "output": 20, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 491, "output": 20, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 9, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
||||
[emit] turn.step.interrupted { "turnId": 0, "step": 2, "reason": "error", "message": "Compaction limit exceeded (1)" }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "context.overflow", "message": "Compaction limit exceeded (1)", "name": "KimiError", "details": { "maxCompactions": 1, "turnId": 0 }, "retryable": true } }
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ describe('AgentRecords persistence metadata', () => {
|
|||
type: 'metadata',
|
||||
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
|
||||
});
|
||||
expect(persistence.records[0]).not.toHaveProperty('app_version');
|
||||
expect(persistence.records[0]).not.toHaveProperty('resumed');
|
||||
expect(persistence.records[1]?.type).toBe('turn.prompt');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,28 @@ const MOCK_PROVIDER = {
|
|||
} as const;
|
||||
|
||||
describe('Agent resume', () => {
|
||||
it('does not append metadata when resuming records that include legacy app version', async () => {
|
||||
const persistence = new RecordingAgentPersistence([
|
||||
{
|
||||
type: 'metadata',
|
||||
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
|
||||
created_at: 1,
|
||||
app_version: '0.0.1-old',
|
||||
} as unknown as AgentRecord,
|
||||
{
|
||||
type: 'turn.prompt',
|
||||
input: [{ type: 'text', text: 'old prompt' }],
|
||||
origin: { kind: 'user' },
|
||||
},
|
||||
]);
|
||||
const ctx = testAgent({ persistence });
|
||||
|
||||
await ctx.agent.resume();
|
||||
|
||||
expect(persistence.appended).toEqual([]);
|
||||
expect(persistence.records.filter((record) => record.type === 'metadata')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('replays persisted records without restarting turns, compactions, plan turns, or tools', async () => {
|
||||
const persistence = new RecordingAgentPersistence(resumeHistory());
|
||||
const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute on resume'));
|
||||
|
|
@ -408,6 +430,186 @@ describe('Agent resume', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('closes interrupted trailing tool calls with synthetic error results after resume', async () => {
|
||||
const persistence = new RecordingAgentPersistence([
|
||||
{
|
||||
type: 'config.update',
|
||||
cwd: process.cwd(),
|
||||
modelAlias: MOCK_PROVIDER.model,
|
||||
systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT,
|
||||
thinkingLevel: 'off',
|
||||
},
|
||||
{
|
||||
type: 'context.append_message',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Run both lookups' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'user' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'context.append_loop_event',
|
||||
event: {
|
||||
type: 'step.begin',
|
||||
uuid: 'interrupted-step',
|
||||
turnId: '0',
|
||||
step: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'context.append_loop_event',
|
||||
event: {
|
||||
type: 'tool.call',
|
||||
uuid: 'call-one',
|
||||
turnId: '0',
|
||||
step: 1,
|
||||
stepUuid: 'interrupted-step',
|
||||
toolCallId: 'call_interrupted_one',
|
||||
name: 'LookupOne',
|
||||
args: { query: 'one' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'context.append_loop_event',
|
||||
event: {
|
||||
type: 'tool.call',
|
||||
uuid: 'call-two',
|
||||
turnId: '0',
|
||||
step: 1,
|
||||
stepUuid: 'interrupted-step',
|
||||
toolCallId: 'call_interrupted_two',
|
||||
name: 'LookupTwo',
|
||||
args: { query: 'two' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'context.append_loop_event',
|
||||
event: {
|
||||
type: 'tool.result',
|
||||
parentUuid: 'call-one',
|
||||
toolCallId: 'call_interrupted_one',
|
||||
result: { output: 'one result' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const ctx = testAgent({ persistence });
|
||||
|
||||
await ctx.agent.resume();
|
||||
|
||||
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'tool',
|
||||
]);
|
||||
const syntheticResult = ctx.agent.context.history.at(-1);
|
||||
expect(syntheticResult).toMatchObject({
|
||||
role: 'tool',
|
||||
toolCallId: 'call_interrupted_two',
|
||||
isError: true,
|
||||
});
|
||||
expect(textContent(syntheticResult)).toContain(
|
||||
'Tool execution was interrupted before its result was recorded',
|
||||
);
|
||||
const replayMessages = ctx.agent.replayBuilder
|
||||
.buildResult()
|
||||
.flatMap((record) => (record.type === 'message' ? [record.message] : []));
|
||||
expect(replayMessages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'tool',
|
||||
]);
|
||||
expect(replayMessages.at(-1)).toMatchObject({
|
||||
role: 'tool',
|
||||
toolCallId: 'call_interrupted_two',
|
||||
isError: true,
|
||||
});
|
||||
expect(textContent(replayMessages.at(-1))).toContain(
|
||||
'Tool execution was interrupted before its result was recorded',
|
||||
);
|
||||
expect(
|
||||
persistence.appended.filter(
|
||||
(record) =>
|
||||
record.type === 'context.append_loop_event' &&
|
||||
record.event.type === 'tool.result' &&
|
||||
record.event.toolCallId === 'call_interrupted_two',
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'context.append_loop_event',
|
||||
event: expect.objectContaining({
|
||||
type: 'tool.result',
|
||||
parentUuid: 'call_interrupted_two',
|
||||
toolCallId: 'call_interrupted_two',
|
||||
result: {
|
||||
output:
|
||||
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.',
|
||||
isError: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Recovered after resume.' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue after resume' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
const syntheticRecordIndex = persistence.records.findIndex(
|
||||
(record) =>
|
||||
record.type === 'context.append_loop_event' &&
|
||||
record.event.type === 'tool.result' &&
|
||||
record.event.toolCallId === 'call_interrupted_two',
|
||||
);
|
||||
const freshUserRecordIndex = persistence.records.findIndex(
|
||||
(record) =>
|
||||
record.type === 'context.append_message' &&
|
||||
record.message.role === 'user' &&
|
||||
textContent(record.message) === 'continue after resume',
|
||||
);
|
||||
expect(syntheticRecordIndex).toBeGreaterThan(-1);
|
||||
expect(freshUserRecordIndex).toBeGreaterThan(-1);
|
||||
expect(syntheticRecordIndex).toBeLessThan(freshUserRecordIndex);
|
||||
|
||||
const llmHistory = ctx.llmCalls[0]?.history ?? [];
|
||||
expect(llmHistory.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'tool',
|
||||
'user',
|
||||
]);
|
||||
expect(textContent(llmHistory[3])).toContain(
|
||||
'<system>ERROR: Tool execution failed.</system>',
|
||||
);
|
||||
expect(textContent(llmHistory[3])).toContain(
|
||||
'Tool execution was interrupted before its result was recorded',
|
||||
);
|
||||
expect(textContent(llmHistory[4])).toBe('continue after resume');
|
||||
expect(
|
||||
ctx.agent.context.history.some(
|
||||
(message) => message.role === 'user' && textContent(message) === 'continue after resume',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const resumedAgain = testAgent({ persistence });
|
||||
await resumedAgain.agent.resume();
|
||||
|
||||
expect(resumedAgain.agent.context.history.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'tool',
|
||||
'user',
|
||||
'assistant',
|
||||
]);
|
||||
expect(textContent(resumedAgain.agent.context.history[3])).toContain(
|
||||
'Tool execution was interrupted before its result was recorded',
|
||||
);
|
||||
expect(textContent(resumedAgain.agent.context.history[4])).toBe('continue after resume');
|
||||
});
|
||||
|
||||
it('rebuilds goal completion replay cards without adding model-visible context', async () => {
|
||||
const persistence = new RecordingAgentPersistence([
|
||||
{
|
||||
|
|
@ -583,6 +785,18 @@ function withMetadata(events: readonly AgentRecord[]): readonly AgentRecord[] {
|
|||
];
|
||||
}
|
||||
|
||||
function textContent(
|
||||
message:
|
||||
| { readonly content: readonly { readonly type: string; readonly text?: string }[] }
|
||||
| undefined,
|
||||
): string {
|
||||
return (
|
||||
message?.content
|
||||
.map((part) => (part.type === 'text' && typeof part.text === 'string' ? part.text : ''))
|
||||
.join('') ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function resumeHistory(): AgentRecord[] {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
55
packages/agent-core/test/agent/skill-prompt.test.ts
Normal file
55
packages/agent-core/test/agent/skill-prompt.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
renderModelToolSkillPrompt,
|
||||
renderUserSlashSkillPrompt,
|
||||
} from '../../src/agent/skill/prompt';
|
||||
|
||||
/**
|
||||
* Regression coverage for the skill directory being surfaced on the
|
||||
* `<kimi-skill-loaded>` block. Without `dir`, an agent that loads a skill
|
||||
* cannot locate the skill's bundled resources (scripts, templates) by
|
||||
* relative path — the bug this guards against.
|
||||
*/
|
||||
describe('renderSkillLoadedBlock skill directory', () => {
|
||||
const base = {
|
||||
skillName: 'review',
|
||||
skillArgs: '',
|
||||
skillContent: 'body',
|
||||
skillSource: 'user' as const,
|
||||
skillDir: '/home/user/.kimi-code/skills/review',
|
||||
};
|
||||
|
||||
it('includes the skill directory for model-tool activations', () => {
|
||||
const text = renderModelToolSkillPrompt({ ...base, trigger: 'model-tool' });
|
||||
expect(text).toContain('dir="/home/user/.kimi-code/skills/review"');
|
||||
});
|
||||
|
||||
it('includes the skill directory for nested-skill activations', () => {
|
||||
const text = renderModelToolSkillPrompt({ ...base, trigger: 'nested-skill' });
|
||||
expect(text).toContain('dir="/home/user/.kimi-code/skills/review"');
|
||||
});
|
||||
|
||||
it('includes the skill directory for user-slash activations', () => {
|
||||
const text = renderUserSlashSkillPrompt(base);
|
||||
expect(text).toContain('dir="/home/user/.kimi-code/skills/review"');
|
||||
});
|
||||
|
||||
it('XML-escapes the skill directory', () => {
|
||||
const text = renderUserSlashSkillPrompt({
|
||||
...base,
|
||||
skillDir: '/skills/a&b/"weird"/<dir>',
|
||||
});
|
||||
expect(text).toContain('dir="/skills/a&b/"weird"/<dir>"');
|
||||
expect(text).not.toContain('dir="/skills/a&b/"weird"/<dir>"');
|
||||
});
|
||||
|
||||
it('omits the dir attribute when no directory is supplied', () => {
|
||||
const { skillDir: _omit, ...withoutDir } = base;
|
||||
const text = renderUserSlashSkillPrompt(withoutDir);
|
||||
expect(text).not.toContain('dir=');
|
||||
// Other attributes still render so the block is well-formed.
|
||||
expect(text).toContain('name="review"');
|
||||
expect(text).toContain('source="user"');
|
||||
});
|
||||
});
|
||||
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