diff --git a/.changeset/quiet-compaction-final-answer.md b/.changeset/quiet-compaction-final-answer.md deleted file mode 100644 index 7fd86399b..000000000 --- a/.changeset/quiet-compaction-final-answer.md +++ /dev/null @@ -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. diff --git a/.changeset/read-media-header-types.md b/.changeset/read-media-header-types.md deleted file mode 100644 index fc6711415..000000000 --- a/.changeset/read-media-header-types.md +++ /dev/null @@ -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. diff --git a/.changeset/sse-mcp-servers.md b/.changeset/sse-mcp-servers.md deleted file mode 100644 index b5dce2f66..000000000 --- a/.changeset/sse-mcp-servers.md +++ /dev/null @@ -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. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index b292b7096..0f8f24767 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -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 diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 133b1942d..b4315f65a 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -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" diff --git a/apps/kimi-code/src/cli/update/cache.ts b/apps/kimi-code/src/cli/update/cache.ts index 83132bf36..63885e2bb 100644 --- a/apps/kimi-code/src/cli/update/cache.ts +++ b/apps/kimi-code/src/cli/update/cache.ts @@ -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 = 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(); diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts index 5664f021f..4990568c9 100644 --- a/apps/kimi-code/src/cli/update/cdn.ts +++ b/apps/kimi-code/src/cli/update/cdn.ts @@ -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 { + 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 { - 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 { + 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 { + const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null); + if (manifest !== null) { + return { latest: manifest.version, manifest }; + } + const latest = await fetchLatestVersionFromCdn(fetchImpl); + return { latest, manifest: null }; +} diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 3d28b5609..fe893a50a 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -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 { + fallbackManifest: UpdateManifest | null, +): Promise { let timeout: ReturnType | undefined; + const fallback: UserVisibleUpdateTarget = { + target: fallbackTarget, + manifest: fallbackManifest, + }; try { const refresh = refreshUpdateCache() - .then((refreshed) => selectUpdateTarget(currentVersion, refreshed.latest)) - .catch(() => fallbackTarget); - const fallback = new Promise((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((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 { 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 { 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( diff --git a/apps/kimi-code/src/cli/update/refresh.ts b/apps/kimi-code/src/cli/update/refresh.ts index 20bcdf0de..938a4a0fa 100644 --- a/apps/kimi-code/src/cli/update/refresh.ts +++ b/apps/kimi-code/src/cli/update/refresh.ts @@ -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; + /** 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; readonly writeCache: (cache: UpdateCache) => Promise; readonly now: () => Date; } @@ -16,16 +17,17 @@ export async function refreshUpdateCache( overrides: Partial = {}, ): Promise { 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; diff --git a/apps/kimi-code/src/cli/update/rollout.ts b/apps/kimi-code/src/cli/update/rollout.ts new file mode 100644 index 000000000..a4ca50256 --- /dev/null +++ b/apps/kimi-code/src/cli/update/rollout.ts @@ -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 + * `/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, + filePath: string = getUpdateRolloutLogFile(), +): Promise { + 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> = process.env, +): boolean { + const value = (env['KIMI_CODE_EXPERIMENTAL_FLAG'] ?? '').trim().toLowerCase(); + return ['1', 'true', 'yes', 'on'].includes(value); +} diff --git a/apps/kimi-code/src/cli/update/types.ts b/apps/kimi-code/src/cli/update/types.ts index ff4c93c1d..b03af767d 100644 --- a/apps/kimi-code/src/cli/update/types.ts +++ b/apps/kimi-code/src/cli/update/types.ts @@ -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, }; } diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 623f7e715..9ec4223ce 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -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'; diff --git a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts index ad9044d7d..9cc7104b1 100644 --- a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts +++ b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts @@ -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, '…')); } } diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 65d17d860..3db1de3cf 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -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, '…')); } } diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index a8c0ceb14..f837dc046 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -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 { diff --git a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts index d85b80170..8a3150bf4 100644 --- a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -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 { diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts index 8680b4a03..3fefe86c0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -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 { diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts index e2df47676..c35d55399 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -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 { diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index 8848d8b7c..bb5ec512f 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -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; 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))); } diff --git a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts index d95eeb3f9..cc8ef4d56 100644 --- a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts +++ b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts @@ -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(); } } diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index cb062b575..7b002ef66 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -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, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts index aaadd1514..3d968bad8 100644 --- a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -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, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts index 0fa31794a..5ca2acf02 100644 --- a/apps/kimi-code/src/tui/components/messages/cron-message.ts +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -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; diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index 4ebd9d1ee..be9df81af 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -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; } diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index cbefc2300..d6b2c6207 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -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); diff --git a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts index b54c378af..0308fada7 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -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, '…')]; } } diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index fe6486374..6ff652d4f 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -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; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 983d1bf8c..4bd2c61d0 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -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, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index 0c1019068..e619ff19d 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -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)]; } } diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 71579e1c5..1eeba55e2 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -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, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index dd99d3c26..778a0407a 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -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, '…')); } } diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 847a6434d..8038be32d 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -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; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 0337785f0..f29d39b8c 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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 { + async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise { 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 { + 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 { 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 { - await this.fetchSessions(); - this.mountSessionPicker({ - onCancel: () => { - this.hideSessionPicker(); - }, + await this.openSessionPicker({ + applyStartupModes: false, + closeOnCancel: false, + forwardEditorExit: false, }); } private async bootstrapFromPicker(): Promise { - 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 { + 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 { + 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 { + 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}`, { diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 8ff43694b..6a9594f01 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -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, diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-native.ts b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts index 051e9af46..cd136b403 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-native.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts @@ -18,6 +18,7 @@ export interface ClipboardModule { availableFormats?(): string[]; hasText?(): boolean; getText?(): Promise; + setText?(text: string): Promise; hasImage(): boolean; getImageBinary(): Promise>; } diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts new file mode 100644 index 000000000..8a8295f57 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts @@ -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 { + 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 { + const clipboardModule = clipboard; + if (clipboardModule?.setText !== undefined) { + try { + await clipboardModule.setText(text); + return; + } catch { + // Fall back to platform clipboard commands below. + } + } + + await copyWithPlatformCommand(text); +} diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index c5e246876..eb58a5265 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -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: `/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: `/user-history/.jsonl`. diff --git a/apps/kimi-code/src/utils/process/external-editor.ts b/apps/kimi-code/src/utils/process/external-editor.ts index 1f8b588c2..7881238b6 100644 --- a/apps/kimi-code/src/utils/process/external-editor.ts +++ b/apps/kimi-code/src/utils/process/external-editor.ts @@ -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); -} diff --git a/apps/kimi-code/src/utils/shell-quote.ts b/apps/kimi-code/src/utils/shell-quote.ts new file mode 100644 index 000000000..eafe1c1e8 --- /dev/null +++ b/apps/kimi-code/src/utils/shell-quote.ts @@ -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('"', '\\"')}"`; +} diff --git a/apps/kimi-code/test/cli/update/cache.test.ts b/apps/kimi-code/test/cli/update/cache.test.ts index b7760fb84..3009c6709 100644 --- a/apps/kimi-code/test/cli/update/cache.test.ts +++ b/apps/kimi-code/test/cli/update/cache.test.ts @@ -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', () => { diff --git a/apps/kimi-code/test/cli/update/cdn.test.ts b/apps/kimi-code/test/cli/update/cdn.test.ts index 3127bd71d..7eba81080 100644 --- a/apps/kimi-code/test/cli/update/cdn.test.ts +++ b/apps/kimi-code/test/cli/update/cdn.test.ts @@ -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): 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 = [ + ['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((_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((_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(); + } + }); +}); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 33c4d42a3..a96d1445b 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -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('../../../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('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 { + 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 { 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', () => { diff --git a/apps/kimi-code/test/cli/update/refresh.test.ts b/apps/kimi-code/test/cli/update/refresh.test.ts index 16a18d2f3..ceb1306f7 100644 --- a/apps/kimi-code/test/cli/update/refresh.test.ts +++ b/apps/kimi-code/test/cli/update/refresh.test.ts @@ -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 () => { diff --git a/apps/kimi-code/test/cli/update/rollout.test.ts b/apps/kimi-code/test/cli/update/rollout.test.ts new file mode 100644 index 000000000..615906148 --- /dev/null +++ b/apps/kimi-code/test/cli/update/rollout.test.ts @@ -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 { + 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([ + [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); + }); +}); diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts index 8995f07cf..31b53b1d9 100644 --- a/apps/kimi-code/test/cli/upgrade.test.ts +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -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; @@ -48,7 +53,9 @@ function createDeps(overrides: { ) => Promise>().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'); + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts index 9dda8ff28..440a4ad06 100644 --- a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts @@ -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); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 4233934db..cc3a2ff21 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -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); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts new file mode 100644 index 000000000..939242383 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts @@ -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); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts index 848c8f5e1..b46ba311c 100644 --- a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts @@ -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); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts index 5b0fdeeb1..068b7f00f 100644 --- a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -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 ') { diff --git a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts index 258f044d2..547b30a66 100644 --- a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -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({ diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 4b6749000..70f7dfe30 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -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'); + }); }); diff --git a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts new file mode 100644 index 000000000..fb070f6c1 --- /dev/null +++ b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts @@ -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; + 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(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index 929a02e37..308c3aa21 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -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 { diff --git a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts index 7a81f6f27..fbc2efbc5 100644 --- a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts @@ -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); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts index 73d87f394..316c30af7 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts @@ -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); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index 0436e6681..b5b89ab0d 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -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', () => { diff --git a/apps/kimi-code/test/tui/components/messages/notice.test.ts b/apps/kimi-code/test/tui/components/messages/notice.test.ts index 0003ac01c..63c60a12e 100644 --- a/apps/kimi-code/test/tui/components/messages/notice.test.ts +++ b/apps/kimi-code/test/tui/components/messages/notice.test.ts @@ -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); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index 7f1385ffe..40f609be1 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -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); + } + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 6d9147030..b3fb71e77 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -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( { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts index 0c6e37d8d..bcab35a48 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts @@ -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); + } + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index fb47e3d14..5540b9b75 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -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. diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index 60029b40c..4c2cb3642 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -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); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts index dcf786129..4d079a1e9 100644 --- a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts +++ b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts @@ -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); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index eb9dbd986..8be57e91c 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -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(); }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 2906e2f2f..cb334fad1 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -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); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index e014fa83e..a5ae05cf1 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -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(); 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 }).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 }).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((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 }).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 }).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 }).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 }).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 }).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({ diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts new file mode 100644 index 000000000..72b036352 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts @@ -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 }; +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; + spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType); + + 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/, + ); + }); +}); diff --git a/apps/kimi-code/tsdown.config.ts b/apps/kimi-code/tsdown.config.ts index 0e5c87220..2645584e1 100644 --- a/apps/kimi-code/tsdown.config.ts +++ b/apps/kimi-code/tsdown.config.ts @@ -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', + }, }); diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md index 9855c46bb..17ad91d3c 100644 --- a/docs/en/configuration/data-locations.md +++ b/docs/en/configuration/data-locations.md @@ -50,7 +50,8 @@ $KIMI_CODE_HOME (default: ~/.kimi-code) ├── updates/ │ ├── latest.json │ ├── install.json -│ └── install.lock +│ ├── install.lock +│ └── rollout.log └── user-history/ └── .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 diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index e18a2ee08..aced4fb31 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -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 diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index cb0fa66ce..9dd6d102b 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -50,7 +50,8 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) ├── updates/ │ ├── latest.json │ ├── install.json -│ └── install.lock +│ ├── install.lock +│ └── rollout.log └── user-history/ └── .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` 记录每次更新检查命中的灰度分批情况,可用于排查设备何时能收到新版本。 ## 输入历史 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index a1e8029b7..fbd2f49d7 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -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) ### 修复 diff --git a/packages/acp-adapter/CHANGELOG.md b/packages/acp-adapter/CHANGELOG.md index e6e2f60a3..b207b2cf2 100644 --- a/packages/acp-adapter/CHANGELOG.md +++ b/packages/acp-adapter/CHANGELOG.md @@ -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 diff --git a/packages/acp-adapter/package.json b/packages/acp-adapter/package.json index a4d1eba63..4dca9c21f 100644 --- a/packages/acp-adapter/package.json +++ b/packages/acp-adapter/package.json @@ -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", diff --git a/packages/agent-core/CHANGELOG.md b/packages/agent-core/CHANGELOG.md index 94b25f66a..865d71cfe 100644 --- a/packages/agent-core/CHANGELOG.md +++ b/packages/agent-core/CHANGELOG.md @@ -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 diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index 78125c5df..18249385b 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -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", diff --git a/packages/agent-core/src/agent/compaction/strategy.ts b/packages/agent-core/src/agent/compaction/strategy.ts index e0a67eb52..2f69bfb07 100644 --- a/packages/agent-core/src/agent/compaction/strategy.ts +++ b/packages/agent-core/src/agent/compaction/strategy.ts @@ -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; +} diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 4746eeb36..9bd25f209 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -20,7 +20,13 @@ const TOOL_EMPTY_STATUS = 'Tool output is empty.'; const TOOL_EMPTY_ERROR_STATUS = 'ERROR: Tool execution failed. Tool output is empty.'; 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', diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 1b90276f9..1f048db1d 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -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; } diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 8bf050398..c00f169fd 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -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 }; } diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index 835c34465..eb07d10a0 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -19,8 +19,6 @@ export interface AgentRecordEvents { metadata: { protocol_version: string; created_at: number; - app_version?: string; - resumed?: boolean; }; forked: {}; diff --git a/packages/agent-core/src/agent/replay/index.ts b/packages/agent-core/src/agent/replay/index.ts index c74747928..440579a15 100644 --- a/packages/agent-core/src/agent/replay/index.ts +++ b/packages/agent-core/src/agent/replay/index.ts @@ -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(), diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index 4a9d55332..684122fb1 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -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, }), }, ]; diff --git a/packages/agent-core/src/agent/skill/prompt.ts b/packages/agent-core/src/agent/skill/prompt.ts index 2ea21076b..54968b7ab 100644 --- a/packages/agent-core/src/agent/skill/prompt.ts +++ b/packages/agent-core/src/agent/skill/prompt.ts @@ -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], ]; diff --git a/packages/agent-core/src/agent/skill/types.ts b/packages/agent-core/src/agent/skill/types.ts new file mode 100644 index 000000000..19413b8a2 --- /dev/null +++ b/packages/agent-core/src/agent/skill/types.ts @@ -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; +} diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 76a49f2ac..b80a87f31 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -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; diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index e03b8469c..49d8d8105 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -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 = + ''; const S_IFMT = 0o170000; const S_IFREG = 0o100000; @@ -16,7 +18,7 @@ export async function prepareSystemPromptContext( brandHome?: string, ): Promise { 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 = 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 { diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index 0436f944b..d3b0084cc 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -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. diff --git a/packages/agent-core/src/profile/types.ts b/packages/agent-core/src/profile/types.ts index fc4c5b01e..27d407c3b 100644 --- a/packages/agent-core/src/profile/types.ts +++ b/packages/agent-core/src/profile/types.ts @@ -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(), diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 851ac58af..7258edfc8 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -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 = 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, }); } diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index f37001142..7ec5bb49b 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -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': diff --git a/packages/agent-core/src/skill/builtin/index.ts b/packages/agent-core/src/skill/builtin/index.ts index af06bb1e4..e73204c06 100644 --- a/packages/agent-core/src/skill/builtin/index.ts +++ b/packages/agent-core/src/skill/builtin/index.ts @@ -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); diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index 00368e391..c3052491a 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -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(); private readonly byPluginAndName = new Map(); private readonly roots: string[] = []; diff --git a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts index d437e78fa..2ee932dd0 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts @@ -141,6 +141,7 @@ export class SkillTool implements BuiltinTool { skillArgs, skillContent, skillSource: skill.source, + skillDir: skill.dir, trigger: promptTrigger, }), }, diff --git a/packages/agent-core/src/tools/support/list-directory.ts b/packages/agent-core/src/tools/support/list-directory.ts index d1597a73d..476e9793b 100644 --- a/packages/agent-core/src/tools/support/list-directory.ts +++ b/packages/agent-core/src/tools/support/list-directory.ts @@ -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 { +export async function listDirectory( + kaos: Kaos, + workDir: string = kaos.getcwd(), + options: ListDirectoryOptions = {}, +): Promise { 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); diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 1090b6f6f..8e5f515cc 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -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": "